Feature: Hilfe/FAQ, Übungen-Content, Navigation-Fixes (SW by-v727)

Hilfe & FAQ:
- Neue Seite /hilfe mit Akkordeon + Live-Suche (6 Kategorien, 25 Artikel)
- DB-Tabelle help_articles — Inhalte admin-seitig ohne Deploy änderbar
- Admin-Tab Hilfe/FAQ zum Bearbeiten aller Artikel
- Link in Einstellungen (unter Welten einrichten, über Abmelden)
- routes/help.py: GET (public), POST/PATCH/DELETE (Admin)

Übungen:
- 110 Übungen: beschreibung (kurz), schritte (JSON 4-6 Schritte), tipp — gutes Deutsch mit Umlauten
- Admin-Tab Übungen: Inline-Editor für alle drei Felder
- PUT /training/exercises/{id} (Admin) neu
- Übung-des-Tages Chip → scrollt jetzt korrekt zur Übung (exercise_id-Feldname-Fix)

Welten-Navigation:
- hide() stellt app-header + bottom-nav wieder her (worlds-hidden wurde nie entfernt)
- init() mit _setupDone-Guard (keine doppelten Event-Listener)
- Login ruft Worlds.init(_appState) statt show() — _state war null → falscher Render
- X-Button in Welten-Konfiguration: 30×30px, Icon 17px, besser sichtbar

Wetter:
- Motivation bei blockiertem Standort: 6-Schritte-iOS-Anleitung + Flugmodus-Tipp
- Auto-locate bleibt (kein Button-Only mehr)

achievements.py:
- my_achievements(): d.user_id → JOIN dogs (zweite Funktion war noch kaputt)
This commit is contained in:
rene 2026-05-05 21:46:16 +02:00
parent 55069d246b
commit 05ecf3b94a
13 changed files with 1158 additions and 43 deletions

View file

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

View file

