diff --git a/backend/database.py b/backend/database.py
index 1a70aa5..eeb1add 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -1747,6 +1747,71 @@ def _migrate(conn_factory):
)
""")
+ 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 (
diff --git a/backend/main.py b/backend/main.py
index 8b259f7..229a856 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
+from brotli_asgi import BrotliMiddleware
from contextlib import asynccontextmanager
from database import init_db
@@ -134,6 +135,7 @@ class MediaCacheMiddleware(BaseHTTPMiddleware):
return response
app.add_middleware(MediaCacheMiddleware)
+app.add_middleware(BrotliMiddleware, minimum_size=1000, quality=4)
app.add_middleware(GZipMiddleware, minimum_size=1000)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index c4e830c..414ec32 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -15,3 +15,4 @@ odfpy==1.4.1
polyline==2.0.2
fpdf2==2.8.3
python-dateutil>=2.9
+brotli-asgi==1.4.0
diff --git a/backend/routes/admin.py b/backend/routes/admin.py
index cd3fee1..c2ffebb 100644
--- a/backend/routes/admin.py
+++ b/backend/routes/admin.py
@@ -356,11 +356,15 @@ async def list_users(
# ------------------------------------------------------------------
@router.patch("/users/{uid}")
async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
- # Rollenwechsel nur für Admins
+ # Rollenwechsel + Privileg-Flags nur für Admins
if data.rolle is not None and user["rolle"] != "admin":
raise HTTPException(403, "Rollenwechsel nur für Admins.")
if data.rolle and data.rolle not in ("user", "moderator", "admin"):
raise HTTPException(400, "Ungültige Rolle.")
+ if data.is_moderator is not None and user["rolle"] != "admin":
+ raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.")
+ if data.is_social_media is not None and user["rolle"] != "admin":
+ raise HTTPException(403, "is_social_media darf nur von Admins geändert werden.")
with db() as conn:
target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone()
diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py
index d742ccc..bde0986 100644
--- a/backend/routes/adoption.py
+++ b/backend/routes/adoption.py
@@ -13,10 +13,17 @@ import os
import math
import logging
import asyncio
+import uuid
import httpx
from datetime import datetime, timedelta
-from fastapi import APIRouter, Query, BackgroundTasks
+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()
@@ -290,3 +297,251 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
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]
diff --git a/backend/routes/auth.py b/backend/routes/auth.py
index 13d857d..4772ae6 100644
--- a/backend/routes/auth.py
+++ b/backend/routes/auth.py
@@ -26,12 +26,14 @@ _SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_P
def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
return
+ import html as _html
from routes.outreach import _send_smtp
from mailer import email_html
url = f"{_APP_URL}/api/auth/verify-email/{token}"
subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse"
+ _ename = _html.escape(name)
body_html = f"""
-
Hallo {name} ,
+ Hallo {_ename} ,
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
@@ -306,13 +308,15 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
"UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?",
(token, expires, user["id"])
)
+ import html as _html
app_url = os.getenv("APP_URL", "https://banyaro.app")
url = f"{app_url}/#reset-password?token={token}"
subject = "Ban Yaro — Passwort zurücksetzen"
from routes.outreach import _send_smtp
from mailer import email_html
+ _ename = _html.escape(user['name'])
body_html = f"""
- Hallo {user['name']} ,
+ Hallo {_ename} ,
du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py
index c7a9066..a44faa0 100644
--- a/backend/routes/dogs.py
+++ b/backend/routes/dogs.py
@@ -315,11 +315,13 @@ async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user
values = list(fields.values()) + [dog_id, user["id"]]
with db() as conn:
- conn.execute(
+ updated = conn.execute(
f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values
- )
+ ).rowcount
+ if not updated:
+ raise HTTPException(404, "Hund nicht gefunden.")
dog = conn.execute(
- "SELECT * FROM dogs WHERE id=?", (dog_id,)
+ "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
return dict(dog)
@@ -413,8 +415,8 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)):
os.remove(path)
with db() as conn:
conn.execute(
- "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?",
- (dog_id,)
+ "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=? AND user_id=?",
+ (dog_id, user["id"])
)
diff --git a/backend/routes/forum.py b/backend/routes/forum.py
index fe730d5..2834ab0 100644
--- a/backend/routes/forum.py
+++ b/backend/routes/forum.py
@@ -641,7 +641,7 @@ async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_c
# GET /api/forum/members/map
# ------------------------------------------------------------------
@router.get("/members/map")
-async def members_map():
+async def members_map(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute(
"""SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname,
diff --git a/backend/routes/jobs.py b/backend/routes/jobs.py
index 8714ae2..59c73c2 100644
--- a/backend/routes/jobs.py
+++ b/backend/routes/jobs.py
@@ -1,5 +1,6 @@
"""BAN YARO — Social-Media-Job Bewerbungs-System"""
+import html as _html
import os
import uuid
from datetime import datetime, timedelta
@@ -98,8 +99,9 @@ async def apply(
# Bestätigungs-Mail an Bewerber
try:
+ _name = _html.escape(name)
body = f"""
- Hallo {name} ,
+ Hallo {_name} ,
deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen.
Wir melden uns bald bei dir!
@@ -110,7 +112,7 @@ async def apply(
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!",
+ f"Hallo {_name}, deine Bewerbung ist eingegangen!",
)
except Exception:
pass
@@ -119,16 +121,22 @@ async def apply(
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"""
Neue Job-Bewerbung eingegangen:
- Name {name}
- E-Mail {email}
- Hund {dog_name} ({dog_rasse})
- Social {social_handle}
+ Name {_ename}
+ E-Mail {_eemail}
+ Hund {_edog_name} ({_edog_rasse})
+ Social {_ehandle}
Anhänge {len([f for f in files if f.filename])} Datei(en)
- {motivation[:300]}{"…" if len(motivation)>300 else ""}
"""
+ {_emotivation}{"…" if len(motivation)>300 else ""}
"""
await send_email(
admin_email,
f"[Banyaro Jobs] Neue Bewerbung — {name}",
@@ -293,16 +301,17 @@ async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)):
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"Hallo {name} ,
wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!
"),
+ f"Hallo {_ename} ,
wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!
"),
"accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉",
- f"Hallo {name} ,
wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro! Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!
"),
+ f"Hallo {_ename} ,
wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro! Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!
"),
"rejected": ("Deine Bewerbung bei Ban Yaro",
- f"Hallo {name} ,
vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!
"),
+ f"Hallo {_ename} ,
vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!
"),
}
- subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"Hallo {name},
"))
- note_html = f'{note}
' if note else ""
+ subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"Hallo {_ename},
"))
+ note_html = f'{_html.escape(note)}
' if note else ""
body = body_start + note_html
async def _send():
diff --git a/backend/routes/litters.py b/backend/routes/litters.py
index 82ba96f..ddc810c 100644
--- a/backend/routes/litters.py
+++ b/backend/routes/litters.py
@@ -265,13 +265,14 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
eltern = conn.execute(
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
).fetchone()
+ import html as _html
welfare_body = f"""
Kritischer Tierschutz-Hinweis bestätigt
- Züchter {zuechter}
- Zwinger {zwinger}
- Vater {eltern['vater_name'] or '—'}
- Mutter {eltern['mutter_name'] or '—'}
+ Züchter {_html.escape(zuechter)}
+ Zwinger {_html.escape(zwinger)}
+ Vater {_html.escape(eltern['vater_name'] or '—')}
+ Mutter {_html.escape(eltern['mutter_name'] or '—')}
Wurf-ID #{litter_id}
"""
try:
diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py
index 1357a85..fa74871 100644
--- a/backend/routes/moderation.py
+++ b/backend/routes/moderation.py
@@ -268,6 +268,9 @@ async def mod_poi_edit_action(edit_id: int, data: dict,
raise HTTPException(409, "Korrektur wurde bereits bearbeitet.")
if action == "approve":
+ _ALLOWED_POI_FIELDS = {"opening_hours", "phone", "website", "name"}
+ if edit["field"] not in _ALLOWED_POI_FIELDS:
+ raise HTTPException(400, f"Ungültiges Feld: {edit['field']}")
conn.execute(
f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?",
(edit["new_value"], edit["osm_id"])
diff --git a/backend/routes/streak.py b/backend/routes/streak.py
index ea03522..c387a68 100644
--- a/backend/routes/streak.py
+++ b/backend/routes/streak.py
@@ -29,7 +29,7 @@ async def get_leaderboard(user=Depends(get_current_user)):
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 OR d.user_id = ts.user_id)
+ AND d.is_public = 1
ORDER BY ts.current_streak DESC
LIMIT 10
""").fetchall()
diff --git a/backend/routes/weather.py b/backend/routes/weather.py
index 319cfd2..fced719 100644
--- a/backend/routes/weather.py
+++ b/backend/routes/weather.py
@@ -3,8 +3,9 @@ BAN YARO — Wetter-API
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
"""
-from fastapi import APIRouter, Query, HTTPException
+from fastapi import APIRouter, Query, HTTPException, Depends
import weather as weather_module
+from auth import get_current_user
router = APIRouter()
@@ -18,3 +19,15 @@ async def get_weather(
return await weather_module.get_weather_for_location(lat, lon)
except Exception as exc:
raise HTTPException(503, f'Wetter nicht verfügbar: {exc}')
+
+
+@router.get('/forecast')
+async def get_weather_forecast(
+ lat: float = Query(..., ge=-90, le=90),
+ lon: float = Query(..., ge=-180, le=180),
+ user=Depends(get_current_user),
+):
+ try:
+ return await weather_module.get_forecast(lat, lon)
+ except Exception as exc:
+ raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}')
diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py
index bf3c19c..45f5bfb 100644
--- a/backend/routes/wiki.py
+++ b/backend/routes/wiki.py
@@ -317,19 +317,24 @@ async def submit_foto(
if not rights_confirmed:
raise HTTPException(400, "Bildrechte-Bestätigung fehlt.")
- # Dateiformat prüfen
- ct = file.content_type or ""
- if not ct.startswith("image/"):
- raise HTTPException(400, "Nur Bilddateien erlaubt.")
+ _IMAGE_MAGIC = [
+ b"\xff\xd8\xff", # JPEG
+ b"\x89PNG\r\n\x1a\n", # PNG
+ b"RIFF", # WebP (RIFF....WEBP)
+ b"GIF87a", b"GIF89a", # GIF
+ ]
os.makedirs(SUBMIT_DIR, exist_ok=True)
- ts = int(time.time())
- filename = f"{slug}_{user['id']}_{ts}.jpg"
- path = os.path.join(SUBMIT_DIR, filename)
-
+ ts = int(time.time())
content = await file.read()
if len(content) > 8 * 1024 * 1024:
raise HTTPException(400, "Datei zu groß (max. 8 MB).")
+
+ if not any(content.startswith(magic) for magic in _IMAGE_MAGIC):
+ raise HTTPException(400, "Nur Bilddateien erlaubt (JPEG, PNG, WebP, GIF).")
+
+ filename = f"{slug}_{user['id']}_{ts}.jpg"
+ path = os.path.join(SUBMIT_DIR, filename)
with open(path, "wb") as f:
f.write(content)
diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg
index a9189b9..2b8028e 100644
--- a/backend/static/icons/phosphor.svg
+++ b/backend/static/icons/phosphor.svg
@@ -270,4 +270,1361 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/static/js/api.js b/backend/static/js/api.js
index a40b99d..c6b26da 100644
--- a/backend/static/js/api.js
+++ b/backend/static/js/api.js
@@ -6,69 +6,84 @@
const API = (() => {
+ // ----------------------------------------------------------
+ // Request-Deduplication: gleiche GET-URL nur einmal in-flight
+ // ----------------------------------------------------------
+ const _inflight = new Map();
+
// ----------------------------------------------------------
// Interner HTTP-Kern
// ----------------------------------------------------------
- async function _request(method, path, body = null, options = {}) {
+ async function _doRequest(method, path, body, attempt) {
const config = {
method,
headers: { 'Content-Type': 'application/json' },
- credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet
+ credentials: 'include',
};
if (body && !(body instanceof FormData)) {
config.body = JSON.stringify(body);
} else if (body instanceof FormData) {
- delete config.headers['Content-Type']; // Browser setzt multipart/form-data
+ delete config.headers['Content-Type'];
config.body = body;
}
- // JWT aus localStorage als Bearer (für API-Calls die das brauchen)
const token = localStorage.getItem('by_token');
- if (token) {
- config.headers['Authorization'] = `Bearer ${token}`;
- }
+ if (token) config.headers['Authorization'] = `Bearer ${token}`;
let response;
try {
response = await fetch(`/api${path}`, config);
- } catch (err) {
- const offlineMsg = 'Kein Internet — du bist offline.';
- if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000);
- throw new APIError(offlineMsg, 0, 'network');
+ } catch {
+ // Netzwerkfehler: bei GET bis zu 2 Retry-Versuche
+ if (method === 'GET' && attempt < 2) {
+ await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
+ return _doRequest(method, path, body, attempt + 1);
+ }
+ const msg = 'Kein Internet — du bist offline.';
+ if (window.UI?.toast) UI.toast.warning(msg, 4000);
+ throw new APIError(msg, 0, 'network');
}
- // 204 No Content
if (response.status === 204) return null;
let data;
- try {
- data = await response.json();
- } catch {
- data = null;
- }
+ try { data = await response.json(); } catch { data = null; }
if (!response.ok) {
const message = data?.detail || data?.message || `Fehler ${response.status}`;
- // SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück
- const isOffline = response.status === 503 && message.startsWith('Offline');
- if (isOffline && window.UI && UI.toast) {
- UI.toast.warning('Kein Internet — du bist offline.', 4000);
+ const isSwOffline = response.status === 503 && message.startsWith('Offline');
+
+ // Retry: GET auf echte 5xx (nicht SW-generierte Offline-503)
+ if (method === 'GET' && response.status >= 500 && !isSwOffline && attempt < 2) {
+ await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt)));
+ return _doRequest(method, path, body, attempt + 1);
}
- throw new APIError(message, response.status, isOffline ? 'network' : data?.code);
+
+ if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000);
+ throw new APIError(message, response.status, isSwOffline ? 'network' : data?.code);
}
- // SW hat die Anfrage in die Offline-Queue eingereiht
if (data?._queued) {
- if (typeof UI !== 'undefined' && UI.toast) {
+ if (typeof UI !== 'undefined' && UI.toast)
UI.toast.info('Offline gespeichert — wird automatisch synchronisiert');
- }
return data;
}
return data;
}
+ async function _request(method, path, body = null) {
+ // GET-Deduplication: laufende identische Anfragen zusammenfassen
+ if (method === 'GET') {
+ if (_inflight.has(path)) return _inflight.get(path);
+ const promise = _doRequest('GET', path, null, 0).finally(() => _inflight.delete(path));
+ _inflight.set(path, promise);
+ return promise;
+ }
+ return _doRequest(method, path, body, 0);
+ }
+
// ----------------------------------------------------------
// Öffentliche HTTP-Methoden
// ----------------------------------------------------------
@@ -426,8 +441,9 @@ const API = (() => {
// WETTER
// ----------------------------------------------------------
const weather = {
- alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
- get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
+ alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
+ get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); },
+ forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); },
};
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/adoption.js b/backend/static/js/pages/adoption.js
index 8e1bc3a..b20682e 100644
--- a/backend/static/js/pages/adoption.js
+++ b/backend/static/js/pages/adoption.js
@@ -17,6 +17,8 @@ window.Page_adoption = (() => {
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
@@ -90,6 +92,12 @@ window.Page_adoption = (() => {
border-bottom:2px solid transparent;font-size:var(--text-sm)">
${UI.icon('house-line')} Tierheime
+
+ ${UI.icon('heart')} Weitervermittlung
+
@@ -213,12 +221,43 @@ window.Page_adoption = (() => {
}
}
+ 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);
@@ -455,6 +494,442 @@ window.Page_adoption = (() => {
`;
}
+ // ------------------------------------------------------------------
+ // TAB: WEITERVERMITTLUNG (Community)
+ // ------------------------------------------------------------------
+ function _renderCommunity(content) {
+ if (!content) return;
+
+ const listings = _communityData || [];
+ const isLoggedIn = !!_appState?.user;
+
+ const fabHtml = isLoggedIn ? `
+
+ ${UI.icon('plus')}
+
+ ` : '';
+
+ if (!listings.length) {
+ content.innerHTML = `
+
+
🐾
+
Noch keine Hunde zur Weitervermittlung
+
+ Hier können Halter Hunde privat zur Weitervermittlung anbieten —
+ zum Beispiel bei Umzug, Krankheit oder Allergie.
+
+ ${isLoggedIn ? `
+
+ ${UI.icon('plus')} Hund zur Vermittlung anbieten
+
+ ` : `
+
+ Bitte anmelden, um ein Inserat zu erstellen.
+
+ `}
+
+ ${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 = `
+
+ ${listings.length} Inserat${listings.length !== 1 ? 'e' : ''} zur Weitervermittlung
+
+
+ ${listings.map(l => _communityCard(l)).join('')}
+
+
+ ${isLoggedIn && _myListings && _myListings.length ? `
+
+
Meine Inserate
+
+ ${_myListings.map(l => _myListingRow(l)).join('')}
+
+
+ ` : ''}
+
+ ${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
+ ? ` `
+ : '🐾
';
+
+ 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
+ ? `
+ ✓ Bereits gemeldet
+ `
+ : `
+ Interesse bekunden
+ `;
+
+ return `
+
+
+
+ ${foto}
+ ${!isActive ? `
+
+
+ ${_esc(statusLabel)}
+
+
+ ` : ''}
+
+
+
+
+ ${_esc(l.name)}
+
+ ${l.rasse ? `
+ ${_esc(l.rasse)}
+
` : ''}
+
+
+ ${alterLabel ? `
+ ${_esc(alterLabel)}
+ ` : ''}
+ ${genderIcon ? `
+ ${genderIcon}
+ ` : ''}
+ ${distTxt ? `
+ ${_esc(distTxt)}
+ ` : ''}
+
+ ${ort ? `
${_esc(ort)}
` : ''}
+ ${l.beschreibung ? `
+ ${_esc(l.beschreibung)}
+
` : ''}
+ ${l.interesse_count ? `
+ ❤️ ${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''}
+
` : ''}
+
+ ${interestBtn}
+
+
+
+ `;
+ }
+
+ function _myListingRow(l) {
+ const statusOptions = [
+ { value: 'active', label: 'Aktiv' },
+ { value: 'reserved', label: 'Reserviert' },
+ { value: 'adopted', label: 'Vermittelt' },
+ ];
+ return `
+
+
+
+ ${_esc(l.name)}
+
+
+ ${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''}
+
+
+
+ ${statusOptions.map(o => `
+ ${o.label}
+ `).join('')}
+
+
+ ${UI.icon('trash')} Löschen
+
+
+ `;
+ }
+
+ // ------------------------------------------------------------------
+ // 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 = `
+
+ `;
+ const footer = `
+
+
+ ${UI.icon('heart')} Interesse bekunden
+
+ Abbrechen
+
+ `;
+
+ 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 = `
+
+
+ Name *
+
+
+
+ Rasse (optional)
+
+
+
+
+ Alter
+
+ Unbekannt
+ Welpe <6Mo
+ Jung 6Mo–2J
+ Adult 2–8J
+ Senior >8J
+
+
+
+ Geschlecht
+
+ Unbekannt
+ Männlich
+ Weiblich
+
+
+
+
+
+
+ Hintergrund (optional)
+
+
+
+ Foto (optional)
+
+
+
+ `;
+
+ const footer = `
+
+
+ ${UI.icon('plus')} Inserat erstellen
+
+ Abbrechen
+
+ `;
+
+ 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
// ----------------------------------------------------------
diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js
index f8488b6..8829bb6 100644
--- a/backend/static/js/pages/settings.js
+++ b/backend/static/js/pages/settings.js
@@ -263,6 +263,12 @@ window.Page_settings = (() => {
Kalender abonnieren
›
+