Release v1.4.0
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
304
backend/routes/challenges.py
Normal 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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
190
backend/routes/gassi_zeiten.py
Normal 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
|
|
@ -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}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
BIN
backend/static/img/banyaro/fruehling_playdate.webp
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
backend/static/img/banyaro/herbst_bach.webp
Normal file
|
After Width: | Height: | Size: 446 KiB |
BIN
backend/static/img/banyaro/herbst_baum.webp
Normal file
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 3.8 MiB |
BIN
backend/static/img/banyaro/hires/banyaro_herbst_bach_hires.jpg
Normal file
|
After Width: | Height: | Size: 20 MiB |
BIN
backend/static/img/banyaro/hires/banyaro_herbst_baum_hires.jpg
Normal file
|
After Width: | Height: | Size: 17 MiB |
BIN
backend/static/img/banyaro/hires/banyaro_winter_schnee_hires.jpg
Normal file
|
After Width: | Height: | Size: 437 KiB |
BIN
backend/static/img/banyaro/winter_schnee.webp
Normal file
|
After Width: | Height: | Size: 84 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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, '<')}</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, '<')}</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, '"')}"
|
||||
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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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} — Forum ansehen
|
||||
</button>
|
||||
`;
|
||||
document.getElementById('dp-breed-chip-btn')?.addEventListener('click', () => {
|
||||
App.navigate('forum', false, { search: hauptRasse });
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
246
backend/static/js/pages/hilfe.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
})();
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
496
backend/static/js/pages/reise.js
Normal 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 (2–3 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
|
|
@ -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 & 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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
· ${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)}
|
||||
· ${wochentageLabel}
|
||||
${z.ort_name ? ` · ${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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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 1–10', '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 & 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 & 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>
|
||||
|
|
|
|||
|
|
@ -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('')}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||