Chore: Sprint32-36 Zwischenstand — alle Änderungen aus dieser Session committen
This commit is contained in:
parent
f4052fbb7d
commit
747c353444
20 changed files with 3115 additions and 63 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
|
||||
<p style="margin:0 0 16px">Hallo <b>{_ename}</b>,</p>
|
||||
<p style="margin:0 0 16px">
|
||||
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
|
||||
</p>
|
||||
|
|
@ -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"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
|
||||
<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!
|
||||
|
|
@ -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"""
|
||||
<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>{name}</b></td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">E-Mail</td><td>{email}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">Hund</td><td>{dog_name} ({dog_rasse})</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#888">Social</td><td>{social_handle}</td></tr>
|
||||
<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">{motivation[:300]}{"…" if len(motivation)>300 else ""}</p>"""
|
||||
<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}",
|
||||
|
|
@ -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"<p>Hallo <b>{name}</b>,</p><p>wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!</p>"),
|
||||
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>{name}</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>"),
|
||||
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>{name}</b>,</p><p>vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!</p>"),
|
||||
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 {name},</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">{note}</div>' if note else ""
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<p style="margin:0 0 12px"><b>Kritischer Tierschutz-Hinweis bestätigt</b></p>
|
||||
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{zuechter}</b></td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{zwinger}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{eltern['vater_name'] or '—'}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{eltern['mutter_name'] or '—'}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{_html.escape(zuechter)}</b></td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{_html.escape(zwinger)}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{_html.escape(eltern['vater_name'] or '—')}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{_html.escape(eltern['mutter_name'] or '—')}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Wurf-ID</td><td style="padding:5px 0">#{litter_id}</td></tr>
|
||||
</table>"""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 691 KiB |
|
|
@ -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}`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</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 -->
|
||||
|
|
@ -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 ? `
|
||||
<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
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -263,6 +263,12 @@ window.Page_settings = (() => {
|
|||
<span>Kalender abonnieren</span>
|
||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||
</div>
|
||||
<div class="sidebar-item" id="settings-worlds-btn"
|
||||
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#squares-four"></use></svg>
|
||||
<span>Welten einrichten</span>
|
||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||
</div>
|
||||
<div class="sidebar-item" id="settings-logout-btn"
|
||||
style="padding:var(--space-4);border-radius:0;cursor:pointer;
|
||||
color:var(--c-danger)">
|
||||
|
|
@ -653,6 +659,11 @@ window.Page_settings = (() => {
|
|||
}
|
||||
});
|
||||
|
||||
document.getElementById('settings-worlds-btn')?.addEventListener('click', () => {
|
||||
if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal();
|
||||
else if (window.Worlds) window.Worlds.openConfig?.();
|
||||
});
|
||||
|
||||
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title : 'Abmelden?',
|
||||
|
|
|
|||
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 };
|
||||
|
||||
})();
|
||||
|
|
@ -161,3 +161,251 @@ async def get_weather_summary() -> dict:
|
|||
|
||||
logger.info(f"Wetter-Zusammenfassung: max_temp={max_temp}°C, thunderstorm={thunderstorm}")
|
||||
return {"max_temp_c": max_temp, "thunderstorm": thunderstorm}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7-Tage-Vorhersage
|
||||
# ---------------------------------------------------------------------------
|
||||
import asyncio # noqa: E402 — appended section
|
||||
|
||||
_forecast_cache: dict = {}
|
||||
_FORECAST_TTL = 3600 # 1 Stunde
|
||||
|
||||
_WDAY_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
|
||||
|
||||
def _wind_dir(deg: float) -> str:
|
||||
dirs = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW']
|
||||
idx = round(deg / 45) % 8
|
||||
return dirs[idx]
|
||||
|
||||
|
||||
def _asphalt_temp(air_max: float, uv_max: float) -> tuple[float, str]:
|
||||
bonus = min(uv_max * 3.0, 30.0)
|
||||
asphalt = air_max + bonus
|
||||
if asphalt <= 30:
|
||||
warn = 'safe'
|
||||
elif asphalt <= 40:
|
||||
warn = 'warm'
|
||||
elif asphalt <= 55:
|
||||
warn = 'hot'
|
||||
else:
|
||||
warn = 'danger'
|
||||
return round(asphalt, 1), warn
|
||||
|
||||
|
||||
def _pollen_lvl(val: float | None) -> dict:
|
||||
if val is None:
|
||||
return {'level': 0, 'label': 'keine'}
|
||||
if val < 5:
|
||||
return {'level': 1, 'label': 'niedrig'}
|
||||
if val < 25:
|
||||
return {'level': 2, 'label': 'mittel'}
|
||||
if val < 100:
|
||||
return {'level': 3, 'label': 'hoch'}
|
||||
return {'level': 4, 'label': 'sehr hoch'}
|
||||
|
||||
|
||||
async def get_forecast(lat: float, lon: float) -> dict:
|
||||
"""7-Tage-Wettervorhersage inkl. Pollen, Asphalttemperatur, Zecken. 1h TTL-Cache."""
|
||||
key = (round(lat, 2), round(lon, 2))
|
||||
now = time.time()
|
||||
if key in _forecast_cache:
|
||||
ts, cached = _forecast_cache[key]
|
||||
if now - ts < _FORECAST_TTL:
|
||||
return cached
|
||||
|
||||
forecast_url = (
|
||||
"https://api.open-meteo.com/v1/forecast"
|
||||
f"?latitude={lat}&longitude={lon}"
|
||||
"&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,"
|
||||
"apparent_temperature_min,precipitation_probability_max,precipitation_sum,"
|
||||
"weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max,"
|
||||
"sunrise,sunset"
|
||||
"&timezone=auto&forecast_days=7"
|
||||
)
|
||||
pollen_url = (
|
||||
"https://air-quality-api.open-meteo.com/v1/air-quality"
|
||||
f"?latitude={lat}&longitude={lon}"
|
||||
"&hourly=alder_pollen,birch_pollen,grass_pollen,mugwort_pollen,ragweed_pollen"
|
||||
"&timezone=auto&forecast_days=7"
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
forecast_task = client.get(forecast_url)
|
||||
pollen_task = client.get(pollen_url)
|
||||
forecast_resp, pollen_resp = await asyncio.gather(
|
||||
forecast_task, pollen_task, return_exceptions=True
|
||||
)
|
||||
|
||||
# --- Forecast (required) ---
|
||||
if isinstance(forecast_resp, Exception):
|
||||
raise forecast_resp
|
||||
forecast_resp.raise_for_status()
|
||||
raw = forecast_resp.json()
|
||||
|
||||
daily = raw.get('daily', {})
|
||||
timezone = raw.get('timezone', 'auto')
|
||||
|
||||
dates = daily.get('time', [])
|
||||
temp_max = daily.get('temperature_2m_max', [])
|
||||
temp_min = daily.get('temperature_2m_min', [])
|
||||
feels_max = daily.get('apparent_temperature_max', [])
|
||||
feels_min = daily.get('apparent_temperature_min', [])
|
||||
precip_prob = daily.get('precipitation_probability_max', [])
|
||||
precip_sum = daily.get('precipitation_sum', [])
|
||||
wcodes = daily.get('weathercode', [])
|
||||
wind_kmh = daily.get('windspeed_10m_max', [])
|
||||
wind_deg = daily.get('winddirection_10m_dominant', [])
|
||||
uv_index = daily.get('uv_index_max', [])
|
||||
sunrises = daily.get('sunrise', [])
|
||||
sunsets = daily.get('sunset', [])
|
||||
|
||||
# --- Pollen (optional) ---
|
||||
pollen_daily: dict | None = None
|
||||
if not isinstance(pollen_resp, Exception):
|
||||
try:
|
||||
pollen_resp.raise_for_status()
|
||||
praw = pollen_resp.json()
|
||||
hourly = praw.get('hourly', {})
|
||||
htimes = hourly.get('time', [])
|
||||
# aggregate hourly → daily max per type
|
||||
pollen_types = {
|
||||
'erle': hourly.get('alder_pollen', []),
|
||||
'birke': hourly.get('birch_pollen', []),
|
||||
'graeser': hourly.get('grass_pollen', []),
|
||||
'beifuss': hourly.get('mugwort_pollen', []),
|
||||
'ambrosia': hourly.get('ragweed_pollen', []),
|
||||
}
|
||||
# build date → max mapping per type
|
||||
pollen_daily = {ptype: {} for ptype in pollen_types}
|
||||
for i, ts_str in enumerate(htimes):
|
||||
day_str = ts_str[:10] # 'YYYY-MM-DD'
|
||||
for ptype, vals in pollen_types.items():
|
||||
v = vals[i] if i < len(vals) else None
|
||||
if v is not None:
|
||||
prev = pollen_daily[ptype].get(day_str)
|
||||
pollen_daily[ptype][day_str] = max(prev, v) if prev is not None else v
|
||||
except Exception as e:
|
||||
logger.warning(f"Pollen-Abruf fehlgeschlagen: {e}")
|
||||
pollen_daily = None
|
||||
|
||||
# --- Assemble days ---
|
||||
days = []
|
||||
for i, date_str in enumerate(dates):
|
||||
wcode = int(wcodes[i]) if i < len(wcodes) and wcodes[i] is not None else 0
|
||||
desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud'))
|
||||
|
||||
t_max = temp_max[i] if i < len(temp_max) else None
|
||||
t_min = temp_min[i] if i < len(temp_min) else None
|
||||
f_max = feels_max[i] if i < len(feels_max) else None
|
||||
f_min = feels_min[i] if i < len(feels_min) else None
|
||||
pp = precip_prob[i] if i < len(precip_prob) else None
|
||||
ps = precip_sum[i] if i < len(precip_sum) else None
|
||||
wk = wind_kmh[i] if i < len(wind_kmh) else None
|
||||
wd_deg = wind_deg[i] if i < len(wind_deg) else None
|
||||
uv = uv_index[i] if i < len(uv_index) else None
|
||||
|
||||
# Sunrise / Sunset → HH:MM only (format: "2025-05-02T06:12")
|
||||
sunrise_raw = sunrises[i] if i < len(sunrises) else None
|
||||
sunset_raw = sunsets[i] if i < len(sunsets) else None
|
||||
sunrise_hm = sunrise_raw[11:16] if sunrise_raw and len(sunrise_raw) >= 16 else sunrise_raw
|
||||
sunset_hm = sunset_raw[11:16] if sunset_raw and len(sunset_raw) >= 16 else sunset_raw
|
||||
|
||||
# Weekday
|
||||
try:
|
||||
dt_obj = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
wday = _WDAY_DE[dt_obj.weekday()]
|
||||
except Exception:
|
||||
wday = ''
|
||||
|
||||
# Asphalt
|
||||
asphalt_t, asphalt_w = _asphalt_temp(t_max or 0.0, uv or 0.0)
|
||||
|
||||
# Zecken
|
||||
month = datetime.strptime(date_str, '%Y-%m-%d').month
|
||||
zecken = None
|
||||
if t_max is not None and t_max > 7.0 and 3 <= month <= 10:
|
||||
zecken = 'hoch' if t_max > 20 else ('mittel' if t_max > 12 else 'niedrig')
|
||||
|
||||
# Pollen
|
||||
if pollen_daily is not None:
|
||||
pollen_out = {
|
||||
pt: _pollen_lvl(pollen_daily[pt].get(date_str))
|
||||
for pt in ('erle', 'birke', 'graeser', 'beifuss', 'ambrosia')
|
||||
}
|
||||
else:
|
||||
pollen_out = None
|
||||
|
||||
days.append({
|
||||
'date': date_str,
|
||||
'wday': wday,
|
||||
'weathercode': wcode,
|
||||
'desc': desc,
|
||||
'icon': icon,
|
||||
'temp_max': t_max,
|
||||
'temp_min': t_min,
|
||||
'feels_max': f_max,
|
||||
'feels_min': f_min,
|
||||
'precip_prob': pp,
|
||||
'precip_sum': ps,
|
||||
'wind_kmh': wk,
|
||||
'wind_dir': _wind_dir(wd_deg) if wd_deg is not None else None,
|
||||
'wind_dir_deg': wd_deg,
|
||||
'uv_index': uv,
|
||||
'sunrise': sunrise_hm,
|
||||
'sunset': sunset_hm,
|
||||
'asphalt_temp': asphalt_t,
|
||||
'asphalt_warn': asphalt_w,
|
||||
'pollen': pollen_out,
|
||||
'zecken': zecken,
|
||||
'thunderstorm': wcode in {95, 96, 99},
|
||||
'paw_cold': wcode in {71, 73, 75, 77, 85, 86} or (t_min is not None and t_min < 0),
|
||||
})
|
||||
|
||||
result = {'timezone': timezone, 'days': days}
|
||||
_forecast_cache[key] = (now, result)
|
||||
_log_forecast(round(lat, 1), round(lon, 1), days)
|
||||
return result
|
||||
|
||||
|
||||
def _log_forecast(lat_r: float, lon_r: float, days: list) -> None:
|
||||
"""Speichert jeden Forecast-Tag in weather_log (INSERT OR IGNORE — kein Überschreiben)."""
|
||||
if not days:
|
||||
return
|
||||
try:
|
||||
import json
|
||||
from database import db
|
||||
with db() as conn:
|
||||
for d in days:
|
||||
pollen = d.get('pollen') or {}
|
||||
conn.execute("""
|
||||
INSERT OR IGNORE INTO weather_log
|
||||
(date, lat_r, lon_r,
|
||||
temp_max, temp_min, feels_max,
|
||||
precip_prob, precip_sum,
|
||||
wind_kmh, wind_dir, uv_index,
|
||||
weathercode, weatherdesc,
|
||||
sunrise, sunset,
|
||||
asphalt_temp, asphalt_warn, zecken,
|
||||
pollen_erle, pollen_birke, pollen_graeser,
|
||||
pollen_beifuss, pollen_ambrosia,
|
||||
forecast_json)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
d['date'], lat_r, lon_r,
|
||||
d.get('temp_max'), d.get('temp_min'), d.get('feels_max'),
|
||||
d.get('precip_prob'), d.get('precip_sum'),
|
||||
d.get('wind_kmh'), d.get('wind_dir'), d.get('uv_index'),
|
||||
d.get('weathercode'), d.get('desc'),
|
||||
d.get('sunrise'), d.get('sunset'),
|
||||
d.get('asphalt_temp'), d.get('asphalt_warn'), d.get('zecken'),
|
||||
pollen.get('erle', {}).get('level'),
|
||||
pollen.get('birke', {}).get('level'),
|
||||
pollen.get('graeser', {}).get('level'),
|
||||
pollen.get('beifuss', {}).get('level'),
|
||||
pollen.get('ambrosia',{}).get('level'),
|
||||
json.dumps(d, ensure_ascii=False),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"weather_log insert fehlgeschlagen: {e}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue