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:
parent
55069d246b
commit
05ecf3b94a
13 changed files with 1158 additions and 43 deletions
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
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}
|
||||||
|
|
@ -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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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, '<')}</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 };
|
return { init, refresh, onDogChange };
|
||||||
|
|
||||||
|
|
|
||||||
246
backend/static/js/pages/hilfe.js
Normal file
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 };
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
@ -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 & 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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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 = '—'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue