Release v1.4.0

This commit is contained in:
rene 2026-05-05 21:46:21 +02:00
commit bb4117dd71
41 changed files with 3539 additions and 222 deletions

View file

@ -2058,3 +2058,214 @@ def _migrate(conn_factory):
ON gassi_zeiten(user_id, aktiv);
""")
logger.info("Migration: Gassi-Zeiten-Tabelle bereit.")
# ---- Feature: Hilfe/FAQ ----
conn.executescript("""
CREATE TABLE IF NOT EXISTS help_articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kategorie TEXT NOT NULL,
frage TEXT NOT NULL,
antwort TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
aktiv INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_help_kat ON help_articles(kategorie, sort_order);
""")
_seed_help_articles(conn)
logger.info("Migration: Hilfe/FAQ-Tabelle bereit.")
def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
count = conn.execute("SELECT COUNT(*) FROM help_articles").fetchone()[0]
if count > 0:
return
_SEED = [
# ── installation ──────────────────────────────────────────────
("installation", "Was ist eine PWA?",
"Eine Progressive Web App (PWA) ist eine Website, die sich wie eine richtige App verhält. "
"Du kannst Ban Yaro direkt im Browser nutzen oder als App auf deinem Startbildschirm speichern — "
"ohne den App Store.\n\n"
"Vorteile: immer aktuell, kein Download, funktioniert auch offline.",
1),
("installation", "Warum ist Ban Yaro nicht im App Store?",
"Als PWA können wir Ban Yaro viel schneller mit neuen Funktionen ausstatten und Fehler sofort "
"beheben. App-Store-Apps müssen oft tagelang auf Freigabe warten.\n\n"
"Außerdem sparst du Speicherplatz auf deinem Handy — Ban Yaro braucht nur wenige Megabyte.",
2),
("installation", "iPhone: Wie füge ich Ban Yaro zum Startbildschirm hinzu?",
"1. Öffne banyaro.app in Safari (nicht Chrome oder Firefox).\n"
"2. Tippe auf das Teilen-Symbol (Quadrat mit Pfeil nach oben) unten in der Mitte.\n"
"3. Scrolle im Menü nach unten und wähle 'Zum Home-Bildschirm'.\n"
"4. Bestätige mit 'Hinzufügen'.\n\n"
"Ban Yaro erscheint jetzt als App-Icon auf deinem Startbildschirm.",
3),
("installation", "Android: Wie füge ich Ban Yaro zum Startbildschirm hinzu?",
"1. Öffne banyaro.app in Chrome.\n"
"2. Tippe oben rechts auf die drei Punkte (Menü).\n"
"3. Wähle 'App installieren' oder 'Zum Startbildschirm hinzufügen'.\n"
"4. Bestätige die Installation.\n\n"
"Bei manchen Android-Geräten erscheint auch automatisch ein Banner am unteren Bildschirmrand.",
4),
("installation", "Wie aktualisiere ich die App?",
"Ban Yaro aktualisiert sich automatisch im Hintergrund. Wenn ein Update bereit ist, "
"siehst du einen Hinweis in der App.\n\n"
"Falls du Probleme hast und die App sich komisch verhält: Einstellungen öffnen, "
"ganz unten 'Auf Update prüfen' tippen. Danach die App schließen und neu öffnen.",
5),
# ── erste_schritte ────────────────────────────────────────────
("erste_schritte", "Wie lege ich meinen Hund an?",
"Tippe in der Navigation unten auf den Hund-Tab (HUND) und dann auf 'Hund hinzufügen'. "
"Du gibst Name, Rasse und Geburtsdatum ein — ein Foto ist optional, macht aber gleich mehr Spaß.\n\n"
"Du kannst mehrere Hunde anlegen und zwischen ihnen wechseln.",
1),
("erste_schritte", "Wie navigiere ich zwischen den Welten?",
"Ban Yaro ist in drei Welten aufgeteilt: JETZT, HUND und WELT. "
"Du kannst links-rechts wischen oder die drei Buttons unten in der Navigation tippen.\n\n"
"JETZT zeigt dir alles, was gerade relevant ist. HUND ist alles über deinen Vierbeiner. "
"WELT verbindet dich mit der Hunde-Community.",
2),
("erste_schritte", "Was bedeuten JETZT, HUND und WELT?",
"JETZT: Dein persönliches Dashboard — Wetter, Gassi-Planer, aktuelle Hinweise in deiner Nähe.\n\n"
"HUND: Alles rund um deinen Hund — Tagebuch, Gesundheit, Training, Ernährung, Tierarzt.\n\n"
"WELT: Die Community — Forum, Wiki, Events, andere Hundebesitzer, Gassi-Treffen, Karte.",
3),
("erste_schritte", "Wie passe ich die Chips auf der JETZT-Seite an?",
"Tippe oben rechts auf der JETZT-Seite auf das Zahnrad-Symbol. "
"Dort kannst du auswählen, welche Funktions-Chips du sehen möchtest "
"und in welcher Reihenfolge sie angezeigt werden.",
4),
("erste_schritte", "Wie schreibe ich mein erstes Tagebuch?",
"Gehe in die HUND-Welt und tippe auf 'Tagebuch'. "
"Mit dem Plus-Button kannst du einen neuen Eintrag anlegen.\n\n"
"Du kannst Text schreiben, Fotos hinzufügen, deine Stimmung eintragen und den Ort markieren. "
"Alle Einträge sind nur für dich sichtbar.",
5),
# ── standort ──────────────────────────────────────────────────
("standort", "Wie gebe ich den Standort auf dem iPhone frei?",
"1. Öffne die Einstellungen deines iPhones.\n"
"2. Scrolle zu Safari.\n"
"3. Tippe auf 'Standort'.\n"
"4. Wähle 'Beim Benutzen der App erlauben'.\n\n"
"Alternativ: Wenn Ban Yaro fragt, ob es deinen Standort nutzen darf, "
"tippe auf 'Erlauben'. Diese Abfrage erscheint beim ersten Öffnen der Karte oder Wetter-Funktion.",
1),
("standort", "Wie gebe ich den Standort auf Android frei?",
"1. Öffne die Einstellungen deines Handys.\n"
"2. Gehe zu Apps → Chrome → Berechtigungen → Standort.\n"
"3. Wähle 'Beim Benutzen der App erlauben'.\n\n"
"Beim ersten Öffnen der Karte oder Wetter-Funktion fragt Ban Yaro automatisch nach der Berechtigung.",
2),
("standort", "Der Standort ist blockiert — wie setze ich das zurück?",
"Wenn du versehentlich 'Ablehnen' getippt hast, kannst du das zurücksetzen:\n\n"
"iPhone: Einstellungen → Safari → Standort → 'Beim Benutzen erlauben'\n\n"
"Android: In Chrome tippe auf das Schloss-Symbol links in der Adresszeile → "
"Website-Einstellungen → Standort → Erlauben.\n\n"
"Tipp: Manchmal hilft es, die Website-Daten zu löschen und Ban Yaro neu zu öffnen.",
3),
("standort", "Das Wetter lädt nicht — was kann ich tun?",
"Das Wetter benötigt deinen aktuellen Standort. Prüfe zuerst, ob die Standort-Berechtigung "
"erteilt ist (siehe 'Standort freigeben').\n\n"
"Falls es trotzdem nicht klappt: Schließe Ban Yaro vollständig und öffne es erneut. "
"Bei anhaltenden Problemen tippe in den Einstellungen auf 'Auf Update prüfen'.",
4),
# ── account ───────────────────────────────────────────────────
("account", "Ich habe mein Passwort vergessen — was nun?",
"Auf der Anmeldeseite findest du den Link 'Passwort vergessen'. "
"Gib dort deine E-Mail-Adresse ein — du erhältst innerhalb weniger Minuten "
"einen Link zum Zurücksetzen.\n\n"
"Schau auch im Spam-Ordner, falls die E-Mail nicht ankommt.",
1),
("account", "Die E-Mail-Bestätigung ist nicht angekommen.",
"Bitte prüfe deinen Spam- oder Junk-Ordner. E-Mails von Ban Yaro kommen von noreply@banyaro.app.\n\n"
"Falls du die E-Mail dort nicht findest, kannst du die Bestätigung in den Einstellungen "
"unter 'Konto' erneut anfordern.\n\n"
"Stelle sicher, dass deine E-Mail-Adresse korrekt geschrieben ist.",
2),
("account", "Wie kann ich mein Konto löschen?",
"Gehe in die Einstellungen (Zahnrad-Symbol) → Konto → 'Konto löschen'.\n\n"
"Achtung: Die Löschung ist endgültig. Alle deine Daten, Hunde-Profile und "
"Tagebuch-Einträge werden dauerhaft entfernt. Diese Aktion kann nicht rückgängig gemacht werden.",
3),
("account", "Wie ändere ich meine E-Mail-Adresse?",
"Das Ändern der E-Mail-Adresse ist aus Sicherheitsgründen aktuell nur über den "
"Support möglich. Schreibe uns an support@banyaro.app mit deiner aktuellen und "
"gewünschten neuen E-Mail-Adresse.",
4),
# ── features ──────────────────────────────────────────────────
("features", "Was ist der Gassi-Score?",
"Der Gassi-Score zeigt dir auf einen Blick, wie gut das Wetter gerade für einen Spaziergang ist. "
"Er berücksichtigt Temperatur, Regen, Wind, UV-Index und — bei heißem Wetter — "
"die Asphalt-Temperatur.\n\n"
"Grün = super. Gelb = geht noch. Rot = lieber warten oder kürzer machen.",
1),
("features", "Was kann der KI-Tierarzt?",
"Der KI-Tierarzt beantwortet allgemeine Fragen rund um die Gesundheit deines Hundes — "
"zum Beispiel zu Symptomen, Ernährung oder Verhalten.\n\n"
"Wichtig: Er ersetzt keinen echten Tierarzt. Bei ernsten Symptomen oder Notfällen "
"wende dich bitte sofort an einen Tierarzt in deiner Nähe.",
2),
("features", "Wie funktioniert der Offline-Modus?",
"Ban Yaro speichert die wichtigsten Funktionen lokal auf deinem Gerät. "
"Auch ohne Internet kannst du dein Tagebuch lesen, Einträge anlegen und "
"gespeicherte Karten nutzen.\n\n"
"Neue Daten werden automatisch synchronisiert, sobald du wieder online bist.",
3),
("features", "Wie richte ich Push-Benachrichtigungen ein?",
"Gehe in die Einstellungen (Zahnrad-Symbol) und tippe auf 'Push-Benachrichtigungen'. "
"Dort kannst du auswählen, für welche Ereignisse du Benachrichtigungen erhalten möchtest — "
"z.B. Giftköder-Warnungen, neue Nachrichten oder Gassi-Erinnerungen.\n\n"
"Auf dem iPhone muss Ban Yaro als App auf dem Startbildschirm installiert sein, "
"damit Push-Nachrichten funktionieren.",
4),
("features", "Kann ich mein Tagebuch-Hintergrundbild ändern?",
"Ja! Im Tagebuch tippe oben auf das Bild-Symbol oder auf 'Hintergrund anpassen'. "
"Du kannst ein eigenes Foto wählen oder eines der vorhandenen Motive nutzen.\n\n"
"Das Hintergrundbild gilt für alle Einträge und macht dein Tagebuch ganz persönlich.",
5),
("features", "Was ist der Giftköder-Alarm?",
"Der Giftköder-Alarm zeigt dir gemeldete Giftköder in deiner Nähe auf einer Karte. "
"Du kannst selbst Funde melden und andere Hundebesitzer warnen.\n\n"
"In den Einstellungen kannst du Push-Benachrichtigungen aktivieren, "
"damit du sofort gewarnt wirst, wenn in deiner Nähe ein Giftköder gemeldet wird.",
6),
# ── probleme ──────────────────────────────────────────────────
("probleme", "Die App zeigt alte Daten — was tun?",
"Tippe in den Einstellungen ganz unten auf 'Auf Update prüfen'. "
"Danach schließe Ban Yaro vollständig (App aus dem Multitasking entfernen) "
"und öffne sie erneut.\n\n"
"Falls das nicht hilft: Lösche die Website-Daten in deinem Browser und öffne "
"Ban Yaro erneut. Du bleibst dabei angemeldet.",
1),
("probleme", "Das Wetter lädt nicht.",
"Stelle sicher, dass die Standort-Berechtigung erteilt ist. "
"Ohne Standort kann Ban Yaro kein lokales Wetter laden.\n\n"
"Prüfe außerdem deine Internetverbindung. Bei schlechtem WLAN oder Mobilfunk "
"kann es zu Verzögerungen kommen. Eine kurze Wartezeit und erneutes Tippen hilft meist.",
2),
("probleme", "Die App reagiert nicht oder friert ein.",
"Schließe Ban Yaro vollständig (aus dem Multitasking entfernen) und öffne sie erneut.\n\n"
"Falls das Problem anhält, prüfe ob dein Gerät ausreichend Speicher hat. "
"Starte dein Handy neu — das löst in den meisten Fällen temporäre Hänger.",
3),
("probleme", "Wie melde ich einen Fehler?",
"Wir freuen uns über Feedback! Schreibe uns an support@banyaro.app mit einer kurzen "
"Beschreibung des Problems.\n\n"
"Hilfreich sind: Was hast du getan? Was hast du erwartet? Was ist stattdessen passiert? "
"Welches Gerät und Browser nutzt du?\n\n"
"Wir melden uns so schnell wie möglich.",
4),
]
for kat, frage, antwort, sort in _SEED:
conn.execute(
"INSERT INTO help_articles (kategorie, frage, antwort, sort_order) VALUES (?, ?, ?, ?)",
(kat, frage, antwort, sort),
)

View file

@ -233,6 +233,9 @@ from routes.health_docs import router as health_docs_router
from routes.passport import router as passport_router
from routes.playdate import router as playdate_router
from routes.ernaehrung import router as ernaehrung_router
from routes.challenges import router as challenges_router
from routes.gassi_zeiten import router as gassi_zeiten_router
from routes.help import router as help_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -292,6 +295,9 @@ app.include_router(health_docs_router, prefix="/api/health-docs", t
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
# ------------------------------------------------------------------
@ -321,6 +327,18 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
APP_VER = "727" # muss mit APP_VER in app.js übereinstimmen
@app.get("/api/version")
async def app_version():
"""Aktuelle Frontend-Version — wird beim App-Start gecheckt."""
return Response(
content=f'{{"version":"{APP_VER}"}}',
media_type="application/json",
headers={"Cache-Control": "no-store"},
)
@app.get("/stats/script.js")
async def umami_script_proxy():
async with httpx.AsyncClient(timeout=10) as client:

View file

@ -178,6 +178,17 @@ def generate_preview(data: bytes, ext: str) -> bytes | None:
return None
def get_image_size(data: bytes) -> tuple[int, int] | None:
"""Gibt (width, height) eines Bildes zurück, oder None bei Fehler."""
try:
from PIL import Image, ImageOps
img = Image.open(io.BytesIO(data))
img = ImageOps.exif_transpose(img)
return img.size # (width, height)
except Exception:
return None
def preview_url_from(url: str | None) -> str | None:
"""Leitet die Preview-URL aus der Original-URL ab (fügt _preview vor Extension ein).
Gibt None für Videos, PDFs, bereits generierte Previews und leere URLs zurück."""

View file

@ -203,11 +203,11 @@ def check_and_award(user_id: int, conn):
"SELECT current_streak FROM users WHERE id=?", (user_id,)
).fetchone()
# Wetter-Tapferkeit: Diary-Einträge bei schlechtem Wetter
# Wetter-Tapferkeit: Diary-Einträge bei schlechtem Wetter (über Dog-Join)
wetter_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.user_id = ?
JOIN dogs dog ON dog.id = d.dog_id
WHERE dog.user_id = ?
AND d.weather_json IS NOT NULL
AND (
CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
@ -216,23 +216,28 @@ def check_and_award(user_id: int, conn):
)
""", (user_id,)).fetchone()
# Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen
# Jahreszeiten: Anzahl Jahreszeiten mit mind. 5 Diary-Einträgen (über Dog-Join)
jahreszeiten_row = conn.execute("""
SELECT
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
(CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
AS jahreszeiten_score
FROM (SELECT 1)
""", (user_id, user_id, user_id, user_id)).fetchone()
# Schnee: Diary-Einträge bei Schnee (weathercode 71-77)
# Schnee: Diary-Einträge bei Schnee (weathercode 71-77, über Dog-Join)
schnee_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary
WHERE user_id = ?
AND weather_json IS NOT NULL
AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
SELECT COUNT(*) AS cnt FROM diary d
JOIN dogs dog ON dog.id = d.dog_id
WHERE dog.user_id = ?
AND d.weather_json IS NOT NULL
AND CAST(json_extract(d.weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
""", (user_id,)).fetchone()
metrics = {
@ -303,8 +308,8 @@ async def my_achievements(user=Depends(get_current_user)):
# Wetter-Tapferkeit
wetter_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
WHERE d.user_id = ?
JOIN dogs dog ON dog.id = d.dog_id
WHERE dog.user_id = ?
AND d.weather_json IS NOT NULL
AND (
CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
@ -316,20 +321,25 @@ async def my_achievements(user=Depends(get_current_user)):
# Jahreszeiten
jahreszeiten_row = conn.execute("""
SELECT
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary WHERE user_id=? AND CAST(strftime('%m', datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
(CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (3,4,5)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (6,7,8)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (9,10,11)) >= 5 THEN 1 ELSE 0 END) +
(CASE WHEN (SELECT COUNT(*) FROM diary d2 JOIN dogs g2 ON g2.id=d2.dog_id
WHERE g2.user_id=? AND CAST(strftime('%m', d2.datum) AS INTEGER) IN (12,1,2)) >= 5 THEN 1 ELSE 0 END)
AS jahreszeiten_score
FROM (SELECT 1)
""", (uid, uid, uid, uid)).fetchone()
# Schnee-Einträge
schnee_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary
WHERE user_id = ?
AND weather_json IS NOT NULL
AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
SELECT COUNT(*) AS cnt FROM diary d
JOIN dogs dog ON dog.id = d.dog_id
WHERE dog.user_id = ?
AND d.weather_json IS NOT NULL
AND CAST(json_extract(d.weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
""", (uid,)).fetchone()
earned_rows = conn.execute(

View file

@ -0,0 +1,304 @@
"""BAN YARO — Foto-Challenge der Woche"""
import os
import uuid
import logging
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
from media_utils import convert_media, generate_preview
logger = logging.getLogger(__name__)
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
CHALLENGE_DIR = os.path.join(MEDIA_DIR, "challenges")
_CHALLENGE_THEMEN = [
"Bestes Schnüffel-Foto 👃",
"Action-Aufnahme 🏃",
"Schlafendes Tier 😴",
"Gassi im Regen 🌧️",
"Hundeblick in die Kamera 👀",
"Spielzeit mit Freunden 🐕",
"Herbstspaziergang 🍂",
"Beste Sprung-Aufnahme 🦘",
"Hund am Wasser 🌊",
"Erstes Mal im Schnee ❄️",
"Genuss-Moment 🦴",
"Versteckt im Gebüsch 🌿",
"Tierisches Selfie 🤳",
"Hund & Kind 👶",
"Hund & Katze zusammen 🐱",
"Der beste Buddel-Moment 🐾",
"Freude beim Apportieren 🎾",
"Hund in seiner Lieblingshöhle 🛋️",
"Sonnenuntergangs-Gassi 🌅",
"Hundebegegnung auf dem Spaziergang 🐕🐕",
"Ausdrucksstarker Hundeblick 😍",
"Hund im Herbstlaub 🍁",
"Welpenfoto 🍼",
"Seniorenhund im Porträt 👴",
"Lustigste Schlafposition 💤",
"Hund trägt etwas 🎀",
"Hund + Besitzer Spiegelfoto 🪞",
"Hund auf Abenteuer 🏕️",
"Beste Lauf-Action 💨",
"Hund im Café ☕",
]
def _current_week_monday() -> str:
today = date.today()
monday = today - timedelta(days=today.weekday())
return monday.isoformat()
def _current_week_sunday() -> str:
monday = date.fromisoformat(_current_week_monday())
return (monday + timedelta(days=6)).isoformat()
def _ensure_current_challenge(conn) -> int:
"""Stellt sicher dass eine Challenge für die aktuelle Woche existiert. Gibt die ID zurück."""
monday = _current_week_monday()
sunday = _current_week_sunday()
existing = conn.execute(
"SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
).fetchone()
if existing:
return existing["id"]
# Thema aus Rotation wählen (Wochennummer % Anzahl Themen)
week_num = date.today().isocalendar()[1]
thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
cur = conn.execute(
"INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
"VALUES (?, ?, ?, ?, NULL)",
(thema, f"Diese Woche: {thema}", monday, sunday)
)
return cur.lastrowid
# ------------------------------------------------------------------
# GET /api/challenges/current
# ------------------------------------------------------------------
@router.get("/current")
async def get_current_challenge(user=Depends(get_current_user_optional)):
with db() as conn:
challenge_id = _ensure_current_challenge(conn)
challenge = conn.execute(
"SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
).fetchone()
submissions = conn.execute("""
SELECT cs.id, cs.user_id, cs.dog_id, cs.foto_url, cs.caption, cs.votes, cs.created_at,
u.name AS user_name, u.avatar_url,
d.name AS dog_name, d.foto_url AS dog_foto_url
FROM challenge_submissions cs
LEFT JOIN users u ON u.id = cs.user_id
LEFT JOIN dogs d ON d.id = cs.dog_id
WHERE cs.challenge_id = ?
ORDER BY cs.votes DESC, cs.created_at ASC
""", (challenge_id,)).fetchall()
my_submission = None
my_votes = set()
if user:
mine = conn.execute(
"SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
(challenge_id, user["id"])
).fetchone()
if mine:
my_submission = mine["id"]
voted_rows = conn.execute(
"SELECT cv.submission_id FROM challenge_votes cv "
"JOIN challenge_submissions cs ON cs.id = cv.submission_id "
"WHERE cv.user_id = ? AND cs.challenge_id = ?",
(user["id"], challenge_id)
).fetchall()
my_votes = {r["submission_id"] for r in voted_rows}
# Countdown bis Sonntag
end = date.fromisoformat(challenge["end_date"])
days_left = (end - date.today()).days + 1
result_subs = []
for s in submissions:
sd = dict(s)
sd["i_voted"] = (sd["id"] in my_votes) if user else False
result_subs.append(sd)
return {
"challenge": dict(challenge),
"submissions": result_subs,
"my_submission_id": my_submission,
"days_left": max(0, days_left),
}
# ------------------------------------------------------------------
# POST /api/challenges/{id}/submit
# ------------------------------------------------------------------
@router.post("/{challenge_id}/submit", status_code=201)
async def submit_photo(
challenge_id: int,
caption: Optional[str] = Form(None),
dog_id: Optional[int] = Form(None),
foto: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
challenge = conn.execute(
"SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
).fetchone()
if not challenge:
raise HTTPException(404, "Challenge nicht gefunden.")
today = date.today().isoformat()
if today > challenge["end_date"]:
raise HTTPException(400, "Die Challenge ist bereits beendet.")
# Doppelt-Check
existing = conn.execute(
"SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
(challenge_id, user["id"])
).fetchone()
if existing:
raise HTTPException(409, "Du hast bereits ein Foto eingereicht.")
# Foto speichern
os.makedirs(CHALLENGE_DIR, exist_ok=True)
orig_filename = foto.filename or "foto.jpg"
ext = os.path.splitext(orig_filename)[1] or ".jpg"
base = uuid.uuid4().hex
raw = await foto.read()
# HEIC→JPEG Konvertierung falls nötig
try:
converted, out_ext = convert_media(raw, orig_filename)
except Exception:
converted, out_ext = raw, ext
save_filename = f"{base}{out_ext}"
save_path = os.path.join(CHALLENGE_DIR, save_filename)
with open(save_path, "wb") as f:
f.write(converted)
foto_url = f"/media/challenges/{save_filename}"
# Preview
try:
preview = generate_preview(converted, out_ext)
if preview:
prev_path = os.path.join(CHALLENGE_DIR, f"{base}_preview.webp")
with open(prev_path, "wb") as f:
f.write(preview)
except Exception:
pass
with db() as conn:
cur = conn.execute(
"INSERT INTO challenge_submissions (challenge_id, user_id, dog_id, foto_url, caption) "
"VALUES (?, ?, ?, ?, ?)",
(challenge_id, user["id"], dog_id, foto_url, caption)
)
row = conn.execute("""
SELECT cs.*, u.name AS user_name, d.name AS dog_name
FROM challenge_submissions cs
LEFT JOIN users u ON u.id = cs.user_id
LEFT JOIN dogs d ON d.id = cs.dog_id
WHERE cs.id = ?
""", (cur.lastrowid,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# POST /api/challenges/submissions/{id}/vote — Toggle-Vote
# ------------------------------------------------------------------
@router.post("/submissions/{submission_id}/vote")
async def vote_submission(submission_id: int, user=Depends(get_current_user)):
with db() as conn:
sub = conn.execute(
"SELECT * FROM challenge_submissions WHERE id = ?", (submission_id,)
).fetchone()
if not sub:
raise HTTPException(404, "Einreichung nicht gefunden.")
if sub["user_id"] == user["id"]:
raise HTTPException(400, "Du kannst nicht für dein eigenes Foto abstimmen.")
existing = conn.execute(
"SELECT id FROM challenge_votes WHERE submission_id=? AND user_id=?",
(submission_id, user["id"])
).fetchone()
if existing:
# Toggle: Vote entfernen
conn.execute(
"DELETE FROM challenge_votes WHERE submission_id=? AND user_id=?",
(submission_id, user["id"])
)
conn.execute(
"UPDATE challenge_submissions SET votes = MAX(0, votes - 1) WHERE id=?",
(submission_id,)
)
voted = False
else:
conn.execute(
"INSERT INTO challenge_votes (submission_id, user_id) VALUES (?, ?)",
(submission_id, user["id"])
)
conn.execute(
"UPDATE challenge_submissions SET votes = votes + 1 WHERE id=?",
(submission_id,)
)
voted = True
votes = conn.execute(
"SELECT votes FROM challenge_submissions WHERE id=?", (submission_id,)
).fetchone()["votes"]
return {"voted": voted, "votes": votes}
# ------------------------------------------------------------------
# GET /api/challenges/winners — letzte 4 Gewinner
# ------------------------------------------------------------------
@router.get("/winners")
async def get_winners():
with db() as conn:
# Vergangene Challenges (ohne aktuelle Woche)
monday = _current_week_monday()
challenges = conn.execute(
"SELECT id, thema, start_date, end_date FROM foto_challenge "
"WHERE end_date < ? ORDER BY end_date DESC LIMIT 4",
(monday,)
).fetchall()
winners = []
for ch in challenges:
winner = conn.execute("""
SELECT cs.id, cs.user_id, cs.foto_url, cs.caption, cs.votes,
u.name AS user_name, u.avatar_url,
d.name AS dog_name
FROM challenge_submissions cs
LEFT JOIN users u ON u.id = cs.user_id
LEFT JOIN dogs d ON d.id = cs.dog_id
WHERE cs.challenge_id = ?
ORDER BY cs.votes DESC, cs.created_at ASC
LIMIT 1
""", (ch["id"],)).fetchone()
winners.append({
"challenge": dict(ch),
"winner": dict(winner) if winner else None,
})
return winners

View file

@ -342,3 +342,56 @@ async def remove_friend(friend_user_id: int, user=Depends(get_current_user)):
AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
""", (uid, friend_user_id, friend_user_id, uid))
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/friends/same-breed — andere User mit gleicher Rasse
# ------------------------------------------------------------------
@router.get("/same-breed")
async def same_breed(user=Depends(get_current_user)):
"""Findet andere User mit Hunden derselben Rasse. Gibt Anzahl + Forum-Suche zurück."""
uid = user["id"]
with db() as conn:
# Rassen des eingeloggten Users
my_dogs = conn.execute(
"SELECT rasse FROM dogs WHERE user_id=? AND rasse IS NOT NULL AND rasse != ''",
(uid,)
).fetchall()
if not my_dogs:
return {"count": 0, "rassen": [], "forum_query": None}
rassen = list({d["rasse"].strip() for d in my_dogs if d["rasse"]})
# Andere User (nicht ich) die eine dieser Rassen haben
ph = ",".join("?" * len(rassen))
count_row = conn.execute(f"""
SELECT COUNT(DISTINCT d.user_id) AS cnt
FROM dogs d
WHERE d.user_id != ?
AND d.rasse IN ({ph})
""", (uid, *rassen)).fetchone()
count = count_row["cnt"] if count_row else 0
# Für jede Rasse: wie viele andere User
rassen_detail = []
for rasse in rassen:
n = conn.execute(
"SELECT COUNT(DISTINCT user_id) AS cnt FROM dogs "
"WHERE user_id != ? AND rasse = ?",
(uid, rasse)
).fetchone()["cnt"]
if n > 0:
rassen_detail.append({"rasse": rasse, "count": n})
rassen_detail.sort(key=lambda x: -x["count"])
# Forum-Suche-Link für die häufigste Rasse
forum_query = rassen_detail[0]["rasse"] if rassen_detail else None
return {
"count": count,
"rassen": rassen_detail,
"forum_query": forum_query,
}

View file

@ -0,0 +1,190 @@
"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)"""
import json
import math
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List
from database import db
from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
def _haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
class GassiZeitCreate(BaseModel):
dog_id: Optional[int] = None
wochentage: List[str] # ["mo", "mi", "fr"]
uhrzeit: str # "17:00"
ort_name: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
radius_m: int = 500
notiz: Optional[str] = None
class GassiZeitUpdate(BaseModel):
aktiv: Optional[int] = None
# ------------------------------------------------------------------
# GET /api/gassi-zeiten — alle in der Nähe (oder eigene)
# ------------------------------------------------------------------
@router.get("")
async def list_gassi_zeiten(
lat: Optional[float] = None,
lon: Optional[float] = None,
radius: int = 5000, # Meter
nur_eigene: bool = False,
user=Depends(get_current_user),
):
with db() as conn:
if nur_eigene:
rows = conn.execute("""
SELECT gz.*, u.name AS user_name, u.avatar_url,
d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
FROM gassi_zeiten gz
LEFT JOIN users u ON u.id = gz.user_id
LEFT JOIN dogs d ON d.id = gz.dog_id
WHERE gz.user_id = ?
ORDER BY gz.uhrzeit ASC
""", (user["id"],)).fetchall()
else:
rows = conn.execute("""
SELECT gz.*, u.name AS user_name, u.avatar_url,
d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
FROM gassi_zeiten gz
LEFT JOIN users u ON u.id = gz.user_id
LEFT JOIN dogs d ON d.id = gz.dog_id
WHERE gz.aktiv = 1
ORDER BY gz.uhrzeit ASC
""").fetchall()
result = []
for r in rows:
d = dict(r)
# wochentage JSON parsen
try:
d["wochentage"] = json.loads(d["wochentage"]) if isinstance(d["wochentage"], str) else d["wochentage"]
except Exception:
d["wochentage"] = []
d["is_mine"] = (d["user_id"] == user["id"])
# Distanz-Filter
if lat is not None and lon is not None and d.get("lat") and d.get("lon"):
dist = _haversine(lat, lon, d["lat"], d["lon"])
if not nur_eigene and dist > radius:
continue
d["distance_m"] = int(dist)
else:
d["distance_m"] = None
result.append(d)
# Sortierung: eigene zuerst, dann nach Distanz
result.sort(key=lambda x: (0 if x["is_mine"] else 1, x.get("distance_m") or 99999))
return result
# ------------------------------------------------------------------
# POST /api/gassi-zeiten — eigene Zeit anlegen
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def create_gassi_zeit(data: GassiZeitCreate, user=Depends(get_current_user)):
if not data.wochentage:
raise HTTPException(400, "Mindestens ein Wochentag muss angegeben werden.")
if not data.uhrzeit:
raise HTTPException(400, "Uhrzeit muss angegeben werden.")
wochentage_json = json.dumps(data.wochentage)
with db() as conn:
# Hund-Prüfung
if data.dog_id:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (data.dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(403, "Hund nicht gefunden oder gehört nicht dir.")
cur = conn.execute("""
INSERT INTO gassi_zeiten (user_id, dog_id, wochentage, uhrzeit,
ort_name, lat, lon, radius_m, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (user["id"], data.dog_id, wochentage_json, data.uhrzeit,
data.ort_name, data.lat, data.lon, data.radius_m, data.notiz))
row = conn.execute("""
SELECT gz.*, u.name AS user_name, d.name AS dog_name, d.foto_url AS dog_foto_url
FROM gassi_zeiten gz
LEFT JOIN users u ON u.id = gz.user_id
LEFT JOIN dogs d ON d.id = gz.dog_id
WHERE gz.id = ?
""", (cur.lastrowid,)).fetchone()
result = dict(row)
try:
result["wochentage"] = json.loads(result["wochentage"])
except Exception:
pass
result["is_mine"] = True
return result
# ------------------------------------------------------------------
# PATCH /api/gassi-zeiten/{id} — pausieren / aktivieren
# ------------------------------------------------------------------
@router.patch("/{gz_id}")
async def update_gassi_zeit(gz_id: int, data: GassiZeitUpdate, user=Depends(get_current_user)):
with db() as conn:
gz = conn.execute(
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
).fetchone()
if not gz:
raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
if gz["user_id"] != user["id"]:
raise HTTPException(403, "Nicht deine Gassi-Zeit.")
updates = data.model_dump(exclude_none=True)
if updates:
cols = ", ".join(f"{k} = ?" for k in updates)
conn.execute(f"UPDATE gassi_zeiten SET {cols} WHERE id=?", [*updates.values(), gz_id])
row = conn.execute(
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
).fetchone()
result = dict(row)
try:
result["wochentage"] = json.loads(result["wochentage"])
except Exception:
pass
result["is_mine"] = True
return result
# ------------------------------------------------------------------
# DELETE /api/gassi-zeiten/{id}
# ------------------------------------------------------------------
@router.delete("/{gz_id}", status_code=204)
async def delete_gassi_zeit(gz_id: int, user=Depends(get_current_user)):
with db() as conn:
gz = conn.execute(
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
).fetchone()
if not gz:
raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
if gz["user_id"] != user["id"]:
raise HTTPException(403, "Nicht deine Gassi-Zeit.")
conn.execute("DELETE FROM gassi_zeiten WHERE id=?", (gz_id,))

98
backend/routes/help.py Normal file
View file

@ -0,0 +1,98 @@
"""BAN YARO — Hilfe / FAQ Routes"""
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user_optional, require_admin
router = APIRouter()
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ArticleCreate(BaseModel):
kategorie: str
frage: str
antwort: str
sort_order: int = 0
aktiv: int = 1
class ArticleUpdate(BaseModel):
kategorie: Optional[str] = None
frage: Optional[str] = None
antwort: Optional[str] = None
sort_order: Optional[int] = None
aktiv: Optional[int] = None
# ------------------------------------------------------------------
# GET /api/help — öffentlich (nur aktive); ?all=1 für Admins
# ------------------------------------------------------------------
@router.get("")
def get_help(
all: int = Query(0),
user=Depends(get_current_user_optional),
):
is_admin = user and user.get("rolle") == "admin"
show_all = all == 1 and is_admin
with db() as conn:
if show_all:
rows = conn.execute(
"SELECT id, kategorie, frage, antwort, sort_order, aktiv "
"FROM help_articles "
"ORDER BY kategorie, sort_order, id"
).fetchall()
else:
rows = conn.execute(
"SELECT id, kategorie, frage, antwort, sort_order, aktiv "
"FROM help_articles "
"WHERE aktiv = 1 "
"ORDER BY kategorie, sort_order, id"
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/help — Admin: neuen Artikel anlegen
# ------------------------------------------------------------------
@router.post("", status_code=201)
def create_article(body: ArticleCreate, admin=Depends(require_admin)):
with db() as conn:
cur = conn.execute(
"INSERT INTO help_articles (kategorie, frage, antwort, sort_order, aktiv) "
"VALUES (?, ?, ?, ?, ?)",
(body.kategorie, body.frage, body.antwort, body.sort_order, body.aktiv),
)
return {"ok": True, "id": cur.lastrowid}
# ------------------------------------------------------------------
# PATCH /api/help/{article_id} — Admin: Artikel bearbeiten
# ------------------------------------------------------------------
@router.patch("/{article_id}")
def update_article(article_id: int, body: ArticleUpdate, admin=Depends(require_admin)):
updates = {k: v for k, v in body.model_dump(exclude_none=True).items()}
if not updates:
return {"ok": True}
set_clause = ", ".join(f"{k}=?" for k in updates)
with db() as conn:
conn.execute(
f"UPDATE help_articles SET {set_clause} WHERE id=?",
(*updates.values(), article_id),
)
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/help/{article_id} — Admin: Artikel löschen
# ------------------------------------------------------------------
@router.delete("/{article_id}")
def delete_article(article_id: int, admin=Depends(require_admin)):
with db() as conn:
conn.execute("DELETE FROM help_articles WHERE id=?", (article_id,))
return {"ok": True}

View file

@ -280,9 +280,14 @@ class UserPoiIn(BaseModel):
ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park',
'giftkoeder', # Giftköder (exklusiv, kein Kombi)
'gefahr', # Allgemeine Gefahr / Hinweis
'freilauf', # Freilauffläche
'restaurant', # Hundefreundliches Restaurant / Café
'shop', # Hundefreundlicher Shop
'tierarzt', # Tierarzt / Tierklinik
'hundeschule', # Hundeschule / Trainer
'kotbeutel', # Kotbeutelspender
'bank', # Sitzbank
'gefahr', # Allgemeine Gefahr / Hinweis
'parkplatz', # Hundefreundlicher Parkplatz
'treffpunkt', # Treffpunkt für Hundehalter
'sonstiges',

View file

@ -6,7 +6,7 @@ from typing import Optional
import datetime
import ki
from database import db
from auth import get_current_user
from auth import get_current_user, require_admin
router = APIRouter()
@ -50,6 +50,36 @@ async def get_exercises():
})
return by_tab
# ------------------------------------------------------------------
# Admin: Übung bearbeiten (beschreibung / schritte / tipp)
# ------------------------------------------------------------------
class ExerciseUpdate(BaseModel):
beschreibung: Optional[str] = None
schritte: Optional[str] = None # JSON-String: '["Schritt 1", ...]'
tipp: Optional[str] = None
@router.put("/exercises/{exercise_id}")
async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(require_admin)):
"""Partial update von beschreibung/schritte/tipp einer Übung (nur Admin)."""
with db() as conn:
row = conn.execute(
"SELECT id FROM training_exercises WHERE id=?", (exercise_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
fields, vals = [], []
if body.beschreibung is not None:
fields.append("beschreibung=?"); vals.append(body.beschreibung)
if body.schritte is not None:
fields.append("schritte=?"); vals.append(body.schritte)
if body.tipp is not None:
fields.append("tipp=?"); vals.append(body.tipp)
if not fields:
return {"ok": True, "updated": 0}
vals.append(exercise_id)
conn.execute(f"UPDATE training_exercises SET {', '.join(fields)} WHERE id=?", vals)
return {"ok": True, "updated": len(fields)}
# ------------------------------------------------------------------
# Übungs-Status
# ------------------------------------------------------------------

View file

@ -43,7 +43,8 @@ async def weather_records(user=Depends(get_current_user)):
rows = conn.execute("""
SELECT d.datum, d.weather_json, d.titel
FROM diary d
WHERE d.user_id = ? AND d.weather_json IS NOT NULL
JOIN dogs dog ON dog.id = d.dog_id
WHERE dog.user_id = ? AND d.weather_json IS NOT NULL
ORDER BY d.datum ASC
""", (uid,)).fetchall()

View file

@ -1,6 +1,6 @@
"""BAN YARO — Widget-Snapshot + Tagesspruch Endpoints"""
import json, random
import json
from datetime import date
from fastapi import APIRouter, Depends, Query
from typing import Optional
@ -59,7 +59,8 @@ async def widget_snapshot(user=Depends(get_current_user)):
(dog_id,)
).fetchall()
random_photo = dict(random.choice(photos)) if photos else None
day_num = (date.today() - date(2024, 1, 1)).days
random_photo = dict(photos[day_num % len(photos)]) if photos else None
# Anzahl überfälliger Erinnerungen
overdue = conn.execute(

View file

@ -156,6 +156,14 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
# Jeden Montag 08:00 Uhr — Neue Foto-Challenge anlegen
_scheduler.add_job(
_job_new_foto_challenge,
CronTrigger(day_of_week='mon', hour=8, minute=0),
id="new_foto_challenge",
replace_existing=True,
misfire_grace_time=3600,
)
# Täglich 07:00 Uhr — Goldene Gassi-Stunde
_scheduler.add_job(
_job_golden_gassi_hour,
@ -181,7 +189,7 @@ def start():
misfire_grace_time=3600,
)
_scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00. OSM-Cache: on-demand (kein Prewarm).")
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@ -1544,6 +1552,46 @@ async def _job_monthly_recap():
_log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}")
async def _job_new_foto_challenge():
"""Jeden Montag 08:00 — neue Foto-Challenge für die aktuelle Woche anlegen."""
from datetime import date, timedelta
from routes.challenges import _CHALLENGE_THEMEN, _current_week_monday, _current_week_sunday
monday = _current_week_monday()
sunday = _current_week_sunday()
week_num = date.today().isocalendar()[1]
thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
with db() as conn:
existing = conn.execute(
"SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
).fetchone()
if existing:
logger.info(f"Foto-Challenge: Woche {monday} bereits vorhanden (id={existing['id']}).")
_log_job("new_foto_challenge", "ok", f"Bereits vorhanden für {monday}")
return
cur = conn.execute(
"INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
"VALUES (?, ?, ?, ?, NULL)",
(thema, f"Diese Woche: {thema}", monday, sunday)
)
challenge_id = cur.lastrowid
# Push an alle User
send_push_to_all({
"type": "foto_challenge",
"title": "📸 Neue Foto-Challenge!",
"body": f"Diese Woche: {thema} — mach mit!",
"data": {"page": "walks", "tab": "challenge"},
"tag": f"challenge-{monday}",
})
logger.info(f"Foto-Challenge angelegt: '{thema}' für {monday}{sunday} (id={challenge_id}).")
_log_job("new_foto_challenge", "ok", f"'{thema}' für {monday}")
async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]:
"""Holt stündliche Wetterdaten für heute von Open-Meteo."""
import httpx

View file

@ -7304,6 +7304,20 @@ svg.empty-state-icon {
color: var(--c-text-secondary);
line-height: 1.2;
}
.exp-kachel-jahr {
font-size: 9px;
color: var(--c-text-muted);
margin-top: 2px;
line-height: 1.2;
}
.exp-kachel-add {
display: flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: var(--c-text-muted);
margin-top: 3px;
}
/* ---- Sektion-Block (Verlauf etc.) ---- */
.exp-section {
@ -7479,6 +7493,36 @@ svg.empty-state-icon {
border-radius: 999px;
padding: 1px 6px;
}
.exp-dog-selector {
display: flex;
gap: 8px;
padding: 10px 16px 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.exp-dog-selector::-webkit-scrollbar { display: none; }
.exp-dog-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 14px;
border-radius: 999px;
border: 1px solid var(--c-border);
background: var(--c-bg-card);
color: var(--c-text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
white-space: nowrap;
transition: background .15s, color .15s, border-color .15s;
}
.exp-dog-pill.active {
background: var(--c-primary);
color: #fff;
border-color: var(--c-primary);
}
/* Rechte Spalte: Betrag + Löschen-Icon */
.exp-entry-right {
@ -8017,28 +8061,35 @@ svg.empty-state-icon {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 16px;
padding: 14px 6px 11px;
padding: 12px 6px;
text-align: center;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
justify-content: center;
gap: 6px;
color: white;
transition: background 0.12s, transform 0.1s;
-webkit-tap-highlight-color: transparent;
user-select: none;
min-height: 80px; /* alle Chips gleich hoch */
}
.world-chip:active {
background: rgba(0, 0, 0, 0.6);
transform: scale(0.93);
}
.world-chip svg { color: white; }
.world-chip svg { color: white; flex-shrink: 0; }
.world-chip-label {
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 1.2;
max-height: 2.4em; /* max. 2 Zeilen */
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Chip-Umrandung je Welt */
@ -8133,3 +8184,189 @@ svg.empty-state-icon {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.25; }
}
/* ── COMMUNITY-FEATURES ──────────────────────────────────── */
/* Walks-Tab-Bar */
.walks-tab-panel { display: flex; flex-direction: column; min-height: 0; flex: 1; }
/* Foto-Challenge */
.challenge-banner {
background: linear-gradient(135deg, var(--c-amber, #f59e0b), var(--c-primary, #C4843A));
border-radius: var(--radius-lg);
margin: var(--space-4);
overflow: hidden;
}
.challenge-banner-inner {
padding: var(--space-5) var(--space-4);
color: #fff;
}
.challenge-thema {
font-size: var(--text-xl);
font-weight: var(--weight-bold);
line-height: 1.2;
margin-bottom: var(--space-2);
}
.challenge-meta {
font-size: var(--text-sm);
opacity: 0.88;
}
.challenge-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-3);
padding: 0 var(--space-4) var(--space-6);
}
.challenge-sub-card {
background: var(--c-surface);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.challenge-sub-card img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
cursor: pointer;
}
.challenge-sub-info {
padding: var(--space-2);
}
.challenge-sub-user {
font-size: var(--text-xs);
color: var(--c-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.challenge-sub-caption {
font-size: var(--text-xs);
color: var(--c-text);
margin-bottom: var(--space-1);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.challenge-vote-btn {
border: none;
background: transparent;
color: var(--c-text-secondary);
font-size: var(--text-xs);
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
gap: 3px;
}
.challenge-vote-btn.voted {
color: var(--c-danger, #ef4444);
}
.challenge-winners { border-top: 1px solid var(--c-border); }
.challenge-winners-row {
display: flex;
gap: var(--space-3);
overflow-x: auto;
padding: var(--space-2) var(--space-4) var(--space-3);
scroll-snap-type: x mandatory;
}
.challenge-winner-chip {
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--c-surface-alt, #fdf6ef);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
min-width: 160px;
flex-shrink: 0;
scroll-snap-align: start;
}
.challenge-winner-chip img {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
object-fit: cover;
flex-shrink: 0;
}
/* Wochentag-Selector */
.wd-selector {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.wd-btn {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 4px 10px;
border: 1.5px solid var(--c-border);
border-radius: var(--radius-full);
font-size: var(--text-sm);
user-select: none;
transition: background .15s, border-color .15s;
}
.wd-btn input { display: none; }
.wd-btn:has(input:checked) {
background: var(--c-primary, #C4843A);
border-color: var(--c-primary, #C4843A);
color: #fff;
}
/* Gassi-Zeit-Karten */
.gassi-zeit-card {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
}
.gz-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
overflow: hidden;
flex-shrink: 0;
background: var(--c-surface-alt);
display: flex;
align-items: center;
justify-content: center;
}
.gz-avatar img { width: 100%; height: 100%; object-fit: cover; }
.gz-avatar-placeholder { font-size: 1.5rem; color: var(--c-text-secondary); }
.gz-body { flex: 1; min-width: 0; }
.gz-name {
font-weight: var(--weight-semibold);
font-size: var(--text-sm);
display: flex;
align-items: center;
gap: var(--space-1);
flex-wrap: wrap;
}
.gz-meta { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; }
.gz-notiz { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; font-style: italic; }
.gz-actions { display: flex; gap: var(--space-1); flex-shrink: 0; }
/* Rassen-Community-Chip */
.breed-community-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--c-surface-alt, #fdf6ef);
border: 1.5px solid var(--c-amber, #f59e0b);
border-radius: var(--radius-full);
padding: 6px 16px;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--c-text);
cursor: pointer;
transition: background .15s;
}
.breed-community-chip:hover, .breed-community-chip:active {
background: #fff3e0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View file

@ -93,9 +93,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=700">
<link rel="stylesheet" href="/css/layout.css?v=700">
<link rel="stylesheet" href="/css/components.css?v=700">
<link rel="stylesheet" href="/css/design-system.css?v=709">
<link rel="stylesheet" href="/css/layout.css?v=709">
<link rel="stylesheet" href="/css/components.css?v=709">
</head>
<body>
@ -409,6 +409,10 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-hilfe">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-settings">
<div class="page-body page-container"></div>
</section>
@ -574,7 +578,7 @@
<script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=94"></script>
<script src="/js/worlds.js?v=700"></script>
<script src="/js/worlds.js?v=727"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -676,5 +680,6 @@
}
</script>
</body>
</html>

View file

@ -3,8 +3,8 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '700'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const APP_VER = '727'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.4.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
const App = (() => {
@ -79,6 +79,7 @@ const App = (() => {
ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true },
personality: { title: 'Persönlichkeitstest', module: null },
reise: { title: 'Reise mit Hund', module: null },
hilfe: { title: 'Hilfe & FAQ', module: null },
};
// ----------------------------------------------------------
@ -484,6 +485,9 @@ const App = (() => {
navigate('onboarding');
}
// Drei Welten nach Login starten (falls noch nicht initialisiert)
if (window.Worlds) window.Worlds.init(state);
_showVerifyBanner();
_updateNotifBadge();
_updateChatBadge();
@ -559,7 +563,8 @@ const App = (() => {
_updateHeaderUserBtn(false);
// Nicht eingeloggte User immer zur Welcome-Seite
window.Worlds?.hide();
document.getElementById('worlds-back')?.classList.remove('worlds-back-visible');
navigate('welcome', false);
}
@ -855,11 +860,8 @@ const App = (() => {
}
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
// Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc.
navigate(state.user ? startPage : 'welcome', false, hashParams);
// Drei Welten nach initialer Navigation starten (damit hide() in navigate() sie nicht gleich killt)
if (window.Worlds) window.Worlds.init(state);
if (window.Worlds && state.user) window.Worlds.init(state);
}
async function _handleInvite(token) {

View file

@ -23,6 +23,8 @@ window.Page_admin = (() => {
{ id: 'partner', label: 'Partner', icon: 'handshake' },
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
];
// ------------------------------------------------------------------
@ -157,6 +159,8 @@ window.Page_admin = (() => {
case 'outreach': await _renderOutreach(el); break;
case 'audit': await _renderAudit(el); break;
case 'bewerbungen': await _renderBewerbungen(el); break;
case 'hilfe': await _renderHilfe(el); break;
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -2809,6 +2813,469 @@ window.Page_admin = (() => {
await _load();
}
// ------------------------------------------------------------------
// TAB: HILFE / FAQ
async function _renderHilfe(el) {
const KAT_LABEL = {
installation: 'Installation & PWA',
erste_schritte: 'Erste Schritte',
standort: 'Standort & Wetter',
account: 'Account & Passwort',
features: 'Features erklärt',
probleme: 'Technische Probleme',
};
el.innerHTML = `
<div style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-4)">
<h2 style="margin:0;font-size:var(--text-lg)">Hilfe / FAQ</h2>
<button class="btn btn-primary btn-sm" id="adm-hilfe-neu">
${UI.icon('plus')} Neuer Artikel
</button>
</div>
<!-- Neuer-Artikel-Formular (versteckt) -->
<div id="adm-hilfe-form" style="display:none;background:var(--c-surface-2);
border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-4);margin-bottom:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Neuer Artikel</h3>
<div style="display:grid;gap:var(--space-3)">
<div>
<label style="font-size:var(--text-sm);font-weight:500;display:block;
margin-bottom:var(--space-1)">Kategorie</label>
<select id="adm-hilfe-kat" style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
${Object.entries(KAT_LABEL).map(([k,v]) =>
`<option value="${k}">${_esc(v)}</option>`
).join('')}
</select>
</div>
<div>
<label style="font-size:var(--text-sm);font-weight:500;display:block;
margin-bottom:var(--space-1)">Frage</label>
<input id="adm-hilfe-frage" type="text" placeholder="Frage eingeben…"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="font-size:var(--text-sm);font-weight:500;display:block;
margin-bottom:var(--space-1)">Antwort</label>
<textarea id="adm-hilfe-antwort" rows="4" placeholder="Antwort eingeben…"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box;
resize:vertical;font-family:inherit"></textarea>
</div>
<div style="display:flex;gap:var(--space-2)">
<label style="font-size:var(--text-sm);font-weight:500;margin-right:var(--space-2)">
Reihenfolge
</label>
<input id="adm-hilfe-sort" type="number" value="0" min="0"
style="width:80px;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
</div>
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary btn-sm" id="adm-hilfe-form-cancel">Abbrechen</button>
<button class="btn btn-primary btn-sm" id="adm-hilfe-form-save">Speichern</button>
</div>
</div>
</div>
<!-- Artikel-Liste -->
<div id="adm-hilfe-list">
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
Lade
</div>
</div>
</div>
`;
async function _load() {
const listEl = el.querySelector('#adm-hilfe-list');
try {
const articles = await API.get('/help?all=1');
if (!articles.length) {
listEl.innerHTML = _emptyState('question', 'Noch keine FAQ-Artikel', '');
return;
}
// Gruppieren nach Kategorie
const grouped = {};
for (const a of articles) {
if (!grouped[a.kategorie]) grouped[a.kategorie] = [];
grouped[a.kategorie].push(a);
}
let html = '';
for (const [kat, items] of Object.entries(grouped)) {
const label = KAT_LABEL[kat] || kat;
html += `
<div style="margin-bottom:var(--space-5)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:0.05em;
padding:var(--space-2) 0;margin-bottom:var(--space-2);
border-bottom:1px solid var(--c-border)">
${_esc(label)}
</div>
`;
for (const a of items) {
html += `
<div class="adm-hilfe-row" data-id="${a.id}"
style="border:1px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);margin-bottom:var(--space-2)">
<!-- Zusammenfassung -->
<div style="display:flex;align-items:center;gap:var(--space-2);
padding:var(--space-3) var(--space-4)">
<span style="flex:1;font-size:var(--text-sm);font-weight:500;
${a.aktiv ? '' : 'opacity:0.45;text-decoration:line-through'}">
${_esc(a.frage)}
</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted);
white-space:nowrap">
#${a.sort_order}
</span>
<button class="btn btn-sm adm-hilfe-edit-btn"
style="padding:2px 8px;font-size:var(--text-xs)"
data-id="${a.id}">
${UI.icon('pencil-simple')} Bearbeiten
</button>
<button class="btn btn-sm adm-hilfe-toggle-btn"
style="padding:2px 8px;font-size:var(--text-xs);
background:${a.aktiv ? 'var(--c-warning-bg,#fef3c7)' : 'var(--c-success-bg,#d1fae5)'};
color:${a.aktiv ? 'var(--c-warning,#92400e)' : 'var(--c-success,#065f46)'}"
data-id="${a.id}" data-aktiv="${a.aktiv}">
${a.aktiv ? UI.icon('eye-slash') + ' Ausblenden' : UI.icon('eye') + ' Einblenden'}
</button>
<button class="btn btn-sm adm-hilfe-del-btn"
style="padding:2px 8px;font-size:var(--text-xs);
background:var(--c-danger-bg,#fee2e2);color:var(--c-danger,#991b1b)"
data-id="${a.id}" data-frage="${_esc(a.frage)}">
${UI.icon('trash')}
</button>
</div>
<!-- Edit-Formular (versteckt) -->
<div class="adm-hilfe-edit-form" data-id="${a.id}"
style="display:none;padding:0 var(--space-4) var(--space-4);
border-top:1px solid var(--c-border)">
<div style="display:grid;gap:var(--space-3);padding-top:var(--space-3)">
<div>
<label style="font-size:var(--text-xs);font-weight:600;display:block;
margin-bottom:4px;color:var(--c-text-secondary)">Kategorie</label>
<select class="adm-hilfe-edit-kat"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
${Object.entries(KAT_LABEL).map(([k,v]) =>
`<option value="${k}" ${k === a.kategorie ? 'selected' : ''}>${_esc(v)}</option>`
).join('')}
</select>
</div>
<div>
<label style="font-size:var(--text-xs);font-weight:600;display:block;
margin-bottom:4px;color:var(--c-text-secondary)">Frage</label>
<input type="text" class="adm-hilfe-edit-frage"
value="${_esc(a.frage)}"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="font-size:var(--text-xs);font-weight:600;display:block;
margin-bottom:4px;color:var(--c-text-secondary)">Antwort</label>
<textarea class="adm-hilfe-edit-antwort" rows="5"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box;
resize:vertical;font-family:inherit">${_esc(a.antwort)}</textarea>
</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<label style="font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary)">Reihenfolge</label>
<input type="number" class="adm-hilfe-edit-sort"
value="${a.sort_order}" min="0"
style="width:70px;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
<div style="flex:1"></div>
<button class="btn btn-secondary btn-sm adm-hilfe-edit-cancel" data-id="${a.id}"
style="font-size:var(--text-xs)">Abbrechen</button>
<button class="btn btn-primary btn-sm adm-hilfe-edit-save" data-id="${a.id}"
style="font-size:var(--text-xs)">Speichern</button>
</div>
</div>
</div>
</div>
`;
}
html += `</div>`;
}
listEl.innerHTML = html;
_bindListEvents(listEl);
} catch (e) {
listEl.innerHTML = _emptyState('warning', 'Fehler beim Laden', e.message || '');
}
}
function _bindListEvents(listEl) {
// Edit-Button: Inline-Formular auf/zu klappen
listEl.querySelectorAll('.adm-hilfe-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const form = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
if (form) form.style.display = form.style.display === 'none' ? '' : 'none';
});
});
// Edit-Cancel
listEl.querySelectorAll('.adm-hilfe-edit-cancel').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const form = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
if (form) form.style.display = 'none';
});
});
// Edit-Save
listEl.querySelectorAll('.adm-hilfe-edit-save').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const row = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
const payload = {
kategorie: row.querySelector('.adm-hilfe-edit-kat').value,
frage: row.querySelector('.adm-hilfe-edit-frage').value.trim(),
antwort: row.querySelector('.adm-hilfe-edit-antwort').value.trim(),
sort_order: parseInt(row.querySelector('.adm-hilfe-edit-sort').value, 10) || 0,
};
if (!payload.frage || !payload.antwort) {
UI.toast.error('Frage und Antwort sind Pflichtfelder.');
return;
}
try {
await API.patch(`/help/${id}`, payload);
UI.toast.success('Artikel gespeichert.');
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); }
});
});
// Toggle aktiv/inaktiv
listEl.querySelectorAll('.adm-hilfe-toggle-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const aktiv = parseInt(btn.dataset.aktiv, 10);
try {
await API.patch(`/help/${id}`, { aktiv: aktiv ? 0 : 1 });
UI.toast.success(aktiv ? 'Artikel ausgeblendet.' : 'Artikel eingeblendet.');
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
// Delete
listEl.querySelectorAll('.adm-hilfe-del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const frage = btn.dataset.frage;
if (!window.confirm(`Artikel wirklich löschen?\n\n"${frage}"`)) return;
try {
await API.del(`/help/${id}`);
UI.toast.success('Artikel gelöscht.');
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Löschen.'); }
});
});
}
// Neuer-Artikel-Button
el.querySelector('#adm-hilfe-neu').addEventListener('click', () => {
const form = el.querySelector('#adm-hilfe-form');
form.style.display = form.style.display === 'none' ? '' : 'none';
});
// Formular abbrechen
el.querySelector('#adm-hilfe-form-cancel').addEventListener('click', () => {
el.querySelector('#adm-hilfe-form').style.display = 'none';
});
// Formular speichern
el.querySelector('#adm-hilfe-form-save').addEventListener('click', async () => {
const kat = el.querySelector('#adm-hilfe-kat').value;
const frage = el.querySelector('#adm-hilfe-frage').value.trim();
const antwort= el.querySelector('#adm-hilfe-antwort').value.trim();
const sort = parseInt(el.querySelector('#adm-hilfe-sort').value, 10) || 0;
if (!frage || !antwort) {
UI.toast.error('Frage und Antwort sind Pflichtfelder.');
return;
}
try {
await API.post('/help', { kategorie: kat, frage, antwort, sort_order: sort });
UI.toast.success('Artikel angelegt.');
el.querySelector('#adm-hilfe-form').style.display = 'none';
el.querySelector('#adm-hilfe-frage').value = '';
el.querySelector('#adm-hilfe-antwort').value = '';
el.querySelector('#adm-hilfe-sort').value = '0';
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Anlegen.'); }
});
await _load();
}
// ------------------------------------------------------------------
async function _renderUebungenAdmin(el) {
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade Übungen…</div>`;
let byTab;
try {
byTab = await API.get('/training/exercises');
} catch (e) {
el.innerHTML = `<div style="padding:var(--space-6);color:var(--c-danger)">Fehler: ${e.message}</div>`;
return;
}
// Flatten to sorted list grouped by kategorie
const allExercises = [];
for (const [kat, list] of Object.entries(byTab)) {
for (const ex of list) allExercises.push({ ...ex, _kat: kat });
}
allExercises.sort((a, b) => a._kat.localeCompare(b._kat) || a.name.localeCompare(b.name));
// Group by kategorie
const grouped = {};
for (const ex of allExercises) {
grouped[ex._kat] = grouped[ex._kat] || [];
grouped[ex._kat].push(ex);
}
const KAT_LABELS = {
'grundkommandos': 'Grundkommandos', 'tricks': 'Tricks',
'problemverhalten': 'Problemverhalten', 'mentale-auslastung': 'Mentale Auslastung',
'koerperpflege': 'Körperpflege', 'hundesport': 'Hundesport', 'welpe-basics': 'Welpe Basics',
};
let html = `<div style="padding:var(--space-4)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4)">
Trainingsübungen bearbeiten
</h2>`;
for (const [kat, list] of Object.entries(grouped)) {
html += `<div style="margin-bottom:var(--space-6)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-secondary);
padding:var(--space-2) 0 var(--space-2);
border-bottom:1px solid var(--c-border);margin-bottom:var(--space-2)">
${KAT_LABELS[kat] || kat} (${list.length})
</div>`;
for (const ex of list) {
const schritte = Array.isArray(ex.schritte) ? ex.schritte.join('\n') : '';
const exId = ex.exercise_id;
html += `<div class="adm-ueb-row" data-ex-id="${exId}"
style="padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-light)">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="flex:1;font-size:var(--text-sm);font-weight:500">${ex.name}</span>
<button class="adm-ueb-edit-btn" data-ex-id="${exId}"
style="font-size:var(--text-xs);padding:2px 10px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-surface);
cursor:pointer;color:var(--c-text-secondary)">Bearbeiten</button>
</div>
<div class="adm-ueb-form" data-ex-id="${exId}"
style="display:none;margin-top:var(--space-3);
background:var(--c-surface-2);border-radius:8px;padding:var(--space-3)">
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
Beschreibung
</label>
<textarea class="adm-ueb-beschreibung" rows="2"
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-2);
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-bg);
color:var(--c-text);resize:vertical">${(ex.beschreibung || '').replace(/</g, '&lt;')}</textarea>
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
Schritte (eine Zeile = ein Schritt)
</label>
<textarea class="adm-ueb-schritte" rows="6"
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-2);
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-bg);
color:var(--c-text);resize:vertical">${schritte.replace(/</g, '&lt;')}</textarea>
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
Tipp
</label>
<input class="adm-ueb-tipp" type="text"
value="${(ex.tipp || '').replace(/"/g, '&quot;')}"
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-3);
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-bg);color:var(--c-text)">
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="adm-ueb-cancel-btn" data-ex-id="${exId}"
style="font-size:var(--text-xs);padding:4px 14px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-surface);
cursor:pointer;color:var(--c-text-secondary)">Abbrechen</button>
<button class="adm-ueb-save-btn" data-ex-id="${exId}"
style="font-size:var(--text-xs);padding:4px 14px;border-radius:6px;
border:none;background:var(--c-primary);
cursor:pointer;color:#fff;font-weight:600">Speichern</button>
</div>
</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
el.innerHTML = html;
// Edit toggle
el.querySelectorAll('.adm-ueb-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const form = el.querySelector(`.adm-ueb-form[data-ex-id="${btn.dataset.exId}"]`);
form.style.display = form.style.display === 'none' ? '' : 'none';
});
});
// Cancel
el.querySelectorAll('.adm-ueb-cancel-btn').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelector(`.adm-ueb-form[data-ex-id="${btn.dataset.exId}"]`).style.display = 'none';
});
});
// Save
el.querySelectorAll('.adm-ueb-save-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.exId;
const form = el.querySelector(`.adm-ueb-form[data-ex-id="${id}"]`);
const beschreibung = form.querySelector('.adm-ueb-beschreibung').value.trim();
const schritte = form.querySelector('.adm-ueb-schritte').value
.split('\n').map(s => s.trim()).filter(Boolean);
const tipp = form.querySelector('.adm-ueb-tipp').value.trim();
btn.disabled = true;
btn.textContent = 'Speichert…';
try {
await API.put(`/training/exercises/${id}`, {
beschreibung,
schritte: JSON.stringify(schritte),
tipp,
});
UI.toast.success('Übung gespeichert.');
form.style.display = 'none';
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Speichern.');
} finally {
btn.disabled = false;
btn.textContent = 'Speichern';
}
});
});
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };

View file

@ -97,8 +97,11 @@ window.Page_dog_profile = (() => {
<h2 style="font-size:var(--text-2xl);font-weight:700;
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
${dog.rasse
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-5)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-5)"></p>`}
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-2)"></p>`}
<!-- Rassen-Community-Chip (wird async geladen) -->
<div id="dp-same-breed-chip" style="margin-bottom:var(--space-4)"></div>
<!-- Info-Grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
@ -245,6 +248,9 @@ window.Page_dog_profile = (() => {
// Pflegetipps laden
_loadPflegeTipps(dog);
// Rassen-Community-Chip laden (falls Rasse bekannt)
if (dog.rasse) _loadSameBreedChip();
// Sitter-Zugang laden (nur für Besitzer)
if (dog.user_id === _appState.user?.id) {
_loadSittingAccess(dog.id);
@ -2386,6 +2392,32 @@ window.Page_dog_profile = (() => {
}
// ----------------------------------------------------------
// RASSEN-COMMUNITY-CHIP
// ----------------------------------------------------------
async function _loadSameBreedChip() {
const el = document.getElementById('dp-same-breed-chip');
if (!el) return;
try {
const data = await API.get('friends/same-breed');
if (!data || data.count === 0) return;
const hauptRasse = data.rassen[0]?.rasse || '';
const label = data.count === 1
? `1 anderer ${_esc(hauptRasse)}-Halter in der App`
: `${data.count} andere ${_esc(hauptRasse)}-Halter in der App`;
el.innerHTML = `
<button class="breed-community-chip" id="dp-breed-chip-btn">
🐕 ${label} &mdash; Forum ansehen
</button>
`;
document.getElementById('dp-breed-chip-btn')?.addEventListener('click', () => {
App.navigate('forum', false, { search: hauptRasse });
});
} catch {}
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------

View file

@ -125,47 +125,64 @@ window.Page_ernaehrung = (() => {
el.innerHTML = `
<div style="padding:var(--space-4) 0">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Berechne den täglichen Kalorienbedarf deines Hundes.
</p>
<style>
.ern-pill-group { display:flex; gap:8px; flex-wrap:wrap; }
.ern-pill {
flex:1; min-width:0; padding:10px 8px; border-radius:12px;
border:1.5px solid var(--c-border); background:var(--c-bg-card);
color:var(--c-text-secondary); font-size:var(--text-xs); font-weight:600;
cursor:pointer; text-align:center; transition:all .15s; line-height:1.3;
}
.ern-pill.active {
background:var(--c-primary); color:#fff; border-color:var(--c-primary);
}
.ern-input-row {
display:grid; grid-template-columns:1fr 1fr; gap:var(--space-3);
margin-bottom:var(--space-4);
}
.ern-field { display:flex; flex-direction:column; gap:6px; }
.ern-field label { font-size:var(--text-xs); color:var(--c-text-secondary); font-weight:600; text-transform:uppercase; letter-spacing:.04em; }
.ern-field input { padding:12px 14px; border-radius:12px; border:1.5px solid var(--c-border); background:var(--c-bg-card); color:var(--c-text); font-size:var(--text-base); font-weight:700; width:100%; box-sizing:border-box; }
.ern-section-label { font-size:var(--text-xs); color:var(--c-text-secondary); font-weight:600; text-transform:uppercase; letter-spacing:.04em; margin-bottom:8px; }
</style>
<div class="by-form-group">
<label class="by-label">Gewicht (kg)</label>
<input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100"
class="by-input" value="${_esc(gewichtDefault)}" placeholder="z. B. 15">
</div>
<div class="by-form-group">
<label class="by-label">Alter (Jahre)</label>
<input id="ern-alter" type="number" step="0.5" min="0" max="25"
class="by-input" value="${_esc(alterDefault)}" placeholder="z. B. 3">
</div>
<div class="by-form-group">
<label class="by-label">Aktivität</label>
<select id="ern-aktivitaet" class="by-select">
<option value="gering">Gering (Couch-Hund)</option>
<option value="normal" selected>Normal</option>
<option value="aktiv">Aktiv</option>
<option value="sport">Sehr aktiv (Sport)</option>
</select>
</div>
<div class="by-form-group">
<label class="by-label">Kastriert</label>
<div style="display:flex;gap:var(--space-3)">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="ern-kastriert" value="ja"> Ja
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="ern-kastriert" value="nein" checked> Nein
</label>
<!-- Gewicht + Alter nebeneinander -->
<div class="ern-input-row">
<div class="ern-field">
<label> Gewicht (kg)</label>
<input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100"
value="${_esc(gewichtDefault)}" placeholder="15">
</div>
<div class="ern-field">
<label>🎂 Alter (Jahre)</label>
<input id="ern-alter" type="number" step="0.5" min="0" max="25"
value="${_esc(alterDefault)}" placeholder="3">
</div>
</div>
<button class="btn btn-primary" id="ern-rechner-btn" style="width:100%">
<!-- Aktivität als Pill-Buttons -->
<div style="margin-bottom:var(--space-4)">
<div class="ern-section-label">🏃 Aktivität</div>
<div class="ern-pill-group">
<button class="ern-pill" data-akt="gering">🛋 Gemütlich</button>
<button class="ern-pill active" data-akt="normal">🚶 Normal</button>
<button class="ern-pill" data-akt="aktiv">🏃 Aktiv</button>
<button class="ern-pill" data-akt="sport">🏅 Sportlich</button>
</div>
</div>
<!-- Kastriert als Pill-Buttons -->
<div style="margin-bottom:var(--space-5)">
<div class="ern-section-label"> Kastriert / Sterilisiert</div>
<div class="ern-pill-group">
<button class="ern-pill active" data-kas="nein" style="flex:none;width:calc(50% - 4px)">Nein</button>
<button class="ern-pill" data-kas="ja" style="flex:none;width:calc(50% - 4px)">Ja</button>
</div>
</div>
<button class="btn btn-primary" id="ern-rechner-btn" style="width:100%;padding:14px;font-size:var(--text-base)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calculator"></use></svg>
Berechnen
Kalorienbedarf berechnen
</button>
<div id="ern-rechner-result" style="display:none;margin-top:var(--space-5)"></div>
@ -208,13 +225,28 @@ window.Page_ernaehrung = (() => {
</div>
`;
// Aktivität Pills
el.querySelectorAll('[data-akt]').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelectorAll('[data-akt]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Kastriert Pills
el.querySelectorAll('[data-kas]').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelectorAll('[data-kas]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
el.querySelector('#ern-rechner-btn').addEventListener('click', () => _berechne(el));
}
function _berechne(el) {
const gewicht = parseFloat(el.querySelector('#ern-gewicht').value);
const aktivitaet = el.querySelector('#ern-aktivitaet').value;
const kastriert = el.querySelector('input[name="ern-kastriert"]:checked')?.value === 'ja';
const gewicht = parseFloat(el.querySelector('#ern-gewicht').value);
const aktivitaet = el.querySelector('[data-akt].active')?.dataset.akt || 'normal';
const kastriert = el.querySelector('[data-kas].active')?.dataset.kas === 'ja';
if (!gewicht || gewicht < 0.5) {
UI.toast.warning('Bitte ein gültiges Gewicht eingeben.');

View file

@ -5,15 +5,26 @@
window.Page_expenses = (() => {
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
let _selectedDogId = null;
// Cache
let _summary = null;
let _entries = [];
let _statsData = null;
function _dogParam() {
return _selectedDogId ? `?dog_id=${_selectedDogId}` : '';
}
function _dogParamAnd() {
return _selectedDogId ? `&dog_id=${_selectedDogId}` : '';
}
function _clearCache() {
_summary = null; _entries = []; _statsData = null;
}
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' },
@ -38,11 +49,10 @@ window.Page_expenses = (() => {
// LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_summary = null;
_entries = [];
_statsData = null;
_container = container;
_appState = appState;
_selectedDogId = null;
_clearCache();
_render();
}
@ -56,6 +66,16 @@ window.Page_expenses = (() => {
// ----------------------------------------------------------
// SHELL
// ----------------------------------------------------------
function _dogSelectorHtml() {
const dogs = _appState?.dogs || [];
if (dogs.length < 2) return '';
const pills = [{ id: null, name: 'Alle' }, ...dogs].map(d => `
<button class="exp-dog-pill${_selectedDogId === d.id ? ' active' : ''}" data-dog="${d.id ?? ''}">
${d.id ? UI.icon('paw-print') : ''} ${_esc(d.name)}
</button>`).join('');
return `<div class="exp-dog-selector" id="exp-dog-selector">${pills}</div>`;
}
function _render() {
_container.innerHTML = `
<div class="by-tabs exp-tabs" id="exp-tabs">
@ -65,6 +85,7 @@ window.Page_expenses = (() => {
</button>
`).join('')}
</div>
${_dogSelectorHtml()}
<div id="exp-content"></div>
<button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
${UI.icon('plus')}
@ -81,6 +102,17 @@ window.Page_expenses = (() => {
});
});
_container.querySelector('#exp-dog-selector')?.addEventListener('click', e => {
const pill = e.target.closest('.exp-dog-pill');
if (!pill) return;
_selectedDogId = pill.dataset.dog ? parseInt(pill.dataset.dog) : null;
_clearCache();
_container.querySelectorAll('.exp-dog-pill').forEach(p =>
p.classList.toggle('active', p.dataset.dog === (pill.dataset.dog))
);
_renderTab();
});
_container.querySelector('#exp-fab')
?.addEventListener('click', () => _showForm(null));
@ -111,7 +143,7 @@ window.Page_expenses = (() => {
// ----------------------------------------------------------
async function _renderUebersicht(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
_summary = await API.get('/expenses/summary' + _dogParam());
}
const s = _summary;
@ -120,14 +152,20 @@ window.Page_expenses = (() => {
const trendHtml = _trendHtml(letzteMonat);
const kacheln = KATEGORIEN.map(k => {
const betrag = s.monat[k.id] || 0;
const monat = s.monat[k.id] || 0;
const jahr = s.jahr[k.id] || 0;
const monatLine = monat > 0
? `<div class="exp-kachel-jahr">${_fmt(monat)} diesen Monat</div>`
: '';
return `
<div class="exp-kachel">
<div class="exp-kachel" data-kat="${k.id}" style="cursor:pointer">
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-kachel-betrag" style="color:var(--c-primary)">${_fmt(betrag)}</div>
<div class="exp-kachel-betrag" style="color:var(--c-primary)">${_fmt(jahr)}</div>
<div class="exp-kachel-label">${k.label}</div>
${monatLine}
<div class="exp-kachel-add">${UI.icon('plus')} eintragen</div>
</div>`;
}).join('');
@ -146,11 +184,15 @@ window.Page_expenses = (() => {
${verlauf}
<div style="height:80px"></div>
`;
el.querySelectorAll('.exp-kachel[data-kat]').forEach(k => {
k.addEventListener('click', () => _showForm(null, k.dataset.kat));
});
}
async function _getLetzteMonateData() {
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
_entries = await API.get('/expenses?limit=500' + _dogParamAnd());
}
const monatMap = {};
_entries.forEach(e => {
@ -208,7 +250,7 @@ window.Page_expenses = (() => {
// ----------------------------------------------------------
async function _renderEintraege(el) {
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
_entries = await API.get('/expenses?limit=500' + _dogParamAnd());
}
if (!_entries.length) {
@ -321,6 +363,7 @@ window.Page_expenses = (() => {
async function _renderDauerauftraege(el) {
let recurring = [];
try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ }
if (_selectedDogId) recurring = recurring.filter(r => r.dog_id === _selectedDogId);
const cards = recurring.map(r => {
const k = _kat(r.kategorie);
@ -481,10 +524,10 @@ window.Page_expenses = (() => {
// ----------------------------------------------------------
async function _renderStatistik(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
_summary = await API.get('/expenses/summary' + _dogParam());
}
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
_entries = await API.get('/expenses?limit=500' + _dogParamAnd());
}
const s = _summary;
@ -637,14 +680,15 @@ window.Page_expenses = (() => {
// ----------------------------------------------------------
// FORMULAR — Neu / Bearbeiten
// ----------------------------------------------------------
function _showForm(entry) {
function _showForm(entry, preKat) {
const isEdit = !!entry;
const today = new Date().toISOString().split('T')[0];
const formId = 'exp-form';
const selKat = entry?.kategorie || 'sonstiges';
const selKat = entry?.kategorie || preKat || 'sonstiges';
const defaultDogId = entry?.dog_id ?? _selectedDogId;
const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}"${entry?.dog_id === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
`<option value="${d.id}"${defaultDogId === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
).join('');
// Kategorie-Kacheln statt Dropdown
@ -730,6 +774,9 @@ window.Page_expenses = (() => {
const modal = UI.modal.open({ title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', body, footer });
// Betrag-Feld fokussieren (besonders beim Schnelleintrag per Kachel)
setTimeout(() => modal.querySelector('input[name="betrag"]')?.focus(), 200);
// Kategorie-Kacheln interaktiv
modal.querySelectorAll('.exp-kat-tile').forEach(tile => {
tile.addEventListener('click', () => {

View file

@ -62,12 +62,21 @@ window.Page_forum = (() => {
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
async function init(container, appState, params = {}) {
_container = container;
_appState = appState;
_render();
_loadHdmCard();
_loadThreads(true);
// Rassen-Suche vorausfüllen (Feature 3: Same-Breed-Chip)
if (params.search) {
const searchInput = document.getElementById('forum-search');
if (searchInput) {
searchInput.value = params.search;
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
}
function refresh() {

View file

@ -0,0 +1,246 @@
/* ============================================================
BAN YARO Hilfe & FAQ
Akkordeon-FAQ mit Suche, gruppiert nach Kategorie.
============================================================ */
window.Page_hilfe = (() => {
let _container = null;
let _appState = null;
let _articles = [];
let _search = '';
const KAT_LABEL = {
installation: 'Installation & PWA',
erste_schritte: 'Erste Schritte',
standort: 'Standort & Wetter',
account: 'Account & Passwort',
features: 'Features erklärt',
probleme: 'Technische Probleme',
};
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_search = '';
_renderShell();
await _load();
}
async function refresh() {
await _load();
}
// ----------------------------------------------------------
function _renderShell() {
_container.innerHTML = `
<div style="padding:var(--space-4) var(--space-4) 0">
<!-- Suchfeld -->
<div style="position:relative;margin-bottom:var(--space-5)">
<svg class="ph-icon" aria-hidden="true"
style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
width:1.25rem;height:1.25rem;color:var(--c-text-muted);pointer-events:none">
<use href="/icons/phosphor.svg#magnifying-glass"></use>
</svg>
<input id="hilfe-search" type="search" autocomplete="off"
placeholder="Suche in den FAQ…"
style="width:100%;padding:var(--space-3) var(--space-3) var(--space-3) 2.75rem;
border:1.5px solid var(--c-border);border-radius:var(--radius-lg);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-base);box-sizing:border-box;
outline:none;transition:border-color 0.15s">
</div>
</div>
<!-- Artikel-Liste -->
<div id="hilfe-articles" style="padding:0 var(--space-4) var(--space-8)">
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">
Lade
</div>
</div>
`;
_container.querySelector('#hilfe-search').addEventListener('input', e => {
_search = e.target.value.trim().toLowerCase();
_render();
});
}
// ----------------------------------------------------------
async function _load() {
try {
_articles = await API.get('/help');
} catch {
_articles = [];
}
_render();
}
// ----------------------------------------------------------
function _render() {
const el = _container.querySelector('#hilfe-articles');
if (!el) return;
// Filter nach Suchbegriff
const filtered = _search
? _articles.filter(a =>
a.frage.toLowerCase().includes(_search) ||
a.antwort.toLowerCase().includes(_search)
)
: _articles;
if (!filtered.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<svg class="ph-icon" aria-hidden="true"
style="width:40px;height:40px;color:var(--c-border);margin-bottom:var(--space-3)">
<use href="/icons/phosphor.svg#magnifying-glass"></use>
</svg>
<p style="font-weight:var(--weight-semibold);color:var(--c-text);margin:0 0 var(--space-1)">
Keine Ergebnisse
</p>
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">
${_search
? `Zu "${_esc(_search)}" wurde nichts gefunden.`
: 'Noch keine FAQ-Artikel vorhanden.'}
</p>
</div>
`;
return;
}
// Gruppieren nach Kategorie (Reihenfolge der KAT_LABEL-Keys)
const katOrder = Object.keys(KAT_LABEL);
const grouped = {};
for (const a of filtered) {
if (!grouped[a.kategorie]) grouped[a.kategorie] = [];
grouped[a.kategorie].push(a);
}
// Sortieren nach KAT_LABEL-Reihenfolge, dann unbekannte hinten
const sortedKats = [
...katOrder.filter(k => grouped[k]),
...Object.keys(grouped).filter(k => !katOrder.includes(k)),
];
let html = '';
for (const kat of sortedKats) {
const items = grouped[kat];
const label = KAT_LABEL[kat] || kat;
html += `
<div style="margin-bottom:var(--space-6)">
<div style="font-size:var(--text-xs);font-weight:700;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.08em;padding:var(--space-1) 0 var(--space-2);
margin-bottom:var(--space-1)">
${_esc(label)}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-1)">
`;
for (const a of items) {
const answerId = `hilfe-ans-${a.id}`;
const chevronId = `hilfe-chev-${a.id}`;
// Highlight Suchtreffer in der Frage
const frageHtml = _search
? _highlight(a.frage, _search)
: _esc(a.frage);
// Antwort: Zeilenumbrüche in <br> wandeln
const antwortHtml = _search
? _highlight(a.antwort, _search).replace(/\n/g, '<br>')
: _esc(a.antwort).replace(/\n/g, '<br>');
// Bei aktiver Suche: Antwort gleich aufgeklappt
const openByDefault = !!_search;
html += `
<div style="border:1px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);overflow:hidden">
<button class="hilfe-frage-btn"
data-target="${answerId}" data-chevron="${chevronId}"
aria-expanded="${openByDefault}"
style="width:100%;text-align:left;background:none;border:none;
padding:var(--space-4);cursor:pointer;
display:flex;align-items:flex-start;gap:var(--space-2);
font-size:var(--text-sm);font-weight:600;
color:var(--c-text);line-height:1.4">
<span style="flex:1">${frageHtml}</span>
<svg id="${chevronId}" class="ph-icon" aria-hidden="true"
style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;
color:var(--c-text-muted);
transform:rotate(${openByDefault ? '180deg' : '0deg'});
transition:transform 0.2s">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</button>
<div id="${answerId}"
style="overflow:hidden;
max-height:${openByDefault ? '2000px' : '0'};
transition:max-height 0.25s ease">
<div style="padding:0 var(--space-4) var(--space-4);
font-size:var(--text-sm);line-height:1.65;
color:var(--c-text-secondary);
border-top:1px solid var(--c-border)">
<div style="padding-top:var(--space-3)">${antwortHtml}</div>
</div>
</div>
</div>
`;
}
html += `</div></div>`;
}
el.innerHTML = html;
// Akkordeon-Interaktion
el.querySelectorAll('.hilfe-frage-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.target;
const chevronId = btn.dataset.chevron;
const answer = document.getElementById(targetId);
const chevron = document.getElementById(chevronId);
if (!answer) return;
const isOpen = answer.style.maxHeight !== '0px' && answer.style.maxHeight !== '';
if (isOpen) {
answer.style.maxHeight = '0';
if (chevron) chevron.style.transform = 'rotate(0deg)';
btn.setAttribute('aria-expanded', 'false');
} else {
answer.style.maxHeight = '2000px';
if (chevron) chevron.style.transform = 'rotate(180deg)';
btn.setAttribute('aria-expanded', 'true');
}
});
});
}
// ----------------------------------------------------------
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _highlight(text, term) {
if (!term) return text;
const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${safe})`, 'gi');
return _esc(text).replace(re,
'<mark style="background:var(--c-warning-bg,#fef3c7);color:inherit;border-radius:2px">$1</mark>'
);
}
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -30,7 +30,7 @@ window.Page_jobs = (() => {
}
_container.innerHTML = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<div style="max-width:640px;margin:0 auto;padding:0;box-sizing:border-box">
<!-- Header -->
<div style="text-align:center;margin-bottom:var(--space-6)">
@ -156,7 +156,7 @@ window.Page_jobs = (() => {
value="${u ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
<div class="form-group" style="margin:0">
<label class="form-label">Hunde-Name</label>
<input class="form-control" type="text" name="dog_name" placeholder="z. B. Bella">

View file

@ -840,15 +840,21 @@ window.Page_map = (() => {
// Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv)
const PIN_TYPES = [
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', exclusive: true },
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' },
{ type: 'drinking_water',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle',color: '#0EA5E9' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', exclusive: true },
{ type: 'gefahr', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B' },
{ type: 'freilauf', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'restaurant', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316' },
{ type: 'shop', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6' },
{ type: 'tierarzt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444' },
{ type: 'hundeschule', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6' },
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' },
{ type: 'drinking_water',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle',color: '#0EA5E9' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
];
function _confirmPlacement(latlng) {

View file

@ -0,0 +1,496 @@
/* ============================================================
BAN YARO Reise mit Hund
Tabs: Checkliste | EU-Länder | Notfälle
============================================================ */
window.Page_reise = (() => {
let _container = null;
let _appState = null;
let _activeTab = 'checkliste';
const TABS = [
{ key: 'checkliste', label: 'Checkliste', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-square"></use></svg>' },
{ key: 'laender', label: 'EU-Länder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg>' },
];
const CHECKLIST = [
{
key: 'dokumente',
label: 'Dokumente',
icon: 'file-text',
items: [
'EU-Heimtierausweis (Pflicht innerhalb EU)',
'Impfpass (Tollwut mind. 21 Tage alt)',
'Krankenkassen-Notfallkarte Tierarzt',
'Foto des Hundes (für Vermisst-Fall)',
'Chip-Nummer notiert',
],
},
{
key: 'gesundheit',
label: 'Gesundheit',
icon: 'heartbeat',
items: [
'Zecken-/Flohschutz aufgefrischt',
'Reisekrankheit-Mittel (falls nötig)',
'Medikamente ausreichend eingepackt',
'Tierarzt-Kontakt am Zielort recherchiert',
'Verbandszeug für Hunde',
],
},
{
key: 'ausruestung',
label: 'Ausrüstung',
icon: 'backpack',
items: [
'Leine + Ersatzleine',
'Halsband mit Adressanhänger',
'Transportbox/Reisekorb',
'Lieblingsdecke/Schlafplatz',
'Spielzeug (23 Stück)',
],
},
{
key: 'futter',
label: 'Futter & Wasser',
icon: 'bowl-food',
items: [
'Genug Futter (+ Reserve)',
'Wassernapf + Flasche',
'Futternapf',
'Bekannte Leckerlis',
],
},
{
key: 'auto',
label: 'Im Auto',
icon: 'car',
items: [
'Sicherheitsgurt-Adapter oder Box gesichert',
'Sonnenschutz-Netz für Fenster',
'Pausen alle 2h eingeplant',
],
},
];
const LAENDER = [
{ flag: '🇩🇪', name: 'Deutschland', regel: 'Keine Einschränkungen bei EU-Pass + Chip' },
{ flag: '🇦🇹', name: 'Österreich', regel: 'Gleiche Regeln wie DE, Leinenpflicht in Bergbahnen' },
{ flag: '🇨🇭', name: 'Schweiz', regel: 'Nicht-EU → eigene Einfuhrregeln, Tollwut-Titer-Test', warn: true },
{ flag: '🇮🇹', name: 'Italien', regel: 'Leinenpflicht öffentlich, Maulkorb in öffentlichen Verkehrsmitteln' },
{ flag: '🇫🇷', name: 'Frankreich', regel: 'Manche Strände im Sommer hundeverboten' },
{ flag: '🇬🇷', name: 'Griechenland', regel: 'Hunde erlaubt, kaum Einschränkungen' },
{ flag: '🇭🇷', name: 'Kroatien', regel: 'Viele Strände hundefreundlich, EU-Pass genügt' },
{ flag: '🇬🇧', name: 'Großbritannien', regel: 'Strenge Einreise! PETS-Zertifikat + Tollwut-Impfung + Bandwurm-Behandlung nötig', warn: true },
];
const SOFORTMASSNAHMEN = [
{ icon: 'thermometer-hot', text: 'Hitzschlag: Sofort Schatten, kühlen mit lauwarmem Wasser, Tierarzt rufen' },
{ icon: 'skull', text: 'Vergiftung: Ruhig halten, NICHT erbrechen lassen, sofort Tiergift-Notfall anrufen' },
{ icon: 'drop', text: 'Starke Blutung: Druckverband anlegen, Druck halten, Tierarzt aufsuchen' },
{ icon: 'bone', text: 'Knochenbruch: Ruhigstellen, nicht bewegen, Tierarzt aufsuchen' },
{ icon: 'heartbeat', text: 'Bewusstlosigkeit: Atemwege freihalten, stabile Seitenlage, 112 rufen' },
];
const LS_KEY = 'banyaro_reise_checkliste';
const LS_CUSTOM_KEY = 'banyaro_reise_custom'; // {catKey: ["custom item",...]}
const LS_HIDDEN_KEY = 'banyaro_reise_hidden'; // {itemKey: true} — gelöschte Standard-Items
let _editMode = false;
function _loadCustom() { try { return JSON.parse(localStorage.getItem(LS_CUSTOM_KEY) || '{}'); } catch { return {}; } }
function _saveCustom(d) { try { localStorage.setItem(LS_CUSTOM_KEY, JSON.stringify(d)); } catch {} }
function _loadHidden() { try { return JSON.parse(localStorage.getItem(LS_HIDDEN_KEY) || '{}'); } catch { return {}; } }
function _saveHidden(d) { try { localStorage.setItem(LS_HIDDEN_KEY, JSON.stringify(d)); } catch {} }
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _loadChecked() {
try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); }
catch { return {}; }
}
function _saveChecked(state) {
try { localStorage.setItem(LS_KEY, JSON.stringify(state)); }
catch {}
}
function _itemKey(catKey, idx) { return `${catKey}__${idx}`; }
// ------------------------------------------------------------------
// LIFECYCLE
// ------------------------------------------------------------------
function init(container, appState, params = {}) {
_container = container;
_appState = appState;
if (params?.tab && TABS.some(t => t.key === params.tab)) {
_activeTab = params.tab;
}
_render();
}
function refresh() { _renderTabContent(); }
function onDogChange() {}
// ------------------------------------------------------------------
// RENDER
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="by-tabs" id="reise-tabs"></div>
<div id="reise-content"></div>
`;
_renderTabBar();
_renderTabContent();
}
function _renderTabBar() {
const el = _container.querySelector('#reise-tabs');
if (!el) return;
el.innerHTML = TABS.map(t => `
<button class="by-tab${t.key === _activeTab ? ' active' : ''}" data-tab="${t.key}">
${t.icon} ${t.label}
</button>`).join('');
el.querySelectorAll('.by-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
el.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_renderTabContent();
});
});
}
function _renderTabContent() {
const el = _container.querySelector('#reise-content');
if (!el) return;
if (_activeTab === 'checkliste') _renderCheckliste(el);
else if (_activeTab === 'laender') _renderLaender(el);
else if (_activeTab === 'notfall') _renderNotfall(el);
}
// ------------------------------------------------------------------
// TAB 1: CHECKLISTE
// ------------------------------------------------------------------
function _renderCheckliste(el) {
const checked = _loadChecked();
const custom = _loadCustom();
const hidden = _loadHidden();
// Alle sichtbaren Items zählen
let totalItems = 0, doneItems = 0;
CHECKLIST.forEach(cat => {
cat.items.forEach((_, idx) => {
if (!hidden[_itemKey(cat.key, idx)]) {
totalItems++;
if (checked[_itemKey(cat.key, idx)]) doneItems++;
}
});
(custom[cat.key] || []).forEach((_, i) => {
totalItems++;
if (checked[`${cat.key}__custom__${i}`]) doneItems++;
});
});
const pct = totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
const cats = CHECKLIST.map(cat => {
const customItems = custom[cat.key] || [];
const stdRows = cat.items.map((item, idx) => {
if (hidden[_itemKey(cat.key, idx)]) return '';
const key = _itemKey(cat.key, idx);
const done = !!checked[key];
if (_editMode) {
return `<div class="reise-check-row" style="justify-content:space-between">
<span style="flex:1;color:var(--c-text)">${_esc(item)}</span>
<button class="reise-del-btn" data-hide="${_esc(key)}"
style="background:none;border:none;color:#EF4444;cursor:pointer;padding:4px;flex-shrink:0">
<svg class="ph-icon" style="width:1rem;height:1rem"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`;
}
return `<label class="reise-check-row${done ? ' done' : ''}">
<input type="checkbox" class="reise-cb" data-key="${_esc(key)}" ${done ? 'checked' : ''}>
<span>${_esc(item)}</span>
</label>`;
}).join('');
const customRows = customItems.map((item, i) => {
const key = `${cat.key}__custom__${i}`;
const done = !!checked[key];
if (_editMode) {
return `<div class="reise-check-row" style="justify-content:space-between">
<span style="flex:1;color:var(--c-primary)">${_esc(item)}</span>
<button class="reise-del-custom-btn" data-cat="${_esc(cat.key)}" data-idx="${i}"
style="background:none;border:none;color:#EF4444;cursor:pointer;padding:4px;flex-shrink:0">
<svg class="ph-icon" style="width:1rem;height:1rem"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`;
}
return `<label class="reise-check-row${done ? ' done' : ''}">
<input type="checkbox" class="reise-cb" data-key="${_esc(key)}" ${done ? 'checked' : ''}>
<span style="color:var(--c-primary)">${_esc(item)}</span>
</label>`;
}).join('');
const addRow = _editMode ? `
<div style="padding:var(--space-2) 0;border-top:1px dashed var(--c-border);margin-top:4px">
<div style="display:flex;gap:8px;align-items:center">
<input class="reise-add-input" data-cat="${_esc(cat.key)}"
style="flex:1;padding:8px 10px;border-radius:8px;border:1px solid var(--c-border);
background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm)"
placeholder="Eigenes Item hinzufügen…">
<button class="reise-add-btn btn btn-primary" data-cat="${_esc(cat.key)}"
style="padding:8px 12px;flex-shrink:0;font-size:var(--text-sm)">
<svg class="ph-icon" style="width:1rem;height:1rem"><use href="/icons/phosphor.svg#plus"></use></svg>
</button>
</div>
</div>` : '';
return `<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(cat.icon)}"></use>
</svg>
<span style="font-weight:var(--weight-semibold)">${_esc(cat.label)}</span>
</div>
<div style="padding:var(--space-2) var(--space-4)">
${stdRows}${customRows}${addRow}
</div>
</div>`;
}).join('');
el.innerHTML = `
<style>
.reise-check-row {
display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-2) 0;cursor:pointer;
border-bottom:1px solid var(--c-surface-2);
font-size:var(--text-sm);color:var(--c-text);line-height:1.5;
}
.reise-check-row:last-child { border-bottom:none; }
.reise-check-row.done span { text-decoration:line-through;color:var(--c-text-secondary); }
.reise-check-row input[type=checkbox] {
flex-shrink:0;margin-top:2px;width:18px;height:18px;
accent-color:var(--c-primary);cursor:pointer;
}
</style>
<!-- Fortschritt + Buttons -->
<div style="margin-bottom:var(--space-5)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-2)">
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${doneItems} von ${totalItems} erledigt</span>
<div style="display:flex;gap:8px;align-items:center">
<span style="font-size:var(--text-sm);font-weight:700;color:var(--c-primary)">${pct}%</span>
<button id="reise-edit-toggle" style="background:${_editMode ? 'var(--c-primary)' : 'var(--c-bg-card)'};
color:${_editMode ? '#fff' : 'var(--c-text-secondary)'};border:1.5px solid var(--c-border);
border-radius:8px;padding:5px 10px;cursor:pointer;font-size:var(--text-xs);font-weight:600;
display:flex;align-items:center;gap:4px">
<svg class="ph-icon" style="width:.9rem;height:.9rem"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
${_editMode ? 'Fertig' : 'Bearbeiten'}
</button>
</div>
</div>
<div style="height:8px;background:var(--c-surface-2);border-radius:var(--radius-full);overflow:hidden">
<div style="height:100%;width:${pct}%;background:var(--c-primary);border-radius:var(--radius-full);transition:width .3s"></div>
</div>
</div>
${cats}
${!_editMode ? `<div style="text-align:center;margin-bottom:var(--space-6)">
<button class="btn btn-secondary" id="reise-reset-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-counter-clockwise"></use></svg>
Abhaken zurücksetzen
</button>
</div>` : ''}
`;
// Checkbox events
el.querySelectorAll('.reise-cb').forEach(cb => {
cb.addEventListener('change', () => {
const key = cb.dataset.key;
const cur = _loadChecked();
cur[key] = cb.checked;
_saveChecked(cur);
_renderTabContent();
});
});
// Edit-Toggle
el.querySelector('#reise-edit-toggle')?.addEventListener('click', () => {
_editMode = !_editMode;
_renderTabContent();
});
// Standard-Item löschen (verstecken)
el.querySelectorAll('.reise-del-btn').forEach(btn => {
btn.addEventListener('click', () => {
const h = _loadHidden();
h[btn.dataset.hide] = true;
_saveHidden(h);
_renderTabContent();
});
});
// Custom-Item löschen
el.querySelectorAll('.reise-del-custom-btn').forEach(btn => {
btn.addEventListener('click', () => {
const c = _loadCustom();
c[btn.dataset.cat] = (c[btn.dataset.cat] || []).filter((_, i) => i !== parseInt(btn.dataset.idx));
_saveCustom(c);
_renderTabContent();
});
});
// Custom-Item hinzufügen
el.querySelectorAll('.reise-add-btn').forEach(btn => {
btn.addEventListener('click', () => {
const cat = btn.dataset.cat;
const input = el.querySelector(`.reise-add-input[data-cat="${cat}"]`);
const val = (input?.value || '').trim();
if (!val) return;
const c = _loadCustom();
if (!c[cat]) c[cat] = [];
c[cat].push(val);
_saveCustom(c);
_renderTabContent();
});
});
// Enter in Add-Input
el.querySelectorAll('.reise-add-input').forEach(input => {
input.addEventListener('keydown', e => {
if (e.key === 'Enter') el.querySelector(`.reise-add-btn[data-cat="${input.dataset.cat}"]`)?.click();
});
});
el.querySelector('#reise-reset-btn')?.addEventListener('click', () => {
_saveChecked({});
_renderTabContent();
});
}
// ------------------------------------------------------------------
// TAB 2: EU-LÄNDER
// ------------------------------------------------------------------
function _renderLaender(el) {
const cards = LAENDER.map(l => `
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<span style="font-size:2rem;line-height:1">${l.flag}</span>
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-1)">
${_esc(l.name)}
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${_esc(l.regel)}
</div>
</div>
${l.warn ? `<svg class="ph-icon" style="color:var(--c-warning,#f59e0b);flex-shrink:0;width:20px;height:20px" aria-hidden="true">
<use href="/icons/phosphor.svg#warning"></use>
</svg>` : ''}
</div>
</div>`).join('');
el.innerHTML = `
<div style="margin-bottom:var(--space-4);padding:var(--space-3) var(--space-4);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-sm);color:var(--c-text-secondary);
display:flex;gap:var(--space-2);align-items:flex-start">
<svg class="ph-icon" style="flex-shrink:0;margin-top:1px" aria-hidden="true">
<use href="/icons/phosphor.svg#info"></use>
</svg>
<span>EU-Heimtierausweis + Mikrochip + gültige Tollwut-Impfung (mindestens 21 Tage alt)
sind Pflicht für alle EU-Reisen. Informationen können sich ändern immer beim
Zielland-Konsulat oder Tierarzt aktuell prüfen.</span>
</div>
${cards}
`;
}
// ------------------------------------------------------------------
// TAB 3: NOTFÄLLE
// ------------------------------------------------------------------
function _renderNotfall(el) {
const massnahmen = SOFORTMASSNAHMEN.map(m => `
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-3) 0;border-bottom:1px solid var(--c-surface-2)">
<svg class="ph-icon" style="color:var(--c-danger,#ef4444);flex-shrink:0;margin-top:1px" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(m.icon)}"></use>
</svg>
<span style="font-size:var(--text-sm);color:var(--c-text)">${_esc(m.text)}</span>
</div>`).join('');
el.innerHTML = `
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--c-border);
font-weight:var(--weight-semibold);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="color:var(--c-danger,#ef4444)" aria-hidden="true">
<use href="/icons/phosphor.svg#phone"></use>
</svg>
Notrufnummern
</div>
<div style="padding:var(--space-4)">
<a href="tel:112" class="btn btn-danger w-full"
style="margin-bottom:var(--space-3);display:flex;align-items:center;
justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone-call"></use></svg>
112 EU-Notruf
</a>
<a href="tel:+498919240" class="btn btn-secondary w-full"
style="margin-bottom:var(--space-2);display:flex;align-items:center;
justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
Tiergift-Notruf München
</a>
<div style="text-align:center;font-size:var(--text-xs);color:var(--c-text-secondary)">
+49 89 19240 (Tierärztliche Hochschule)
</div>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--c-border);
font-weight:var(--weight-semibold)">Tierarzt finden</div>
<div style="padding:var(--space-4)">
<button class="btn btn-primary w-full" id="reise-map-btn"
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
Tierarzt in der Nähe suchen
</button>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--c-border);
font-weight:var(--weight-semibold);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="color:var(--c-warning,#f59e0b)" aria-hidden="true">
<use href="/icons/phosphor.svg#warning"></use>
</svg>
Sofortmaßnahmen
</div>
<div style="padding:var(--space-2) var(--space-4)">
${massnahmen}
</div>
</div>
`;
el.querySelector('#reise-map-btn')?.addEventListener('click', () => {
App.navigate('map');
});
}
// ------------------------------------------------------------------
// PUBLIC
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();

View file

@ -270,6 +270,12 @@ window.Page_settings = (() => {
<span>Welten einrichten</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-hilfe-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#question"></use></svg>
<span>Hilfe &amp; FAQ</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)">
@ -766,6 +772,10 @@ window.Page_settings = (() => {
else if (window.Worlds) window.Worlds.openConfig?.();
});
document.getElementById('settings-hilfe-btn')?.addEventListener('click', () => {
App.navigate('hilfe');
});
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title : 'Abmelden?',
@ -1424,15 +1434,15 @@ window.Page_settings = (() => {
_mode = mode;
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
<div style="max-width:380px;width:100%;margin:0 auto;padding:var(--space-6) 0;box-sizing:border-box">
<!-- Logo -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);
margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">Ban Yaro</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0">
display:block;margin:0 auto var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0;text-align:center">Ban Yaro</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0;text-align:center">
Alles rund um deinen Hund
</p>
</div>
@ -1672,9 +1682,9 @@ window.Page_settings = (() => {
_offerPushNotifications();
}
// Nach Login: Direkt in HUND-Welt oder Profil anlegen
// Nach Login: Welten initialisieren (mit User-State) oder Profil anlegen
if (_appState.activeDog) {
window.Worlds?.show(1);
window.Worlds?.init(_appState);
} else {
App.navigate('dog-profile');
}

View file

@ -9,9 +9,12 @@ window.Page_walks = (() => {
let _appState = null;
let _data = [];
let _view = 'liste'; // 'liste' | 'karte'
let _tab = 'treffen'; // 'treffen' | 'challenge' | 'stamm'
let _map = null;
let _markers = [];
let _userPos = null;
let _challengeData = null;
let _gassiZeiten = [];
// _esc ersetzt durch UI.escape()
@ -56,9 +59,17 @@ window.Page_walks = (() => {
_loadData();
}
function refresh() { _loadData(); }
function refresh() {
_loadData();
if (_tab === 'challenge') _loadChallenge();
if (_tab === 'stamm') _loadGassiZeiten();
}
function onDogChange() {}
function openNew() { _showCreateForm(); }
function openNew() {
if (_tab === 'challenge') { _showSubmitForm(); return; }
if (_tab === 'stamm') { _showGassiZeitForm(); return; }
_showCreateForm();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur
@ -67,30 +78,63 @@ window.Page_walks = (() => {
_container.innerHTML = `
<div class="walks-layout">
<!-- Toolbar -->
<div class="by-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} Treffen planen</button>
<!-- Tab-Bar -->
<div class="by-tabs" id="walks-tab-bar" style="padding:var(--space-3) var(--space-4) 0">
<button class="by-tab active" data-tab="treffen">${UI.icon('paw-print')} Treffen</button>
<button class="by-tab" data-tab="challenge">${UI.icon('camera')} Challenge</button>
<button class="by-tab" data-tab="stamm">${UI.icon('clock')} Stamm-Gassis</button>
</div>
<!-- Liste -->
<div id="walks-list-view" class="walks-content">
<div id="walks-list">
<!-- Tab: Treffen -->
<div id="walks-tab-treffen" class="walks-tab-panel">
<!-- Toolbar -->
<div class="by-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} Treffen planen</button>
</div>
<!-- Liste -->
<div id="walks-list-view" class="walks-content">
<div id="walks-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
<!-- Karte -->
<div id="walks-map-view" class="walks-content" style="display:none">
<div id="walks-map" class="walks-map"></div>
</div>
</div>
<!-- Tab: Challenge -->
<div id="walks-tab-challenge" class="walks-tab-panel" style="display:none">
<div id="challenge-content">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
<!-- Karte -->
<div id="walks-map-view" class="walks-content" style="display:none">
<div id="walks-map" class="walks-map"></div>
<!-- Tab: Stamm-Gassis -->
<div id="walks-tab-stamm" class="walks-tab-panel" style="display:none">
<div class="by-toolbar">
<span style="font-weight:600;color:var(--c-text)">${UI.icon('clock')} Stamm-Gassi-Zeiten</span>
<button class="btn btn-primary btn-sm" id="gassi-zeit-add-btn">${UI.icon('plus')} Meine Zeit eintragen</button>
</div>
<div id="gassi-zeiten-content">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
</div>
`;
// Tab-Bar Events
document.getElementById('walks-tab-bar').addEventListener('click', e => {
const btn = e.target.closest('.by-tab');
if (!btn) return;
_switchTab(btn.dataset.tab);
});
document.getElementById('walks-view-toggle').addEventListener('click', e => {
const btn = e.target.closest('.walks-view-btn');
if (!btn) return;
@ -105,6 +149,23 @@ window.Page_walks = (() => {
}
_showCreateForm();
});
document.getElementById('gassi-zeit-add-btn').addEventListener('click', () => {
if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden.'); App.navigate('settings'); return; }
_showGassiZeitForm();
});
}
function _switchTab(tab) {
_tab = tab;
document.querySelectorAll('.by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === tab));
document.querySelectorAll('.walks-tab-panel').forEach(p => p.style.display = 'none');
const panel = document.getElementById(`walks-tab-${tab}`);
if (panel) panel.style.display = '';
if (tab === 'challenge' && !_challengeData) _loadChallenge();
if (tab === 'stamm' && !_gassiZeiten.length) _loadGassiZeiten();
}
function _switchView(view) {
@ -1038,6 +1099,375 @@ window.Page_walks = (() => {
});
}
// ==============================================================
// FEATURE 1: Foto-Challenge der Woche
// ==============================================================
async function _loadChallenge() {
const el = document.getElementById('challenge-content');
if (!el) return;
try {
_challengeData = await API.get('challenges/current');
_renderChallenge();
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Konnte Challenge nicht laden.</p>`;
}
}
function _renderChallenge() {
const el = document.getElementById('challenge-content');
if (!el || !_challengeData) return;
const { challenge, submissions, my_submission_id, days_left } = _challengeData;
const canSubmit = _appState.user && !my_submission_id;
const dayLabel = days_left === 1 ? '1 Tag' : `${days_left} Tage`;
el.innerHTML = `
<div class="challenge-banner">
<div class="challenge-banner-inner">
<div class="challenge-thema">${UI.escape(challenge.thema)}</div>
<div class="challenge-meta">
${UI.icon('calendar')} ${_fmtDate(challenge.start_date)} ${_fmtDate(challenge.end_date)}
&nbsp;·&nbsp; ${UI.icon('timer')} Noch ${dayLabel}
</div>
${canSubmit ? `<button class="btn btn-primary btn-sm" id="challenge-submit-btn" style="margin-top:var(--space-3)">${UI.icon('camera')} Foto einreichen</button>` : ''}
${my_submission_id ? `<span class="badge badge-success" style="margin-top:var(--space-2)">${UI.icon('check')} Du hast bereits teilgenommen</span>` : ''}
</div>
</div>
<div class="challenge-winners" id="challenge-winners-section">
<h4 style="padding:var(--space-3) var(--space-4);margin:0;color:var(--c-text-secondary);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:.05em">Letzte Gewinner</h4>
<div id="challenge-winners-list"><p style="color:var(--c-text-secondary);padding:0 var(--space-4);font-size:var(--text-sm)">Lädt</p></div>
</div>
<div style="padding:var(--space-4) var(--space-4) var(--space-2)">
<h4 style="margin:0;font-size:var(--text-sm);font-weight:600;color:var(--c-text)">
${UI.icon('images')} Einreichungen dieser Woche (${submissions.length})
</h4>
</div>
<div class="challenge-gallery" id="challenge-gallery">
${submissions.length === 0
? `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8);grid-column:1/-1">Noch keine Fotos — sei der Erste! 📸</p>`
: submissions.map(s => _challengeSubmissionCard(s)).join('')}
</div>
`;
// Submit-Button
const submitBtn = document.getElementById('challenge-submit-btn');
if (submitBtn) submitBtn.addEventListener('click', _showSubmitForm);
// Vote-Buttons
el.querySelectorAll('.challenge-vote-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
const subId = parseInt(btn.dataset.id);
try {
const res = await API.post(`challenges/submissions/${subId}/vote`, {});
btn.querySelector('.vote-count').textContent = res.votes;
btn.classList.toggle('voted', res.voted);
} catch (e) { UI.toast.error(e.message || 'Fehler beim Abstimmen.'); }
});
});
// Gewinner laden
_loadChallengeWinners();
}
function _challengeSubmissionCard(s) {
const voted = s.i_voted;
return `
<div class="challenge-sub-card">
<img src="${UI.escape(s.foto_url)}" alt="Challenge-Foto" loading="lazy"
onerror="this.src='/icons/icon-192.png'"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(s.foto_url)}' }], 0)">
<div class="challenge-sub-info">
<div class="challenge-sub-user">${UI.icon('user')} ${UI.escape(s.user_name || 'Anonym')}
${s.dog_name ? ` · 🐕 ${UI.escape(s.dog_name)}` : ''}</div>
${s.caption ? `<div class="challenge-sub-caption">${UI.escape(s.caption)}</div>` : ''}
<button class="challenge-vote-btn ${voted ? 'voted' : ''}" data-id="${s.id}">
${UI.icon(voted ? 'heart-fill' : 'heart')} <span class="vote-count">${s.votes}</span>
</button>
</div>
</div>
`;
}
async function _loadChallengeWinners() {
const el = document.getElementById('challenge-winners-list');
if (!el) return;
try {
const winners = await API.get('challenges/winners');
if (!winners.length) { el.innerHTML = '<p style="color:var(--c-text-secondary);padding:0 var(--space-4);font-size:var(--text-sm)">Noch keine vergangenen Challenges.</p>'; return; }
el.innerHTML = `<div class="challenge-winners-row">` +
winners.map(w => {
if (!w.winner) return `<div class="challenge-winner-chip"><span>${UI.escape(w.challenge.thema)}</span><small>Kein Gewinner</small></div>`;
return `<div class="challenge-winner-chip">
<img src="${UI.escape(w.winner.foto_url)}" alt="Gewinner" onerror="this.src='/icons/icon-192.png'">
<div>
<div style="font-weight:600;font-size:var(--text-xs)">${UI.escape(w.challenge.thema)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(w.winner.user_name)} · ${w.winner.votes} </div>
</div>
</div>`;
}).join('') +
`</div>`;
} catch {}
}
async function _showSubmitForm() {
if (!_challengeData) return;
const dogs = _appState.dogs || [];
const dogOptions = dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}</option>`).join('');
UI.modal.open({
title: `📸 ${UI.escape(_challengeData.challenge.thema)}`,
body: `
<form id="challenge-submit-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="form-group">
<label>Foto *</label>
<input type="file" id="challenge-foto-input" accept="image/*" required style="width:100%">
</div>
${dogs.length ? `<div class="form-group">
<label>Hund</label>
<select id="challenge-dog-select" style="width:100%">
<option value="">Kein Hund</option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label>Bildunterschrift</label>
<input type="text" id="challenge-caption" placeholder="z.B. Mein Bello beim besten Schnüffeln…" maxlength="200" style="width:100%">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="challenge-submit-ok">Einreichen</button>
`,
});
document.getElementById('challenge-submit-ok').addEventListener('click', async () => {
const fotoInput = document.getElementById('challenge-foto-input');
if (!fotoInput.files.length) { UI.toast.warning('Bitte ein Foto auswählen.'); return; }
const caption = document.getElementById('challenge-caption')?.value?.trim() || '';
const dogSelect = document.getElementById('challenge-dog-select');
const dogId = dogSelect ? (parseInt(dogSelect.value) || '') : '';
const fd = new FormData();
fd.append('foto', fotoInput.files[0]);
if (caption) fd.append('caption', caption);
if (dogId) fd.append('dog_id', dogId);
try {
await API.upload(`challenges/${_challengeData.challenge.id}/submit`, fd);
UI.toast.success('Foto eingereicht! Viel Erfolg 🎉');
UI.modal.close();
_challengeData = null;
_loadChallenge();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Einreichen.'); }
});
}
// ==============================================================
// FEATURE 2: Gassi-Zeiten-Pool (Stamm-Gassis)
// ==============================================================
const _WOCHENTAGE = [
{ key: 'mo', label: 'Mo' }, { key: 'di', label: 'Di' }, { key: 'mi', label: 'Mi' },
{ key: 'do', label: 'Do' }, { key: 'fr', label: 'Fr' }, { key: 'sa', label: 'Sa' },
{ key: 'so', label: 'So' },
];
async function _loadGassiZeiten() {
const el = document.getElementById('gassi-zeiten-content');
if (!el) return;
try {
const params = _userPos ? `?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=10000` : '';
_gassiZeiten = await API.get(`gassi-zeiten${params}`);
_renderGassiZeiten();
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Konnte Gassi-Zeiten nicht laden.</p>`;
}
}
function _renderGassiZeiten() {
const el = document.getElementById('gassi-zeiten-content');
if (!el) return;
if (!_gassiZeiten.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-secondary)">
${UI.icon('clock')}
<p>Noch keine Stamm-Gassi-Zeiten in deiner Nähe.</p>
<p style="font-size:var(--text-sm)">Trag deine regelmäßigen Zeiten ein andere finden dich dann!</p>
</div>`;
return;
}
const myZeiten = _gassiZeiten.filter(z => z.is_mine);
const andereZeiten = _gassiZeiten.filter(z => !z.is_mine);
let html = '';
if (myZeiten.length) {
html += `<div style="padding:var(--space-3) var(--space-4) var(--space-1);font-weight:600;font-size:var(--text-xs);color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em">Meine Zeiten</div>`;
html += myZeiten.map(z => _gassiZeitCard(z)).join('');
}
if (andereZeiten.length) {
html += `<div style="padding:var(--space-3) var(--space-4) var(--space-1);font-weight:600;font-size:var(--text-xs);color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em">In deiner Nähe</div>`;
html += andereZeiten.map(z => _gassiZeitCard(z)).join('');
}
el.innerHTML = html;
// Events
el.querySelectorAll('.gz-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Gassi-Zeit löschen?')) return;
try {
await API.del(`gassi-zeiten/${btn.dataset.id}`);
_gassiZeiten = _gassiZeiten.filter(z => z.id !== parseInt(btn.dataset.id));
_renderGassiZeiten();
UI.toast.success('Gelöscht.');
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
el.querySelectorAll('.gz-toggle-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const gz = _gassiZeiten.find(z => z.id === parseInt(btn.dataset.id));
if (!gz) return;
try {
const updated = await API.patch(`gassi-zeiten/${gz.id}`, { aktiv: gz.aktiv ? 0 : 1 });
const idx = _gassiZeiten.findIndex(z => z.id === gz.id);
if (idx !== -1) _gassiZeiten[idx] = { ..._gassiZeiten[idx], aktiv: updated.aktiv };
_renderGassiZeiten();
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
el.querySelectorAll('.gz-chat-btn').forEach(btn => {
btn.addEventListener('click', () => {
const userId = parseInt(btn.dataset.userId);
App.navigate('chat', { user_id: userId });
});
});
}
function _gassiZeitCard(z) {
const wochentageLabel = (z.wochentage || []).join(', ').toUpperCase();
const distLabel = z.distance_m != null
? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${z.distance_m < 1000 ? z.distance_m + ' m' : (z.distance_m/1000).toFixed(1) + ' km'}</span>`
: '';
const pausedStyle = z.aktiv ? '' : 'opacity:.5';
return `
<div class="gassi-zeit-card" style="${pausedStyle}">
<div class="gz-avatar">
${z.dog_foto_url
? `<img src="${UI.escape(z.dog_foto_url)}" alt="${UI.escape(z.dog_name || '')}">`
: `<div class="gz-avatar-placeholder">${UI.icon('paw-print')}</div>`}
</div>
<div class="gz-body">
<div class="gz-name">${UI.escape(z.dog_name || z.user_name || '?')}
${z.dog_rasse ? `<span class="badge" style="font-size:var(--text-xs)">${UI.escape(z.dog_rasse)}</span>` : ''}
${!z.aktiv ? `<span class="badge badge-warning">Pausiert</span>` : ''}
</div>
<div class="gz-meta">
${UI.icon('clock')} ${UI.escape(z.uhrzeit)}
&nbsp;·&nbsp; ${wochentageLabel}
${z.ort_name ? `&nbsp;·&nbsp; ${UI.icon('map-pin')} ${UI.escape(z.ort_name)}` : ''}
${distLabel}
</div>
${z.notiz ? `<div class="gz-notiz">${UI.escape(z.notiz)}</div>` : ''}
</div>
<div class="gz-actions">
${z.is_mine ? `
<button class="btn btn-outline btn-xs gz-toggle-btn" data-id="${z.id}" title="${z.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(z.aktiv ? 'pause' : 'play')}
</button>
<button class="btn btn-outline btn-xs gz-delete-btn" data-id="${z.id}" title="Löschen">
${UI.icon('trash')}
</button>
` : `
<button class="btn btn-primary btn-xs gz-chat-btn" data-user-id="${z.user_id}" title="Chat öffnen">
${UI.icon('chat-circle')} Mitmachen
</button>
`}
</div>
</div>
`;
}
async function _showGassiZeitForm() {
const dogs = _appState.dogs || [];
const dogOptions = dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}</option>`).join('');
const wdBtns = _WOCHENTAGE.map(w =>
`<label class="wd-btn"><input type="checkbox" value="${w.key}"> ${w.label}</label>`
).join('');
UI.modal.open({
title: `${UI.icon('clock')} Stamm-Gassi-Zeit eintragen`,
body: `
<form id="gassi-zeit-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
${dogs.length ? `<div class="form-group">
<label>Hund</label>
<select id="gz-dog-select" style="width:100%">
<option value="">Kein Hund</option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label>Uhrzeit *</label>
<input type="time" id="gz-uhrzeit" required style="width:100%">
</div>
<div class="form-group">
<label>Wochentage *</label>
<div class="wd-selector">${wdBtns}</div>
</div>
<div class="form-group">
<label>Ort (optional)</label>
<input type="text" id="gz-ort-name" placeholder="z.B. Stadtpark Ebersberg" style="width:100%">
</div>
<div class="form-group">
<label>Notiz (optional)</label>
<input type="text" id="gz-notiz" placeholder="z.B. Wir sind eine ruhige Gruppe…" maxlength="200" style="width:100%">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="gz-save-btn">Speichern</button>
`,
});
document.getElementById('gz-save-btn').addEventListener('click', async () => {
const uhrzeit = document.getElementById('gz-uhrzeit')?.value;
if (!uhrzeit) { UI.toast.warning('Bitte Uhrzeit angeben.'); return; }
const wochentage = Array.from(document.querySelectorAll('.wd-btn input:checked')).map(cb => cb.value);
if (!wochentage.length) { UI.toast.warning('Bitte mindestens einen Wochentag wählen.'); return; }
const dogId = parseInt(document.getElementById('gz-dog-select')?.value) || null;
const ortName = document.getElementById('gz-ort-name')?.value?.trim() || null;
const notiz = document.getElementById('gz-notiz')?.value?.trim() || null;
const payload = { wochentage, uhrzeit, ort_name: ortName, notiz };
if (dogId) payload.dog_id = dogId;
if (_userPos) { payload.lat = _userPos.lat; payload.lon = _userPos.lon; }
try {
const created = await API.post('gassi-zeiten', payload);
_gassiZeiten.unshift({ ...created });
_renderGassiZeiten();
UI.toast.success('Gassi-Zeit eingetragen! 🐾');
UI.modal.close();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); }
});
}
return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
})();

View file

@ -497,9 +497,16 @@ window.Page_welcome = (() => {
API.dogs.welcomeDashboard(dog.id).then(dash => {
_updateHeroFromDash(dash, dog);
_updateChipsFromDash(dash);
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
_tryRouteChip(dash);
}).catch(() => { /* Skeleton bleibt sichtbar */ });
// Hero-Foto stündlich auffrischen (Tageswechsel um Mitternacht sichtbar ohne Reload)
setInterval(() => {
API.dogs.welcomeDashboard(dog.id)
.then(dash => _updateHeroFromDash(dash, dog))
.catch(() => {});
}, 60 * 60 * 1000);
// Streak-Widget asynchron laden
_loadStreakWidget(dog.id);
}

View file

@ -73,23 +73,17 @@ window.Page_wetter = (() => {
_tryAutoLocate();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_selDay = 0;
_selDay = 0;
_recordsLoaded = false;
_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="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>
@ -97,37 +91,126 @@ window.Page_wetter = (() => {
`;
}
// ----------------------------------------------------------
// 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();
} catch (err) {
_showLocationError(err?.code);
}
}
function _showLocationError() {
const body = _container.querySelector('#wttr-body');
function _showLocationError(errCode) {
const body = _container?.querySelector('#wttr-body');
if (!body) return;
const isLoggedIn = !!_appState?.user;
const isDenied = errCode === 1; // GeolocationPositionError.PERMISSION_DENIED
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const deniedHint = isDenied ? `
<div style="background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.4);
border-radius:var(--radius-lg);padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:#F59E0B;flex-shrink:0">
<use href="/icons/phosphor.svg#warning"></use>
</svg>
<div style="font-weight:700;font-size:var(--text-sm)">Standort-Zugriff blockiert</div>
</div>
${isIos ? `
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-3)">
<b>Wichtig:</b> Die App läuft getrennt von Safari Safari-Einstellungen gelten hier nicht.
</div>
<ol style="margin:0;padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-1);
color:var(--c-text-secondary);font-size:var(--text-sm)">
<li>Öffne <b>Einstellungen Datenschutz &amp; Sicherheit Ortungsdienste</b></li>
<li>Scrolle ganz nach unten zu <b>Ban Yaro</b> (nicht Safari!)</li>
<li>Wähle <b>Beim Verwenden der App"</b></li>
<li>Komm zurück und tippe nochmal auf den Button</li>
</ol>
<div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">
<b>Letzter Ausweg:</b> Einstellungen Apps Safari Erweitert Website-Daten banyaro.app löschen. Danach nochmal öffnen und Button tippen.
</div>` : `
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">
Klicke auf das Schloss-Symbol in der Adressleiste <b>Standort</b> <b>Erlauben</b>, dann nochmal tippen.
</div>`}
</div>` : '';
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 style="max-width:420px;margin:0 auto;padding:var(--space-6) var(--space-4)">
${deniedHint}
<!-- Hero -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:4rem;line-height:1;margin-bottom:var(--space-2)">🌤🐾</div>
<h2 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-2)">
Das Gassi-Wetter wartet auf dich
</h2>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
Erfahre sekundengenau, ob gerade der perfekte Moment für eine Runde ist
zugeschnitten auf dich und deinen Hund.
</p>
</div>
<!-- Feature-Liste -->
<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-6)">
${[
['sun', '#F59E0B', 'Gassi-Score 110', 'Wetter bewertet nach Temperatur, Regen und Wind'],
['thermometer', '#3B82F6', '7-Tage-Vorschau', 'Plane deine Runden für die ganze Woche voraus'],
['drop', '#06B6D4', 'Regenradar stündlich', '24h-Niederschlagstimeline auf einen Blick'],
['trophy', '#10B981', 'Wetter-Rekorde', 'Wärmster, nassester und stürmischster Gassi-Tag'],
].map(([icon, color, title, sub]) => `
<div style="display:flex;align-items:center;gap:var(--space-3);
background:var(--c-bg-card);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4)">
<div style="width:38px;height:38px;border-radius:var(--radius-md);
background:${color}18;display:flex;align-items:center;
justify-content:center;flex-shrink:0">
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:${color}">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
</div>
<div>
<div style="font-weight:700;font-size:var(--text-sm)">${title}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-xs)">${sub}</div>
</div>
</div>
`).join('')}
</div>
<!-- CTAs -->
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<button class="btn btn-primary" id="wttr-btn-retry"
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:1rem;height:1rem">
<use href="/icons/phosphor.svg#map-pin"></use>
</svg>
Standort freigeben &amp; loslegen
</button>
${!isLoggedIn ? `
<button class="btn btn-secondary" id="wttr-btn-login"
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:1rem;height:1rem">
<use href="/icons/phosphor.svg#user"></use>
</svg>
Kostenlos registrieren
</button>
<p style="text-align:center;font-size:var(--text-xs);color:var(--c-text-secondary);margin:0">
Mit Account werden Rekorde &amp; Gassi-Score für deinen Hund gespeichert.
</p>
` : ''}
</div>
</div>
`;
body.querySelector('#wttr-btn-retry')?.addEventListener('click', () => {
_renderShell();
_tryAutoLocate();
});
body.querySelector('#wttr-btn-login')?.addEventListener('click', () => {
if (window.App) App.navigate('settings');
});
}
// ----------------------------------------------------------
@ -1007,19 +1090,21 @@ window.Page_wetter = (() => {
function _recordCard(emoji, title, value, subtitle, color) {
return `
<div style="background:var(--c-bg-card);border:1px solid var(--c-border);
border-radius:var(--radius);padding:var(--space-3) var(--space-3);
display:flex;flex-direction:column;gap:2px">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
display:flex;align-items:center;gap:4px;font-weight:600">
<div style="background:${color}10;border:1px solid ${color}33;
border-radius:var(--radius);padding:var(--space-3);
display:flex;flex-direction:column;gap:3px">
<div style="font-size:10px;color:var(--c-text-secondary);
display:flex;align-items:center;gap:3px;font-weight:700;
text-transform:uppercase;letter-spacing:.04em">
<span>${emoji}</span>
<span>${_esc(title)}</span>
</div>
<div style="font-size:var(--text-xl);font-weight:800;color:${color};line-height:1.1">
<div style="font-size:var(--text-lg);font-weight:800;color:${color};line-height:1.1">
${_esc(value)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
<div style="font-size:10px;color:var(--c-text-secondary);
overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.3">
${_esc(subtitle)}
</div>
</div>

View file

@ -13,6 +13,8 @@ window.Worlds = (() => {
let _lastUserId = undefined;
let _dogs = []; // gecachte Hundesliste
let _dogIdx = 0; // aktuell angezeigter Hund
let _hasBgPhoto = false; // Hintergrund-Foto vorhanden?
let _setupDone = false; // Swipe/Button-Listener nur einmal registrieren
// Touch-Tracking
const _t = { x:0, y:0, active:false, vert:null, moved:0 };
@ -43,14 +45,67 @@ window.Worlds = (() => {
// ── PUBLIC ──────────────────────────────────────────────────
async function init(appState) {
_state = appState;
_cur = 1; // immer HUND als Start
_setupSwipe();
_setupButtons();
_state = appState;
_lastUserId = undefined; // Neurender erzwingen
_cur = 1;
if (!_setupDone) {
_setupDone = true;
_setupSwipe();
_setupButtons();
_showSwipeHints();
}
_goTo(_cur, false);
show();
}
function _showSwipeHints() {
if (localStorage.getItem('worlds_swipe_seen')) return;
localStorage.setItem('worlds_swipe_seen', '1');
const ov = document.getElementById('worlds-overlay');
if (!ov) return;
const hint = document.createElement('div');
hint.style.cssText = [
'position:absolute;inset:0;pointer-events:none;z-index:55',
'display:flex;align-items:center;justify-content:space-between',
'padding:0 8px;transition:opacity 1s ease',
].join(';');
const arrowStyle = `
display:flex;flex-direction:column;align-items:center;gap:4px;
background:rgba(0,0,0,0.42);backdrop-filter:blur(10px);
-webkit-backdrop-filter:blur(10px);
border:1px solid rgba(255,255,255,0.18);border-radius:14px;
padding:10px 10px;animation:worlds-pulse 1.2s ease infinite alternate;
`;
hint.innerHTML = `
<style>
@keyframes worlds-pulse {
from { opacity:0.75; transform:translateX(0); }
to { opacity:1; transform:translateX(-3px); }
}
.wsh-right { animation-name:worlds-pulse-r !important; }
@keyframes worlds-pulse-r {
from { opacity:0.75; transform:translateX(0); }
to { opacity:1; transform:translateX(3px); }
}
</style>
<div style="${arrowStyle}">
<svg style="width:20px;height:20px;color:white" viewBox="0 0 256 256">
<path fill="currentColor" d="M165.66 202.34a8 8 0 0 1-11.32 11.32l-80-80a8 8 0 0 1 0-11.32l80-80a8 8 0 0 1 11.32 11.32L91.31 128Z"/>
</svg>
<span style="font-size:8px;font-weight:800;letter-spacing:.12em;color:rgba(255,255,255,0.7);text-transform:uppercase">JETZT</span>
</div>
<div class="wsh-right" style="${arrowStyle}">
<svg style="width:20px;height:20px;color:white" viewBox="0 0 256 256">
<path fill="currentColor" d="M90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32l-80 80a8 8 0 0 1-11.32-11.32L164.69 128Z"/>
</svg>
<span style="font-size:8px;font-weight:800;letter-spacing:.12em;color:rgba(255,255,255,0.7);text-transform:uppercase">WELT</span>
</div>
`;
ov.appendChild(hint);
setTimeout(() => { hint.style.opacity = '0'; }, 2800);
setTimeout(() => hint.remove(), 3900);
}
function show(worldIdx) {
const ov = document.getElementById('worlds-overlay');
if (!ov) return;
@ -83,6 +138,8 @@ window.Worlds = (() => {
ov.classList.remove('worlds-visible');
ov.style.display = 'none';
_visible = false;
document.getElementById('app-header')?.classList.remove('worlds-hidden');
document.getElementById('bottom-nav')?.classList.remove('worlds-hidden');
document.getElementById('worlds-back')?.classList.add('worlds-back-visible');
}
@ -187,7 +244,10 @@ window.Worlds = (() => {
function _setupButtons() {
document.getElementById('worlds-fab')?.addEventListener('click', _openFab);
document.getElementById('worlds-back')?.addEventListener('click', () => show());
document.getElementById('worlds-back')?.addEventListener('click', () => {
if (_state?.user) show();
else if (window.App) window.App.navigate('welcome');
});
document.querySelectorAll('.wdot').forEach((dot, i) => {
dot.style.pointerEvents = 'auto';
dot.addEventListener('click', () => {
@ -414,7 +474,7 @@ window.Worlds = (() => {
{ icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] },
{ icon:'target', label:'Übungen', page:'uebungen',
fab:[{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen', sub:'Übung absolviert', page:'uebungen' }] },
{ icon:'list-checks', label:'Trainings-\npläne', page:'trainingsplaene',
{ icon:'list-checks', label:'Trainingspläne', page:'trainingsplaene',
fab:[{ icon:'list-checks', color:'#10B981', label:'Plan erstellen', sub:'Neuen Trainingsplan anlegen', page:'trainingsplaene', action:'openNew' }] },
{ icon:'heart', label:'Adoption', page:'adoption',
fab:[{ icon:'heart', color:'#EF4444', label:'Hund anbieten', sub:'Zur Adoption freigeben', page:'adoption', action:'openNew' }] },
@ -444,7 +504,7 @@ window.Worlds = (() => {
{ icon:'sparkle', label:'Jobs', page:'jobs' },
{ icon:'book-open', label:'Knigge', page:'knigge' },
{ icon:'film-slate', label:'Filme', page:'movies' },
{ icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder',
{ icon:'tree-structure', label:'Zuchtkartei', page:'zuchthunde', role:'breeder',
fab:[{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] },
{ icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder',
fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }] },
@ -452,12 +512,19 @@ window.Worlds = (() => {
fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] },
{ icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' },
{ icon:'gear', label:'Admin', page:'admin', role:'admin' },
// ── NEUE FEATURES ────────────────────────────────────────────
{ icon:'fork-knife', label:'Ernährung', page:'ernaehrung',
fab:[{ icon:'fork-knife', color:'#F97316', label:'Futter-Tagebuch', sub:'Mahlzeit oder Futtercheck', page:'ernaehrung' }] },
{ icon:'airplane', label:'Reise', page:'reise' },
{ icon:'smiley', label:'Persönlichkeit', page:'personality' },
];
const _DEFAULT_CONFIG = {
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','settings'],
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse'],
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'],
jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'],
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse',
'litters','zuchthunde','ernaehrung','personality'],
welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events',
'jobs','knigge','movies','reise'],
};
// _cfgCache: wird beim Init aus DB geladen, Fallback localStorage → Default
@ -605,10 +672,11 @@ window.Worlds = (() => {
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none">
${!c.pinned ? `
<button class="wc-remove" data-page="${c.page}" data-zone="${w}"
style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;
border-radius:50%;background:#EF4444;border:none;cursor:pointer;
display:flex;align-items:center;justify-content:center;z-index:2">
<svg class="ph-icon" style="width:9px;height:9px;color:white">
style="position:absolute;top:-10px;right:-10px;width:30px;height:30px;
border-radius:50%;background:#EF4444;border:3px solid rgba(18,22,32,0.95);
cursor:pointer;display:flex;align-items:center;justify-content:center;
z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.7)">
<svg class="ph-icon" style="width:17px;height:17px;color:white;stroke:white;stroke-width:1.5">
<use href="/icons/phosphor.svg#x"></use>
</svg>
</button>` : `
@ -781,11 +849,19 @@ window.Worlds = (() => {
const track = document.getElementById('worlds-track');
if (!track) return;
if (url) {
const img = new Image();
img.onload = () => { track.style.backgroundImage = `url('${url}')`; track.style.backgroundSize = '100% auto'; track.style.backgroundPosition = '0 40%'; track.style.backgroundRepeat = 'no-repeat'; };
img.onerror = () => _applyBgImage(null);
img.src = url;
const toLoad = new Image();
toLoad.onload = () => {
_hasBgPhoto = true;
track.style.backgroundImage = `url('${url}')`;
track.style.backgroundSize = '100% auto';
track.style.backgroundPosition = '0 40%';
track.style.backgroundRepeat = 'no-repeat';
document.getElementById('wh-photo-hint')?.remove();
};
toLoad.onerror = () => _applyBgImage(null);
toLoad.src = url;
} else {
_hasBgPhoto = false;
track.style.backgroundImage = 'linear-gradient(160deg,#1a1f35 0%,#16213e 33%,#1a2535 67%,#0f1921 100%)';
track.style.backgroundSize = '100% 100%';
}
@ -839,26 +915,29 @@ window.Worlds = (() => {
const greet = hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend';
const firstName = user?.name?.split(' ')[0] || '';
const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' });
const stale = isOffline && staleMin > 5
const stale = isOffline && staleMin > 5
? `<span style="font-size:9px;opacity:0.5;margin-left:6px">· Offline</span>` : '';
const weatherLine = w
? `${Math.round(w.temp_c)}° ${_esc(w.desc?.split(' ')[0] || '')} · ${Math.round(w.wind_kmh || 0)} km/h · ${w.precip_prob || 0}% Regen`
: '';
// Streak für 3er-Chip-Zeile
let streakVal = '—', streakCol = 'rgba(255,255,255,0.4)';
if (user && dog) {
try {
const sr = await _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`);
const s = sr.data;
const streak = s?.current_streak || 0;
const trainedToday = s?.last_training_date === new Date().toISOString().slice(0,10);
streakCol = trainedToday ? '#10B981' : (streak > 0 ? '#F59E0B' : 'rgba(255,255,255,0.4)');
streakVal = streak > 0
? (trainedToday ? `${streak} Tage` : `🔥 ${streak} Tage`)
: (trainedToday ? '✓ Heute' : 'Heute starten');
} catch {}
// Gassi-Score aus Wetterdaten berechnen
function _calcGassiScore(wd) {
if (!wd) return null;
let s = 10;
const t = wd.temp_c ?? 20, p = wd.precip_prob ?? 0, wind = wd.wind_kmh ?? 0;
if (t > 30) s -= 3; else if (t > 25) s -= 1; else if (t < 0) s -= 3; else if (t < 5) s -= 1;
if (p > 70) s -= 3; else if (p > 40) s -= 2; else if (p > 20) s -= 1;
if (wind > 60) s -= 2; else if (wind > 40) s -= 1;
if (wd.thunderstorm) s -= 3;
return Math.max(1, Math.min(10, s));
}
const gassiScore = _calcGassiScore(w);
const gassiColor = gassiScore >= 8 ? '#10B981' : gassiScore >= 5 ? '#F59E0B' : '#EF4444';
const weatherEmoji = !w ? '🌤️'
: w.thunderstorm ? '⛈️'
: (w.precip_prob ?? 0) > 70 ? '🌧️'
: (w.precip_prob ?? 0) > 30 ? '🌦️'
: (w.temp_c ?? 20) > 28 ? '☀️🔥'
: (w.temp_c ?? 20) < 2 ? '🌨️'
: '☀️';
// Alert-Reminder
const alertHtml = alertList.slice(0,1).map(a => `
@ -901,7 +980,7 @@ window.Worlds = (() => {
<div class="world-info-title">
${_esc(greet)}${firstName ? `, <span style="color:var(--c-primary)">${_esc(firstName)}</span>` : ''}${stale}
</div>
<div class="world-info-sub">${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}${totalKm != null ? ' · ' + totalKm + ' km' : ''}</div>
<div class="world-info-sub">${_esc(dayStr)}</div>
</div>
${user ? userAvatarHtml : ''}
</div>
@ -909,19 +988,27 @@ window.Worlds = (() => {
${alertHtml}
${user && dog ? `
<div class="wj-chip-row">
<div class="wj-chip" data-wnav="uebungen">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:${streakCol}">
<use href="/icons/phosphor.svg#target"></use></svg>
<span class="wj-chip-label">Streak</span>
<span class="wj-chip-val">${streakVal}</span>
<div class="wj-chip" data-wnav="wetter" style="${gassiScore ? `border-color:${gassiColor}44;background:${gassiColor}12;` : ''}">
<div style="display:flex;align-items:center;gap:6px;width:100%">
<span style="font-size:1.4rem;line-height:1;flex-shrink:0">${weatherEmoji}</span>
<div style="flex:1;min-width:0">
<div style="font-size:9px;color:rgba(255,255,255,0.55);font-weight:600;letter-spacing:.05em;text-transform:uppercase">Gassi-Score</div>
<div style="display:flex;align-items:baseline;gap:3px;margin-top:1px">
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
</div>
${w ? `<div style="font-size:9px;color:rgba(255,255,255,0.5);margin-top:1px">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>` : ''}
</div>
</div>
</div>
<div class="wj-chip" data-wnav="routes">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#path"></use></svg>
<span class="wj-chip-label">Gassirunde</span>
<span class="wj-chip-val" id="wj-route-val"></span>
${totalKm != null ? `<span style="font-size:9px;color:rgba(255,255,255,0.4);margin-top:1px">∑ ${totalKm} km</span>` : ''}
</div>
<div class="wj-chip" data-wnav="uebungen">
<div class="wj-chip" id="wj-exercise-chip">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#barbell"></use></svg>
<span class="wj-chip-label">Übung</span>
@ -954,6 +1041,17 @@ window.Worlds = (() => {
const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`);
const ex = res.data?.daily_exercise;
valEl.textContent = ex?.name || '—';
// Chip-Klick mit exercise_id/name damit übungen.js direkt dorthin scrollt
const chip = document.getElementById('wj-exercise-chip');
if (chip) {
chip.style.cursor = 'pointer';
chip.onclick = () => {
hide();
if (window.App) window.App.navigate('uebungen', true,
ex ? { exercise_id: ex.exercise_id || '', name: ex.name || '' } : {}
);
};
}
} catch { valEl.textContent = '—'; }
}
@ -1018,13 +1116,41 @@ window.Worlds = (() => {
const dogs = dogsRes.data || [];
if (!dogs.length) {
const features = [
{ icon:'book-open', color:'#8B5CF6', title:'Tagebuch', sub:'Fotos & Erlebnisse' },
{ icon:'heartbeat', color:'#EF4444', title:'Gesundheit', sub:'Impfungen & Gewicht' },
{ icon:'target', color:'#F59E0B', title:'Training', sub:'104 Übungen' },
{ icon:'books', color:'#10B981', title:'Wiki', sub:'Alle Rassen' },
{ icon:'paw-print', color:'#3B82F6', title:'Gassi', sub:'Routen & GPS' },
{ icon:'currency-eur',color:'#06B6D4',title:'Ausgaben', sub:'Budget im Blick' },
];
el.innerHTML = `
<div class="world-info-card" style="text-align:center">
<div style="font-size:4rem;margin-bottom:12px">🐶</div>
<div class="world-info-title">Noch kein Hund angelegt</div>
<div class="world-info-sub" style="margin-bottom:20px">Erstelle das Profil deines Hundes</div>
<button class="btn btn-primary" onclick="Worlds.navigateTo('dog-profile')">Hund anlegen</button>
<div class="world-top">
<div class="world-info-card" style="text-align:center">
<div style="font-size:3.2rem;margin-bottom:10px">🐶</div>
<div class="world-info-title">Dein Hund wartet!</div>
<div class="world-info-sub" style="margin-bottom:16px">
Lege ein Profil an und schalte alle Features frei
</div>
<button class="btn btn-primary" style="width:100%" onclick="Worlds.navigateTo('dog-profile')">
Hund anlegen
</button>
</div>
</div>
<div class="world-bottom">
<div class="world-section-label">Was dich erwartet</div>
<div class="world-chips-grid">
${features.map(f => `
<div class="world-chip" style="opacity:0.7;cursor:default">
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:${f.color}">
<use href="/icons/phosphor.svg#${f.icon}"></use>
</svg>
<span class="world-chip-label">${f.title}</span>
</div>
`).join('')}
</div>
</div>`;
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
return;
}
@ -1093,6 +1219,25 @@ window.Worlds = (() => {
</div>
</div>
<div class="world-bottom">
${!_hasBgPhoto ? `
<div id="wh-photo-hint" data-wnav="diary"
style="background:rgba(0,0,0,0.32);backdrop-filter:blur(12px);
-webkit-backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.12);
border-radius:16px;padding:11px 14px;display:flex;align-items:center;
gap:10px;cursor:pointer;color:white;flex-shrink:0">
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:rgba(196,132,58,0.9);flex-shrink:0">
<use href="/icons/phosphor.svg#camera"></use>
</svg>
<div>
<div style="font-size:var(--text-xs);font-weight:700;color:rgba(255,255,255,0.85)">
Hintergrund-Foto hinzufügen
</div>
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:1px">
Tagebuchfotos erscheinen hier als Panorama
</div>
</div>
</div>
` : ''}
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
<div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}

View file

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

View file

@ -226,9 +226,90 @@
</div>
</section>
<!-- Foto-Galerie -->
<section>
<div class="section-label">Fotos — zur redaktionellen Verwendung freigegeben</div>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-bottom:8px">
<!-- Herbst am Bach -->
<div style="border-radius:var(--radius);overflow:hidden;background:var(--white);border:1px solid var(--border)">
<div style="position:relative;aspect-ratio:4/3">
<img src="/img/banyaro/herbst_bach.webp" alt="Ban Yaro am Bach im Herbst"
style="width:100%;height:100%;object-fit:cover;display:block">
</div>
<div style="padding:10px 12px">
<div style="font-size:.8rem;font-weight:700;margin-bottom:2px">Herbst am Bach</div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:8px">8064 × 6048 px · 20 MB JPEG</div>
<a href="/img/banyaro/hires/banyaro_herbst_bach_hires.jpg"
download="banyaro-herbst-bach-hires.jpg"
style="display:inline-flex;align-items:center;gap:5px;background:var(--primary);color:white;
font-size:.72rem;font-weight:700;padding:5px 12px;border-radius:6px;text-decoration:none">
↓ Hi-Res herunterladen
</a>
</div>
</div>
<!-- Winter im Schnee -->
<div style="border-radius:var(--radius);overflow:hidden;background:var(--white);border:1px solid var(--border)">
<div style="position:relative;aspect-ratio:4/3">
<img src="/img/banyaro/winter_schnee.webp" alt="Ban Yaro im Schnee"
style="width:100%;height:100%;object-fit:cover;display:block">
</div>
<div style="padding:10px 12px">
<div style="font-size:.8rem;font-weight:700;margin-bottom:2px">Winter im Schnee</div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:8px">Original-Auflösung · JPEG</div>
<a href="/img/banyaro/hires/banyaro_winter_schnee_hires.jpg"
download="banyaro-winter-schnee-hires.jpg"
style="display:inline-flex;align-items:center;gap:5px;background:var(--primary);color:white;
font-size:.72rem;font-weight:700;padding:5px 12px;border-radius:6px;text-decoration:none">
↓ Hi-Res herunterladen
</a>
</div>
</div>
<!-- Frühling & Playdate -->
<div style="border-radius:var(--radius);overflow:hidden;background:var(--white);border:1px solid var(--border)">
<div style="position:relative;aspect-ratio:4/3">
<img src="/img/banyaro/fruehling_playdate.webp" alt="Ban Yaro spielt im Frühling"
style="width:100%;height:100%;object-fit:cover;display:block">
</div>
<div style="padding:10px 12px">
<div style="font-size:.8rem;font-weight:700;margin-bottom:2px">Frühling &amp; Playdate</div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:8px">3199 × 2648 px · 3,8 MB JPEG</div>
<a href="/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg"
download="banyaro-fruehling-playdate-hires.jpg"
style="display:inline-flex;align-items:center;gap:5px;background:var(--primary);color:white;
font-size:.72rem;font-weight:700;padding:5px 12px;border-radius:6px;text-decoration:none">
↓ Hi-Res herunterladen
</a>
</div>
</div>
<!-- Herbst & Neugier -->
<div style="border-radius:var(--radius);overflow:hidden;background:var(--white);border:1px solid var(--border)">
<div style="position:relative;aspect-ratio:4/3">
<img src="/img/banyaro/herbst_baum.webp" alt="Ban Yaro neugierig am Baum"
style="width:100%;height:100%;object-fit:cover;display:block">
</div>
<div style="padding:10px 12px">
<div style="font-size:.8rem;font-weight:700;margin-bottom:2px">Herbst &amp; Neugier</div>
<div style="font-size:.7rem;color:var(--muted);margin-bottom:8px">8064 × 6048 px · 17 MB JPEG</div>
<a href="/img/banyaro/hires/banyaro_herbst_baum_hires.jpg"
download="banyaro-herbst-baum-hires.jpg"
style="display:inline-flex;align-items:center;gap:5px;background:var(--primary);color:white;
font-size:.72rem;font-weight:700;padding:5px 12px;border-radius:6px;text-decoration:none">
↓ Hi-Res herunterladen
</a>
</div>
</div>
</div>
<p style="font-size:.78rem;color:var(--muted)">Alle Fotos: Ban Yaro (Kromfohrländer) · Fotograf: René Degelmann · Zur redaktionellen Verwendung freigegeben</p>
</section>
<!-- Screenshots -->
<section>
<div class="section-label">Screenshots — zur redaktionellen Verwendung freigegeben</div>
<div class="section-label">App-Screenshots — zur redaktionellen Verwendung freigegeben</div>
<div class="download-grid">
<a class="download-card" href="/img/screenshots/screen-1.jpg" download="banyaro-tagebuch.jpg">
<img class="thumb" src="/img/screenshots/screen-1.jpg" alt="Tagebuch">

View file

@ -3,16 +3,16 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v700';
const CACHE_VERSION = 'by-v727';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
// index.html wird NICHT pre-gecacht (immer Network-First)
const STATIC_ASSETS = [
'/css/design-system.css?v=545',
'/css/layout.css?v=545',
'/css/components.css?v=545',
'/css/design-system.css?v=700',
'/css/layout.css?v=700',
'/css/components.css?v=700',
'/icons/phosphor.svg',
'/js/api.js',
'/js/ui.js',