@ -235,6 +235,7 @@ from routes.playdate import router as playdate_router
from routes.ernaehrung import router as ernaehrung_router from routes.ernaehrung import router as ernaehrung_router
from routes.challenges import router as challenges_router from routes.challenges import router as challenges_router
from routes.gassi_zeiten import router as gassi_zeiten_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -296,6 +297,7 @@ app.include_router(playdate_router, prefix="/api/playdate", ta
app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"]) 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(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -325,7 +327,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True) os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
APP_VER = "715" # muss mit APP_VER in app.js übereinstimmen APP_VER = "727" # muss mit APP_VER in app.js übereinstimmen
@app.get("/api/version") @app.get("/api/version")
async def app_version(): async def app_version():

View file

@ -308,8 +308,8 @@ async def my_achievements(user=Depends(get_current_user)):
# Wetter-Tapferkeit # Wetter-Tapferkeit
wetter_row = conn.execute(""" wetter_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary d SELECT COUNT(*) AS cnt FROM diary d
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id JOIN dogs dog ON dog.id = d.dog_id
WHERE d.user_id = ? WHERE dog.user_id = ?
AND d.weather_json IS NOT NULL AND d.weather_json IS NOT NULL
AND ( AND (
CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60 CAST(json_extract(d.weather_json, '$.precip_prob') AS INTEGER) > 60
@ -321,20 +321,25 @@ async def my_achievements(user=Depends(get_current_user)):
# Jahreszeiten # Jahreszeiten
jahreszeiten_row = conn.execute(""" jahreszeiten_row = conn.execute("""
SELECT 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 d2 JOIN dogs g2 ON g2.id=d2.dog_id
(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) + 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 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 d2 JOIN dogs g2 ON g2.id=d2.dog_id
(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) 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 AS jahreszeiten_score
FROM (SELECT 1) FROM (SELECT 1)
""", (uid, uid, uid, uid)).fetchone() """, (uid, uid, uid, uid)).fetchone()
# Schnee-Einträge # Schnee-Einträge
schnee_row = conn.execute(""" schnee_row = conn.execute("""
SELECT COUNT(*) AS cnt FROM diary SELECT COUNT(*) AS cnt FROM diary d
WHERE user_id = ? JOIN dogs dog ON dog.id = d.dog_id
AND weather_json IS NOT NULL WHERE dog.user_id = ?
AND CAST(json_extract(weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77 AND d.weather_json IS NOT NULL
AND CAST(json_extract(d.weather_json, '$.weathercode') AS INTEGER) BETWEEN 71 AND 77
""", (uid,)).fetchone() """, (uid,)).fetchone()
earned_rows = conn.execute( earned_rows = conn.execute(

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

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

View file

@ -6,7 +6,7 @@ from typing import Optional
import datetime import datetime
import ki import ki
from database import db from database import db
from auth import get_current_user from auth import get_current_user, require_admin
router = APIRouter() router = APIRouter()
@ -50,6 +50,36 @@ async def get_exercises():
}) })
return by_tab 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 # Übungs-Status
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -409,6 +409,10 @@
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
<section class="page" id="page-hilfe">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-settings"> <section class="page" id="page-settings">
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
@ -574,7 +578,7 @@
<script src="/js/api.js?v=94"></script> <script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=94"></script> <script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=94"></script> <script src="/js/app.js?v=94"></script>
<script src="/js/worlds.js?v=715"></script> <script src="/js/worlds.js?v=727"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '715'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '727'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
@ -79,6 +79,7 @@ const App = (() => {
ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true }, ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true },
personality: { title: 'Persönlichkeitstest', module: null }, personality: { title: 'Persönlichkeitstest', module: null },
reise: { title: 'Reise mit Hund', module: null }, reise: { title: 'Reise mit Hund', module: null },
hilfe: { title: 'Hilfe & FAQ', module: null },
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

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

View file

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

View file

@ -270,6 +270,12 @@ window.Page_settings = (() => {
<span>Welten einrichten</span> <span>Welten einrichten</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span> <span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div> </div>
<div class="sidebar-item" id="settings-hilfe-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#question"></use></svg>
<span>Hilfe &amp; FAQ</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-logout-btn" <div class="sidebar-item" id="settings-logout-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer; style="padding:var(--space-4);border-radius:0;cursor:pointer;
color:var(--c-danger)"> color:var(--c-danger)">
@ -766,6 +772,10 @@ window.Page_settings = (() => {
else if (window.Worlds) window.Worlds.openConfig?.(); 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 () => { document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ const ok = await UI.modal.confirm({
title : 'Abmelden?', title : 'Abmelden?',
@ -1672,9 +1682,9 @@ window.Page_settings = (() => {
_offerPushNotifications(); _offerPushNotifications();
} }
// Nach Login: Direkt in HUND-Welt oder Profil anlegen // Nach Login: Welten initialisieren (mit User-State) oder Profil anlegen
if (_appState.activeDog) { if (_appState.activeDog) {
window.Worlds?.show(1); window.Worlds?.init(_appState);
} else { } else {
App.navigate('dog-profile'); App.navigate('dog-profile');
} }

View file

@ -73,23 +73,17 @@ window.Page_wetter = (() => {
_tryAutoLocate(); _tryAutoLocate();
} }
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() { async function refresh() {
_selDay = 0; _selDay = 0;
_recordsLoaded = false; _recordsLoaded = false;
_renderShell(); _renderShell();
_tryAutoLocate(); _tryAutoLocate();
} }
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _renderShell() { function _renderShell() {
_container.innerHTML = ` _container.innerHTML = `
<div id="wttr-body"> <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> <div style="margin-bottom:var(--space-3)">${_wmoIcon(2, '2.5rem')}</div>
<p style="color:var(--c-text-secondary)">Standort wird ermittelt</p> <p style="color:var(--c-text-secondary)">Standort wird ermittelt</p>
</div> </div>
@ -97,26 +91,55 @@ window.Page_wetter = (() => {
`; `;
} }
// ----------------------------------------------------------
// STANDORT AUTOMATISCH ERMITTELN
// ----------------------------------------------------------
async function _tryAutoLocate() { async function _tryAutoLocate() {
try { try {
const pos = await API.getLocation({ timeout: 8000, maximumAge: 300_000 }); const pos = await API.getLocation({ timeout: 8000, maximumAge: 300_000 });
await _loadData(pos.lat, pos.lon); await _loadData(pos.lat, pos.lon);
} catch { } catch (err) {
_showLocationError(); _showLocationError(err?.code);
} }
} }
function _showLocationError() { function _showLocationError(errCode) {
const body = _container.querySelector('#wttr-body'); const body = _container?.querySelector('#wttr-body');
if (!body) return; if (!body) return;
const isLoggedIn = !!_appState?.user; const isLoggedIn = !!_appState?.user;
const isDenied = errCode === 1; // GeolocationPositionError.PERMISSION_DENIED
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const deniedHint = isDenied ? `
<div style="background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.4);
border-radius:var(--radius-lg);padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:#F59E0B;flex-shrink:0">
<use href="/icons/phosphor.svg#warning"></use>
</svg>
<div style="font-weight:700;font-size:var(--text-sm)">Standort-Zugriff blockiert</div>
</div>
${isIos ? `
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-3)">
<b>Wichtig:</b> Die App läuft getrennt von Safari Safari-Einstellungen gelten hier nicht.
</div>
<ol style="margin:0;padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-1);
color:var(--c-text-secondary);font-size:var(--text-sm)">
<li>Öffne <b>Einstellungen Datenschutz &amp; Sicherheit Ortungsdienste</b></li>
<li>Scrolle ganz nach unten zu <b>Ban Yaro</b> (nicht Safari!)</li>
<li>Wähle <b>Beim Verwenden der App"</b></li>
<li>Komm zurück und tippe nochmal auf den Button</li>
</ol>
<div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">
<b>Letzter Ausweg:</b> Einstellungen Apps Safari Erweitert Website-Daten banyaro.app löschen. Danach nochmal öffnen und Button tippen.
</div>` : `
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">
Klicke auf das Schloss-Symbol in der Adressleiste <b>Standort</b> <b>Erlauben</b>, dann nochmal tippen.
</div>`}
</div>` : '';
body.innerHTML = ` body.innerHTML = `
<div style="max-width:420px;margin:0 auto;padding:var(--space-6) var(--space-4)"> <div style="max-width:420px;margin:0 auto;padding:var(--space-6) var(--space-4)">
${deniedHint}
<!-- Hero --> <!-- Hero -->
<div style="text-align:center;margin-bottom:var(--space-6)"> <div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:4rem;line-height:1;margin-bottom:var(--space-2)">🌤🐾</div> <div style="font-size:4rem;line-height:1;margin-bottom:var(--space-2)">🌤🐾</div>

View file

@ -14,6 +14,7 @@ window.Worlds = (() => {
let _dogs = []; // gecachte Hundesliste let _dogs = []; // gecachte Hundesliste
let _dogIdx = 0; // aktuell angezeigter Hund let _dogIdx = 0; // aktuell angezeigter Hund
let _hasBgPhoto = false; // Hintergrund-Foto vorhanden? let _hasBgPhoto = false; // Hintergrund-Foto vorhanden?
let _setupDone = false; // Swipe/Button-Listener nur einmal registrieren
// Touch-Tracking // Touch-Tracking
const _t = { x:0, y:0, active:false, vert:null, moved:0 }; const _t = { x:0, y:0, active:false, vert:null, moved:0 };
@ -44,13 +45,17 @@ window.Worlds = (() => {
// ── PUBLIC ────────────────────────────────────────────────── // ── PUBLIC ──────────────────────────────────────────────────
async function init(appState) { async function init(appState) {
_state = appState; _state = appState;
_cur = 1; // immer HUND als Start _lastUserId = undefined; // Neurender erzwingen
_setupSwipe(); _cur = 1;
_setupButtons(); if (!_setupDone) {
_setupDone = true;
_setupSwipe();
_setupButtons();
_showSwipeHints();
}
_goTo(_cur, false); _goTo(_cur, false);
show(); show();
_showSwipeHints();
} }
function _showSwipeHints() { function _showSwipeHints() {
@ -133,6 +138,8 @@ window.Worlds = (() => {
ov.classList.remove('worlds-visible'); ov.classList.remove('worlds-visible');
ov.style.display = 'none'; ov.style.display = 'none';
_visible = false; _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'); document.getElementById('worlds-back')?.classList.add('worlds-back-visible');
} }
@ -665,11 +672,11 @@ window.Worlds = (() => {
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none"> user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none">
${!c.pinned ? ` ${!c.pinned ? `
<button class="wc-remove" data-page="${c.page}" data-zone="${w}" <button class="wc-remove" data-page="${c.page}" data-zone="${w}"
style="position:absolute;top:-8px;right:-8px;width:24px;height:24px; style="position:absolute;top:-10px;right:-10px;width:30px;height:30px;
border-radius:50%;background:#EF4444;border:2px solid rgba(18,22,32,0.9); border-radius:50%;background:#EF4444;border:3px solid rgba(18,22,32,0.95);
cursor:pointer;display:flex;align-items:center;justify-content:center; cursor:pointer;display:flex;align-items:center;justify-content:center;
z-index:2;box-shadow:0 2px 6px rgba(0,0,0,0.5)"> z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.7)">
<svg class="ph-icon" style="width:13px;height:13px;color:white"> <svg class="ph-icon" style="width:17px;height:17px;color:white;stroke:white;stroke-width:1.5">
<use href="/icons/phosphor.svg#x"></use> <use href="/icons/phosphor.svg#x"></use>
</svg> </svg>
</button>` : ` </button>` : `
@ -1001,7 +1008,7 @@ window.Worlds = (() => {
<span class="wj-chip-val" id="wj-route-val"></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>` : ''} ${totalKm != null ? `<span style="font-size:9px;color:rgba(255,255,255,0.4);margin-top:1px">∑ ${totalKm} km</span>` : ''}
</div> </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)"> <svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#barbell"></use></svg> <use href="/icons/phosphor.svg#barbell"></use></svg>
<span class="wj-chip-label">Übung</span> <span class="wj-chip-label">Übung</span>
@ -1034,6 +1041,17 @@ window.Worlds = (() => {
const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`); const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`);
const ex = res.data?.daily_exercise; const ex = res.data?.daily_exercise;
valEl.textContent = ex?.name || '—'; 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 = '—'; } } catch { valEl.textContent = '—'; }
} }

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v715'; const CACHE_VERSION = 'by-v727';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache