Compare commits
31 commits
e8cf742911
...
25417364d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 25417364d2 | |||
| 9139e33492 | |||
| b4fec76644 | |||
| 183cc564fc | |||
| effdf5ba5b | |||
| 5a30f657a1 | |||
| 06022cf5da | |||
| 047e5be986 | |||
| 1fdd7d4ed0 | |||
| 480a343ec0 | |||
| b4879d615f | |||
| 3acb7aa874 | |||
| 891e11df65 | |||
| 0b06669635 | |||
| af2851a4ac | |||
| 5f1a6d578b | |||
| ab851d4bb1 | |||
| fbd8f0cd8f | |||
| 322a9609b3 | |||
| 626148436a | |||
| 6eb333bb01 | |||
| 2a545377c1 | |||
| e20e691c4d | |||
| c1bcc029ea | |||
| d91cc8da26 | |||
| 67f042df75 | |||
| 209d6703ad | |||
| 5949a07b28 | |||
| 97a03ce006 | |||
| f73a2bdeab | |||
| e4b170d45b |
18 changed files with 338 additions and 161 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,3 +11,4 @@ __pycache__/
|
||||||
# Design-Quell-Dateien (nicht für Server)
|
# Design-Quell-Dateien (nicht für Server)
|
||||||
/icons/
|
/icons/
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
Ban Yaro - Google Play package/
|
||||||
|
|
|
||||||
|
|
@ -1236,11 +1236,16 @@ def _migrate(conn_factory):
|
||||||
CREATE TABLE IF NOT EXISTS ki_health_reports (
|
CREATE TABLE IF NOT EXISTS ki_health_reports (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
bericht TEXT NOT NULL,
|
bericht TEXT NOT NULL,
|
||||||
erstellt_at TEXT NOT NULL DEFAULT (datetime('now'))
|
erstellt_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
# user_id nachträglich ergänzen falls Tabelle ohne diese Spalte erstellt wurde
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE ki_health_reports ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE")
|
||||||
|
except Exception:
|
||||||
|
pass # Spalte existiert bereits
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_ki_health_reports_dog
|
CREATE INDEX IF NOT EXISTS idx_ki_health_reports_dog
|
||||||
ON ki_health_reports(dog_id, erstellt_at DESC)
|
ON ki_health_reports(dog_id, erstellt_at DESC)
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ def _track_usage(user_id: int | None, source: str) -> None:
|
||||||
|
|
||||||
def _is_cloud_priority_user(user_id: int | None) -> bool:
|
def _is_cloud_priority_user(user_id: int | None) -> bool:
|
||||||
"""Privilegierte Rollen (Admin, Moderator, Züchter, Manager) nutzen Cloud-KI primär."""
|
"""Privilegierte Rollen (Admin, Moderator, Züchter, Manager) nutzen Cloud-KI primär."""
|
||||||
if not user_id or not ANTHROPIC_KEY:
|
if not user_id:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
from database import db
|
from database import db
|
||||||
|
|
@ -173,8 +173,10 @@ async def complete(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Cloud-KI nicht erreichbar für privilegierten User, Fallback lokal: {e}")
|
logger.warning(f"Cloud-KI nicht erreichbar für privilegierten User, Fallback lokal: {e}")
|
||||||
# Fallback auf lokales Modell
|
try:
|
||||||
text = await _local_complete(prompt, system, max_tokens, json_mode)
|
text = await _local_complete(prompt, system, max_tokens, json_mode)
|
||||||
|
except Exception as local_e:
|
||||||
|
raise KIUnavailableError("KI-Modell nicht erreichbar.") from local_e
|
||||||
_track_usage(user_id, "local")
|
_track_usage(user_id, "local")
|
||||||
if return_model:
|
if return_model:
|
||||||
return (text, LOCAL_MODEL)
|
return (text, LOCAL_MODEL)
|
||||||
|
|
@ -399,7 +401,7 @@ async def health_summary(health_data: list, dog_info: dict,
|
||||||
if not subset:
|
if not subset:
|
||||||
return " (keine Einträge)"
|
return " (keine Einträge)"
|
||||||
lines = []
|
lines = []
|
||||||
for e in subset[:10]: # maximal 10 pro Typ
|
for e in subset[:5]: # maximal 5 pro Typ — Kontextfenster schonen
|
||||||
line = f" - {e.get('datum', '?')}: {e.get('bezeichnung', '?')}"
|
line = f" - {e.get('datum', '?')}: {e.get('bezeichnung', '?')}"
|
||||||
if e.get("naechstes"):
|
if e.get("naechstes"):
|
||||||
line += f" (nächste Fälligkeit: {e['naechstes']})"
|
line += f" (nächste Fälligkeit: {e['naechstes']})"
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,20 @@ class _UploadSizeMiddleware(BaseHTTPMiddleware):
|
||||||
app.add_middleware(_UploadSizeMiddleware)
|
app.add_middleware(_UploadSizeMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
class _AppVersionMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Fügt X-App-Version zu allen /api/-Antworten hinzu.
|
||||||
|
api.js erkennt damit sofort wenn eine neue Version deployed wurde
|
||||||
|
und lädt beim nächsten Seitenwechsel automatisch neu — kein Banner nötig.
|
||||||
|
"""
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
if request.url.path.startswith('/api/'):
|
||||||
|
response.headers['X-App-Version'] = APP_VER
|
||||||
|
return response
|
||||||
|
|
||||||
|
app.add_middleware(_AppVersionMiddleware)
|
||||||
|
|
||||||
|
|
||||||
class _CacheControlMiddleware(BaseHTTPMiddleware):
|
class _CacheControlMiddleware(BaseHTTPMiddleware):
|
||||||
"""Setzt Cache-Control-Header für statische Assets.
|
"""Setzt Cache-Control-Header für statische Assets.
|
||||||
CSS/JS: no-cache (ETag-Validierung) — iOS cached sonst ewig ohne Ablaufdatum.
|
CSS/JS: no-cache (ETag-Validierung) — iOS cached sonst ewig ohne Ablaufdatum.
|
||||||
|
|
@ -327,7 +341,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 = "785" # muss mit APP_VER in app.js übereinstimmen
|
APP_VER = "819" # muss mit APP_VER in app.js übereinstimmen
|
||||||
|
|
||||||
@app.get("/.well-known/assetlinks.json")
|
@app.get("/.well-known/assetlinks.json")
|
||||||
async def assetlinks():
|
async def assetlinks():
|
||||||
|
|
@ -1544,6 +1558,84 @@ async def presse():
|
||||||
return FileResponse(f"{STATIC_DIR}/presse.html", headers={"Cache-Control": "max-age=3600"})
|
return FileResponse(f"{STATIC_DIR}/presse.html", headers={"Cache-Control": "max-age=3600"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/konto-loeschen")
|
||||||
|
async def konto_loeschen():
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
html = """<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Konto löschen — Ban Yaro</title>
|
||||||
|
<link rel="stylesheet" href="/css/design-system.css">
|
||||||
|
<style>
|
||||||
|
body { font-family: var(--font-sans); background: var(--c-bg); color: var(--c-text);
|
||||||
|
max-width: 600px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||||
|
h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.5rem; }
|
||||||
|
p { color: var(--c-text-secondary); line-height: 1.6; margin-bottom: 1rem; }
|
||||||
|
ol { color: var(--c-text-secondary); line-height: 2; padding-left: 1.5rem; }
|
||||||
|
.btn { display: inline-flex; align-items: center; gap: 0.5rem;
|
||||||
|
background: var(--c-primary); color: #fff; border: none;
|
||||||
|
border-radius: 100px; padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem; font-weight: 600; text-decoration: none;
|
||||||
|
margin-top: 1.5rem; cursor: pointer; }
|
||||||
|
.warn { background: var(--c-danger-subtle); border: 1px solid var(--c-danger-border);
|
||||||
|
border-radius: 12px; padding: 1rem 1.25rem; margin-bottom: 1.5rem;
|
||||||
|
color: var(--c-danger); font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p style="margin-bottom:2rem"><a href="/" style="color:var(--c-primary);text-decoration:none">← Zurück zu Ban Yaro</a></p>
|
||||||
|
<h1>Konto löschen</h1>
|
||||||
|
<p>Du kannst dein Ban Yaro-Konto und alle zugehörigen Daten dauerhaft löschen.</p>
|
||||||
|
<div class="warn">
|
||||||
|
⚠️ Diese Aktion ist unwiderruflich. Alle Daten (Tagebuch, Gesundheit, Training, Fotos) werden dauerhaft gelöscht.
|
||||||
|
</div>
|
||||||
|
<p><strong>So löschst du dein Konto:</strong></p>
|
||||||
|
<ol>
|
||||||
|
<li>Öffne <a href="/" style="color:var(--c-primary)">banyaro.app</a> und melde dich an</li>
|
||||||
|
<li>Tippe auf das Menü-Symbol oben rechts</li>
|
||||||
|
<li>Gehe zu <strong>Einstellungen</strong></li>
|
||||||
|
<li>Scrolle nach unten zu <strong>„Konto löschen"</strong></li>
|
||||||
|
<li>Bestätige die Löschung</li>
|
||||||
|
</ol>
|
||||||
|
<a href="/" class="btn">Ban Yaro öffnen</a>
|
||||||
|
<p style="margin-top:2rem;font-size:0.85rem">
|
||||||
|
Alternativ kannst du die Löschung per E-Mail an
|
||||||
|
<a href="mailto:support@banyaro.app" style="color:var(--c-primary)">support@banyaro.app</a> beantragen.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"})
|
||||||
|
|
||||||
|
|
||||||
|
# /force-update — SW + Cache-Killer für hartnäckige alte Versionen
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@app.get("/force-update")
|
||||||
|
async def force_update():
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
html = """<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Ban Yaro — Update</title>
|
||||||
|
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;
|
||||||
|
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
|
||||||
|
p{color:#94a3b8;font-size:14px}</style></head>
|
||||||
|
<body>
|
||||||
|
<div>⏳ Aktualisiere Ban Yaro…</div>
|
||||||
|
<p id="s">Service Worker wird entfernt…</p>
|
||||||
|
<script>
|
||||||
|
// Zweiten Reload durch SW-updatefound verhindern
|
||||||
|
sessionStorage.setItem('by_skip_sw_reload','1');
|
||||||
|
// Fire-and-forget — kein await, Reload nach spätestens 1.5s
|
||||||
|
try{
|
||||||
|
navigator.serviceWorker?.getRegistrations().then(r=>r.forEach(s=>s.unregister())).catch(()=>{});
|
||||||
|
caches.keys().then(k=>k.forEach(c=>caches.delete(c))).catch(()=>{});
|
||||||
|
}catch(e){}
|
||||||
|
setTimeout(()=>location.replace('/'),1500);
|
||||||
|
</script></body></html>"""
|
||||||
|
return HTMLResponse(content=html, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
# /partner — Influencer-Landingpage
|
# /partner — Influencer-Landingpage
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@app.get("/partner")
|
@app.get("/partner")
|
||||||
|
|
|
||||||
|
|
@ -454,7 +454,21 @@ async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)):
|
||||||
user_is_premium=bool(user.get("is_premium")),
|
user_is_premium=bool(user.get("is_premium")),
|
||||||
user_id=user["id"],
|
user_id=user["id"],
|
||||||
)
|
)
|
||||||
return {"zusammenfassung": result}
|
save_error = None
|
||||||
|
try:
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ki_health_reports (dog_id, user_id, bericht) VALUES (?,?,?)",
|
||||||
|
(dog_id, user["id"], result)
|
||||||
|
)
|
||||||
|
count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM ki_health_reports WHERE dog_id=?", (dog_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
except Exception as e:
|
||||||
|
save_error = str(e)
|
||||||
|
count = 0
|
||||||
|
logger.warning(f"KI-Bericht konnte nicht gespeichert werden: {e}")
|
||||||
|
return {"zusammenfassung": result, "saved_count": count, "save_error": save_error}
|
||||||
except KIPremiumRequired as e:
|
except KIPremiumRequired as e:
|
||||||
raise HTTPException(402, str(e))
|
raise HTTPException(402, str(e))
|
||||||
except KIUnavailableError as e:
|
except KIUnavailableError as e:
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,7 @@ async def ki_geburtstag(req: BirthdayRequest, request: Request,
|
||||||
try:
|
try:
|
||||||
answer = await ki_module.complete(
|
answer = await ki_module.complete(
|
||||||
system=system, prompt=prompt, max_tokens=600, requires_premium=False,
|
system=system, prompt=prompt, max_tokens=600, requires_premium=False,
|
||||||
|
user_id=user["id"],
|
||||||
)
|
)
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -142,3 +142,28 @@ async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
|
||||||
conn.execute("UPDATE users SET world_config=? WHERE id=?",
|
conn.execute("UPDATE users SET world_config=? WHERE id=?",
|
||||||
(_json.dumps(body.config), user['id']))
|
(_json.dumps(body.config), user['id']))
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# DELETE /profile/account — Konto unwiderruflich löschen
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
@router.delete('/account')
|
||||||
|
async def delete_account(user=Depends(get_current_user)):
|
||||||
|
"""Löscht das Konto und alle zugehörigen Daten unwiderruflich."""
|
||||||
|
uid = user['id']
|
||||||
|
with db() as conn:
|
||||||
|
# Alle Hunde-IDs des Users
|
||||||
|
dog_ids = [r['id'] for r in conn.execute(
|
||||||
|
"SELECT id FROM dogs WHERE user_id=?", (uid,)).fetchall()]
|
||||||
|
for did in dog_ids:
|
||||||
|
conn.execute("DELETE FROM diary WHERE dog_id=?", (did,))
|
||||||
|
conn.execute("DELETE FROM health WHERE dog_id=?", (did,))
|
||||||
|
conn.execute("DELETE FROM training_sessions WHERE dog_id=?", (did,))
|
||||||
|
conn.execute("DELETE FROM training_streaks WHERE dog_id=?", (did,))
|
||||||
|
conn.execute("DELETE FROM expenses WHERE dog_id=?", (did,))
|
||||||
|
conn.execute("DELETE FROM dogs WHERE user_id=?", (uid,))
|
||||||
|
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
|
||||||
|
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
|
||||||
|
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
|
||||||
|
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
1. TOKENS — Farben, Abstände, Typografie, Schatten
|
1. TOKENS — Farben, Abstände, Typografie, Schatten
|
||||||
------------------------------------------------------------ */
|
------------------------------------------------------------ */
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: dark light;
|
||||||
/* Primärfarben — Honig-Amber aus Ban Yaros Fell */
|
/* Primärfarben — Honig-Amber aus Ban Yaros Fell */
|
||||||
--c-primary: #C4843A;
|
--c-primary: #C4843A;
|
||||||
--c-primary-dark: #9E6520;
|
--c-primary-dark: #9E6520;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#C4843A">
|
<meta name="theme-color" content="#0f1623" id="meta-theme-color">
|
||||||
<meta name="description" content="Ban Yaro — Die kostenlose Hunde-App für Deutschland. Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting. DSGVO-konform, ohne App Store.">
|
<meta name="description" content="Ban Yaro — Die kostenlose Hunde-App für Deutschland. Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting. DSGVO-konform, ohne App Store.">
|
||||||
<meta name="keywords" content="Hunde App, Hunde Tagebuch, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundesitting, Hunde Wiki, Hunderassen, PWA Hunde, DSGVO Hunde App">
|
<meta name="keywords" content="Hunde App, Hunde Tagebuch, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundesitting, Hunde Wiki, Hunderassen, PWA Hunde, DSGVO Hunde App">
|
||||||
<link rel="canonical" href="https://banyaro.app/">
|
<link rel="canonical" href="https://banyaro.app/">
|
||||||
|
|
@ -67,6 +67,9 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Eigenes Farbschema — verhindert Browser-eigenen Dark-Mode-Filter -->
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- Favicons -->
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
|
||||||
|
|
@ -82,20 +85,25 @@
|
||||||
|
|
||||||
<title>Ban Yaro</title>
|
<title>Ban Yaro</title>
|
||||||
|
|
||||||
<!-- Theme vor CSS setzen — verhindert Flash of unstyled content -->
|
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var t = localStorage.getItem('by_theme');
|
var t = localStorage.getItem('by_theme');
|
||||||
|
var isDark = t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
var isAndroid = /android/i.test(navigator.userAgent);
|
||||||
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
|
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||||
// 'system' (oder kein Wert) → kein data-theme → @media prefers-color-scheme greift
|
// Android: immer dunkel (Amber-Streifen nicht möglich transparent zu machen)
|
||||||
|
// iOS: black-translucent übernimmt das
|
||||||
|
var m = document.getElementById('meta-theme-color');
|
||||||
|
if (m) m.setAttribute('content', (isDark || isAndroid) ? '#0f1623' : '#C4843A');
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css?v=709">
|
<link rel="stylesheet" href="/css/design-system.css?v=819">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=709">
|
<link rel="stylesheet" href="/css/layout.css?v=819">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=738">
|
<link rel="stylesheet" href="/css/components.css?v=819">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -575,10 +583,10 @@
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
|
|
||||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||||
<script src="/js/api.js?v=785"></script>
|
<script src="/js/api.js?v=819"></script>
|
||||||
<script src="/js/ui.js?v=785"></script>
|
<script src="/js/ui.js?v=819"></script>
|
||||||
<script src="/js/app.js?v=785"></script>
|
<script src="/js/app.js?v=819"></script>
|
||||||
<script src="/js/worlds.js?v=785"></script>
|
<script src="/js/worlds.js?v=819"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
@ -625,20 +633,36 @@
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
|
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
|
||||||
.then(reg => {
|
.then(reg => {
|
||||||
// iOS PWA: Update sofort prüfen (Standalone-Modus prüft sonst nicht automatisch)
|
function _watchSW(sw) {
|
||||||
|
if (!sw) return;
|
||||||
|
sw.addEventListener('statechange', () => {
|
||||||
|
if (sw.state === 'activated') {
|
||||||
|
// Kein zweiter Reload nach force-update
|
||||||
|
if (sessionStorage.getItem('by_skip_sw_reload')) {
|
||||||
|
sessionStorage.removeItem('by_skip_sw_reload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.replace('/?_t=' + Date.now());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Listener VOR update() registrieren — verhindert Race Condition
|
||||||
|
reg.addEventListener('updatefound', () => _watchSW(reg.installing));
|
||||||
|
// Falls SW bereits installiert (Seite wurde nach SW-Install neu geladen)
|
||||||
|
if (reg.installing) _watchSW(reg.installing);
|
||||||
reg.update();
|
reg.update();
|
||||||
})
|
})
|
||||||
.catch(err => console.warn('SW Registration failed:', err));
|
.catch(err => console.warn('SW Registration failed:', err));
|
||||||
});
|
});
|
||||||
|
|
||||||
// iOS PWA: erneut prüfen wenn App aus dem Hintergrund kommt
|
// Backup: erneut prüfen wenn App aus dem Hintergrund kommt
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
navigator.serviceWorker.getRegistration().then(reg => reg?.update());
|
navigator.serviceWorker.getRegistration().then(reg => reg?.update());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wenn neuer SW die Kontrolle übernimmt → Seite neu laden
|
// Backup: controllerchange (falls updatefound nicht feuert)
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
window.location.replace('/?_t=' + Date.now());
|
window.location.replace('/?_t=' + Date.now());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,13 @@ const API = (() => {
|
||||||
throw new APIError(msg, 0, 'network');
|
throw new APIError(msg, 0, 'network');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Versions-Check: Server meldet neue Version → Banner anzeigen (einmalig)
|
||||||
|
const serverVer = response.headers.get('x-app-version');
|
||||||
|
if (serverVer && serverVer !== APP_VER && !window._byUpdatePending) {
|
||||||
|
window._byUpdatePending = true;
|
||||||
|
window._byNewVersion = serverVer;
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 204) return null;
|
if (response.status === 204) return null;
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '785'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '819'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
||||||
|
|
@ -119,6 +119,18 @@ const App = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function navigate(pageId, pushHistory = true, params = {}) {
|
function navigate(pageId, pushHistory = true, params = {}) {
|
||||||
if (!pages[pageId]) return;
|
if (!pages[pageId]) return;
|
||||||
|
// Neue Version erkannt → nur aktualisieren wenn kein Bearbeitungsfenster offen ist
|
||||||
|
if (window._byUpdatePending) {
|
||||||
|
const modalOpen = document.querySelector('#modal-container .modal-overlay') !== null;
|
||||||
|
if (!modalOpen) {
|
||||||
|
window._byUpdatePending = false;
|
||||||
|
sessionStorage.setItem('by_updated_to', window._byNewVersion || '');
|
||||||
|
sessionStorage.setItem('by_update_target', pageId); // Zielseite nach Update
|
||||||
|
location.href = '/force-update';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Modal offen → beim nächsten Seitenwechsel versuchen
|
||||||
|
}
|
||||||
if (window.Worlds?._visible) window.Worlds.hide();
|
if (window.Worlds?._visible) window.Worlds.hide();
|
||||||
|
|
||||||
// Aktive Seite ausblenden
|
// Aktive Seite ausblenden
|
||||||
|
|
@ -536,6 +548,7 @@ const App = (() => {
|
||||||
if (window.Worlds) window.Worlds.init(state);
|
if (window.Worlds) window.Worlds.init(state);
|
||||||
|
|
||||||
_showVerifyBanner();
|
_showVerifyBanner();
|
||||||
|
_showAndroidBetaBanner();
|
||||||
_updateNotifBadge();
|
_updateNotifBadge();
|
||||||
_updateChatBadge();
|
_updateChatBadge();
|
||||||
_checkNearbyAlerts();
|
_checkNearbyAlerts();
|
||||||
|
|
@ -612,11 +625,34 @@ const App = (() => {
|
||||||
|
|
||||||
function _applyUserTheme(user) {
|
function _applyUserTheme(user) {
|
||||||
const theme = user?.preferred_theme;
|
const theme = user?.preferred_theme;
|
||||||
if (!theme || theme === 'system') return; // System-Einstellung: nichts tun
|
if (!theme || theme === 'system') { _syncThemeColor(); return; }
|
||||||
localStorage.setItem('by_theme', theme);
|
localStorage.setItem('by_theme', theme);
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
if (theme === 'dark') html.setAttribute('data-theme', 'dark');
|
if (theme === 'dark') html.setAttribute('data-theme', 'dark');
|
||||||
else if (theme === 'light') html.setAttribute('data-theme', 'light');
|
else if (theme === 'light') html.setAttribute('data-theme', 'light');
|
||||||
|
_syncThemeColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _syncThemeColor() {
|
||||||
|
const isAndroid = /android/i.test(navigator.userAgent);
|
||||||
|
const isDark = isAndroid
|
||||||
|
|| document.documentElement.getAttribute('data-theme') === 'dark'
|
||||||
|
|| (window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
&& document.documentElement.getAttribute('data-theme') !== 'light');
|
||||||
|
document.getElementById('meta-theme-color')?.setAttribute('content', isDark ? '#0f1623' : '#C4843A');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showAndroidBetaBanner() {
|
||||||
|
// Nur auf Android, nur einmalig, nur für eingeloggte Nutzer
|
||||||
|
if (!/android/i.test(navigator.userAgent)) return;
|
||||||
|
if (localStorage.getItem('by_android_beta_dismissed')) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
UI.toast.info(
|
||||||
|
'📱 Play Store Beta: Hilf uns beim Android-Test! Schreib an <a href="mailto:support@banyaro.app" style="color:#fff;font-weight:700;text-decoration:underline">support@banyaro.app</a>',
|
||||||
|
20000
|
||||||
|
);
|
||||||
|
localStorage.setItem('by_android_beta_dismissed', '1');
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showVerifyBanner() {
|
function _showVerifyBanner() {
|
||||||
|
|
@ -857,6 +893,7 @@ const App = (() => {
|
||||||
// INITIALISIERUNG
|
// INITIALISIERUNG
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function init() {
|
async function init() {
|
||||||
|
_syncThemeColor(); // Statusleisten-Farbe sofort setzen
|
||||||
// Spezielle Hash-Parameter → in App bleiben (kein /info-Redirect)
|
// Spezielle Hash-Parameter → in App bleiben (kein /info-Redirect)
|
||||||
const _rawHash = location.hash.replace('#', '');
|
const _rawHash = location.hash.replace('#', '');
|
||||||
const _hashQuery = _rawHash.split('?')[1] || '';
|
const _hashQuery = _rawHash.split('?')[1] || '';
|
||||||
|
|
@ -876,6 +913,18 @@ const App = (() => {
|
||||||
|
|
||||||
_bindNavigation();
|
_bindNavigation();
|
||||||
|
|
||||||
|
// Nach stillem Update: Toast + zur ursprünglichen Zielseite navigieren
|
||||||
|
const updatedTo = sessionStorage.getItem('by_updated_to');
|
||||||
|
if (updatedTo) {
|
||||||
|
sessionStorage.removeItem('by_updated_to');
|
||||||
|
const target = sessionStorage.getItem('by_update_target');
|
||||||
|
sessionStorage.removeItem('by_update_target');
|
||||||
|
setTimeout(() => {
|
||||||
|
UI.toast?.success(`App auf v${updatedTo} aktualisiert`);
|
||||||
|
if (target && pages[target]) navigate(target, false);
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
try { localStorage.removeItem('by_wissen_open'); } catch (_) {}
|
try { localStorage.removeItem('by_wissen_open'); } catch (_) {}
|
||||||
|
|
||||||
_initVersionCheck();
|
_initVersionCheck();
|
||||||
|
|
@ -977,122 +1026,8 @@ const App = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// ----------------------------------------------------------
|
// VERSION-CHECK — stilles Auto-Update beim nächsten Seitenwechsel
|
||||||
// VERSION-CHECK — persistentes Banner wenn neue Version verfügbar
|
function _initVersionCheck() { /* X-App-Version Header in api.js übernimmt das */ }
|
||||||
// ----------------------------------------------------------
|
|
||||||
let _updateBannerShown = false;
|
|
||||||
|
|
||||||
async function _checkVersion() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/version', { cache: 'no-store' });
|
|
||||||
if (!r.ok) return;
|
|
||||||
const { version } = await r.json();
|
|
||||||
if (version && version !== APP_VER && !_updateBannerShown) {
|
|
||||||
_updateBannerShown = true;
|
|
||||||
_showUpdateBanner(version);
|
|
||||||
}
|
|
||||||
} catch { /* offline — ignorieren */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showUpdateBanner(newVersion) {
|
|
||||||
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
||||||
const existing = document.getElementById('app-update-banner');
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
const banner = document.createElement('div');
|
|
||||||
banner.id = 'app-update-banner';
|
|
||||||
banner.style.cssText = [
|
|
||||||
'position:fixed;bottom:calc(env(safe-area-inset-bottom,0px) + 72px);left:12px;right:12px',
|
|
||||||
'z-index:9000;background:var(--c-primary);color:#fff;border-radius:16px',
|
|
||||||
'padding:14px 16px;box-shadow:0 4px 20px rgba(0,0,0,0.3)',
|
|
||||||
'display:flex;flex-direction:column;gap:10px',
|
|
||||||
].join(';');
|
|
||||||
|
|
||||||
banner.innerHTML = `
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
|
|
||||||
<div>
|
|
||||||
<div style="font-weight:700;font-size:var(--text-sm)">
|
|
||||||
Neue Version verfügbar (v${newVersion})
|
|
||||||
</div>
|
|
||||||
<div style="font-size:var(--text-xs);opacity:0.85;margin-top:2px">
|
|
||||||
Tippe auf Aktualisieren um die neueste Version zu laden.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
|
||||||
<button id="upd-btn-reload"
|
|
||||||
style="background:rgba(255,255,255,0.2);border:1px solid rgba(255,255,255,0.4);
|
|
||||||
color:#fff;border-radius:10px;padding:8px 14px;cursor:pointer;
|
|
||||||
font-size:var(--text-sm);font-weight:700;white-space:nowrap">
|
|
||||||
Aktualisieren
|
|
||||||
</button>
|
|
||||||
<button id="upd-btn-close"
|
|
||||||
style="background:none;border:none;color:rgba(255,255,255,0.7);
|
|
||||||
cursor:pointer;font-size:1.1rem;padding:4px 6px;line-height:1">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="upd-ios-hint" style="display:none;font-size:var(--text-xs);
|
|
||||||
background:rgba(0,0,0,0.2);border-radius:10px;padding:10px 12px;line-height:1.6">
|
|
||||||
${isIos
|
|
||||||
? `Falls die App nach dem Aktualisieren noch die alte Version zeigt:<br>
|
|
||||||
<strong>1.</strong> Drücke lange auf das App-Icon am Homescreen<br>
|
|
||||||
<strong>2.</strong> Wähle „App entfernen" (nur das Symbol, keine Daten)<br>
|
|
||||||
<strong>3.</strong> Öffne banyaro.app in Safari und füge die App erneut hinzu`
|
|
||||||
: `Falls die Seite noch die alte Version zeigt:<br>
|
|
||||||
Drücke <strong>Cmd+Shift+R</strong> (Mac) bzw. <strong>Ctrl+Shift+R</strong> (Windows/Android Chrome) für einen harten Reload.`}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(banner);
|
|
||||||
|
|
||||||
banner.querySelector('#upd-btn-close').addEventListener('click', () => banner.remove());
|
|
||||||
|
|
||||||
banner.querySelector('#upd-btn-reload').addEventListener('click', async () => {
|
|
||||||
const btn = banner.querySelector('#upd-btn-reload');
|
|
||||||
btn.textContent = 'Lädt…';
|
|
||||||
btn.disabled = true;
|
|
||||||
sessionStorage.setItem('by_update_reload', APP_VER);
|
|
||||||
// ?_t= Timestamp zwingt iOS bfcache zur Aufgabe — wird beim Start sofort entfernt
|
|
||||||
setTimeout(() => location.replace('/?_t=' + Date.now()), 800);
|
|
||||||
try {
|
|
||||||
const reg = await navigator.serviceWorker?.getRegistration();
|
|
||||||
if (reg?.waiting) reg.waiting.postMessage({ type: 'SKIP_WAITING' });
|
|
||||||
reg?.update().catch(() => {}); // kein await — kann hängen
|
|
||||||
const keys = await caches.keys();
|
|
||||||
await Promise.all(keys.map(k => caches.delete(k)));
|
|
||||||
} catch { /* ignorieren */ }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _initVersionCheck() {
|
|
||||||
// Beim Start nach 10 Sekunden prüfen (nicht sofort — Prio für Auth)
|
|
||||||
setTimeout(_checkVersion, 10_000);
|
|
||||||
// Dann alle 30 Minuten
|
|
||||||
setInterval(_checkVersion, 30 * 60_000);
|
|
||||||
// Beim Wiedereinstieg in die App
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.visibilityState === 'visible') _checkVersion();
|
|
||||||
});
|
|
||||||
// Nach Reload: war das ein Update-Reload? Falls Version immer noch alt → iOS-Hinweis
|
|
||||||
const reloadVer = sessionStorage.getItem('by_update_reload');
|
|
||||||
if (reloadVer && reloadVer === APP_VER) {
|
|
||||||
// Version hat sich nicht geändert nach Reload → iOS-Cache-Problem
|
|
||||||
sessionStorage.removeItem('by_update_reload');
|
|
||||||
setTimeout(() => {
|
|
||||||
fetch('/api/version', { cache: 'no-store' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(({ version }) => {
|
|
||||||
if (version && version !== APP_VER) {
|
|
||||||
_updateBannerShown = true;
|
|
||||||
_showUpdateBanner(version);
|
|
||||||
// iOS-Hinweis sofort aufklappen
|
|
||||||
setTimeout(() => {
|
|
||||||
document.getElementById('upd-ios-hint')?.style.setProperty('display', 'block');
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// ÖFFENTLICHE API
|
// ÖFFENTLICHE API
|
||||||
|
|
|
||||||
|
|
@ -2312,11 +2312,14 @@ window.Page_health = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// KI-GESUNDHEITSBERICHTE (gespeicherte automatische Berichte)
|
// KI-GESUNDHEITSBERICHTE (gespeicherte automatische Berichte)
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function _loadKiBerichte(dogId) {
|
async function _loadKiBerichte(dogId, force = false) {
|
||||||
const el = _container.querySelector('#health-ki-berichte');
|
const el = _container.querySelector('#health-ki-berichte');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
try {
|
try {
|
||||||
const berichte = await API.health.kiBerichte(dogId);
|
// force=true: Cache-Buster damit SW den neuen Bericht nicht übersieht
|
||||||
|
const berichte = force
|
||||||
|
? await API.get(`/dogs/${dogId}/health/ki-berichte?_t=${Date.now()}`)
|
||||||
|
: await API.health.kiBerichte(dogId);
|
||||||
if (!berichte || berichte.length === 0) return;
|
if (!berichte || berichte.length === 0) return;
|
||||||
const neuester = berichte[0];
|
const neuester = berichte[0];
|
||||||
const datum = neuester.erstellt_at
|
const datum = neuester.erstellt_at
|
||||||
|
|
@ -2343,19 +2346,34 @@ window.Page_health = (() => {
|
||||||
${berichte.length > 1 ? `<div style="font-size:var(--text-xs);color:var(--c-accent,#c4843a);margin-top:var(--space-1)">${berichte.length} Berichte gespeichert — zum Öffnen tippen</div>` : ''}
|
${berichte.length > 1 ? `<div style="font-size:var(--text-xs);color:var(--c-accent,#c4843a);margin-top:var(--space-1)">${berichte.length} Berichte gespeichert — zum Öffnen tippen</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
el.querySelector('.health-ki-bericht-banner').addEventListener('click', () => {
|
el.querySelector('.health-ki-bericht-banner').addEventListener('click', () => {
|
||||||
const listeHtml = berichte.map((b, i) => {
|
let idx = 0;
|
||||||
const d = b.erstellt_at
|
const fmtDate = b => b.erstellt_at
|
||||||
? new Date(b.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
? new Date(b.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
: '';
|
: '';
|
||||||
return `<div style="${i > 0 ? 'border-top:1px solid var(--c-border);padding-top:var(--space-3);margin-top:var(--space-3)' : ''}">
|
|
||||||
${d ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1)">${d}</div>` : ''}
|
function showBericht() {
|
||||||
<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(b.bericht)}</div>
|
const b = berichte[idx];
|
||||||
</div>`;
|
const nav = berichte.length > 1 ? `
|
||||||
}).join('');
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||||
|
<button onclick="window._kiPrev()" style="padding:6px 16px;border-radius:999px;
|
||||||
|
border:1.5px solid var(--c-border);background:var(--c-surface);cursor:pointer;
|
||||||
|
font-size:var(--text-sm);${idx >= berichte.length-1 ? 'opacity:.3;pointer-events:none' : ''}">‹ Älter</button>
|
||||||
|
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${idx+1} / ${berichte.length}</span>
|
||||||
|
<button onclick="window._kiNext()" style="padding:6px 16px;border-radius:999px;
|
||||||
|
border:1.5px solid var(--c-border);background:var(--c-surface);cursor:pointer;
|
||||||
|
font-size:var(--text-sm);${idx <= 0 ? 'opacity:.3;pointer-events:none' : ''}">Neuer ›</button>
|
||||||
|
</div>` : '';
|
||||||
UI.modal.open({
|
UI.modal.open({
|
||||||
title: `${UI.icon('star')} KI-Gesundheitsberichte`,
|
title: `${UI.icon('star')} KI-Gesundheitsberichte`,
|
||||||
body: listeHtml,
|
body: `${nav}
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin-bottom:8px">${fmtDate(b)}</div>
|
||||||
|
<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(b.bericht)}</div>`,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window._kiPrev = () => { if (idx < berichte.length - 1) { idx++; showBericht(); } };
|
||||||
|
window._kiNext = () => { if (idx > 0) { idx--; showBericht(); } };
|
||||||
|
showBericht();
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Silently ignore — Berichte sind optional
|
// Silently ignore — Berichte sind optional
|
||||||
|
|
@ -2790,11 +2808,16 @@ window.Page_health = (() => {
|
||||||
UI.setLoading(btn, true);
|
UI.setLoading(btn, true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { zusammenfassung } = await API.health.kiZusammenfassung(_appState.activeDog.id);
|
const res = await API.health.kiZusammenfassung(_appState.activeDog.id);
|
||||||
|
const zusammenfassung = res.zusammenfassung ?? res;
|
||||||
|
if (res.save_error) UI.toast.warning(`Speichern fehlgeschlagen: ${res.save_error}`);
|
||||||
|
else if (res.saved_count !== undefined) UI.toast.info(`${res.saved_count} Bericht(e) in DB`, { duration: 8000 });
|
||||||
UI.modal.open({
|
UI.modal.open({
|
||||||
title: `${UI.icon('star')} KI-Gesundheitsbericht`,
|
title: `${UI.icon('star')} KI-Gesundheitsbericht`,
|
||||||
body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`,
|
body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`,
|
||||||
});
|
});
|
||||||
|
// Berichte-Liste nach Generierung frisch laden (Cache-Buster)
|
||||||
|
_loadKiBerichte(_appState.activeDog.id, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 503) {
|
if (err.status === 503) {
|
||||||
UI.toast.error('KI ist momentan nicht verfügbar. Bitte später erneut versuchen.');
|
UI.toast.error('KI ist momentan nicht verfügbar. Bitte später erneut versuchen.');
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,15 @@ window.Page_settings = (() => {
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
|
||||||
Abmelden
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
|
<button id="settings-delete-account-btn"
|
||||||
|
style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;
|
||||||
|
gap:var(--space-2);padding:var(--space-2) var(--space-4);
|
||||||
|
border-radius:var(--radius-md);border:none;
|
||||||
|
background:none;color:var(--c-text-muted);
|
||||||
|
font-size:var(--text-xs);cursor:pointer">
|
||||||
|
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||||||
|
Konto löschen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -320,6 +329,15 @@ window.Page_settings = (() => {
|
||||||
<option value="dark" ${(u.preferred_theme||localStorage.getItem('by_theme')) === 'dark' ? 'selected' : ''}>Dunkel</option>
|
<option value="dark" ${(u.preferred_theme||localStorage.getItem('by_theme')) === 'dark' ? 'selected' : ''}>Dunkel</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
${/SamsungBrowser/i.test(navigator.userAgent) ? `
|
||||||
|
<div style="margin:6px 0 4px;padding:10px 12px;border-radius:var(--radius-md);
|
||||||
|
background:var(--c-warning-subtle,rgba(245,158,11,0.12));
|
||||||
|
border:1px solid rgba(245,158,11,0.3);
|
||||||
|
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
|
||||||
|
<strong style="color:var(--c-warning,#f59e0b)">Samsung Internet Tipps:</strong><br>
|
||||||
|
• Farben: <em>Einstellungen → Webseitenansicht → Dark Mode</em> deaktivieren.<br>
|
||||||
|
• Vollbild: <em>Einstellungen → Display → Navigationsleiste → Wischgesten</em> aktivieren.
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
<!-- KI-Notiz-Assistent -->
|
<!-- KI-Notiz-Assistent -->
|
||||||
<div class="settings-toggle-row">
|
<div class="settings-toggle-row">
|
||||||
|
|
@ -789,6 +807,24 @@ window.Page_settings = (() => {
|
||||||
_render();
|
_render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('settings-delete-account-btn')?.addEventListener('click', async () => {
|
||||||
|
const ok = await UI.modal.confirm({
|
||||||
|
title: 'Konto unwiderruflich löschen?',
|
||||||
|
body: 'Alle deine Daten (Tagebuch, Gesundheit, Training, Fotos) werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||||
|
confirmText: 'Ja, Konto löschen',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await API.del('/profile/account');
|
||||||
|
_appState.user = null; _appState.dogs = []; _appState.activeDog = null;
|
||||||
|
UI.toast.info('Dein Konto wurde gelöscht.');
|
||||||
|
App.navigate('welcome');
|
||||||
|
} catch {
|
||||||
|
UI.toast.error('Konto konnte nicht gelöscht werden. Bitte versuche es erneut.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('settings-install-btn')?.addEventListener('click', () => {
|
document.getElementById('settings-install-btn')?.addEventListener('click', () => {
|
||||||
App.navigate('welcome', true, { install: true });
|
App.navigate('welcome', true, { install: true });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -479,6 +479,7 @@ window.Page_uebungen = (() => {
|
||||||
_render();
|
_render();
|
||||||
_helpHandle = UI.pageInfo(_container, {
|
_helpHandle = UI.pageInfo(_container, {
|
||||||
pageId: 'uebungen',
|
pageId: 'uebungen',
|
||||||
|
defaultClosed: true,
|
||||||
title: 'Übungsbibliothek',
|
title: 'Übungsbibliothek',
|
||||||
icon: 'graduation-cap',
|
icon: 'graduation-cap',
|
||||||
intro: 'Hier findest du alle Übungen für deinen Hund — von Grundkommandos bis zu Tricks und Problemverhalten. Du kannst deinen Trainingsfortschritt für jede Übung festhalten.',
|
intro: 'Hier findest du alle Übungen für deinen Hund — von Grundkommandos bis zu Tricks und Problemverhalten. Du kannst deinen Trainingsfortschritt für jede Übung festhalten.',
|
||||||
|
|
|
||||||
|
|
@ -316,8 +316,8 @@ const UI = (() => {
|
||||||
// Kein automatischer absolut-positionierter Trigger mehr.
|
// Kein automatischer absolut-positionierter Trigger mehr.
|
||||||
// Aufrufer kann openModal() nutzen und den Button selbst platzieren.
|
// Aufrufer kann openModal() nutzen und den Button selbst platzieren.
|
||||||
|
|
||||||
// Banner beim ersten Besuch
|
// Banner beim ersten Besuch (nicht wenn defaultClosed gesetzt)
|
||||||
if (!seen) {
|
if (!seen && !config.defaultClosed) {
|
||||||
localStorage.setItem(seenKey, '1');
|
localStorage.setItem(seenKey, '1');
|
||||||
const banner = document.createElement('div');
|
const banner = document.createElement('div');
|
||||||
banner.className = 'pinfo-banner';
|
banner.className = 'pinfo-banner';
|
||||||
|
|
|
||||||
|
|
@ -741,7 +741,7 @@ window.Worlds = (() => {
|
||||||
border-radius:16px;padding:10px 4px 8px;height:80px;box-sizing:border-box;
|
border-radius:16px;padding:10px 4px 8px;height:80px;box-sizing:border-box;
|
||||||
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;
|
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;
|
||||||
cursor:grab;position:relative;min-width:0;overflow:hidden;
|
cursor:grab;position:relative;min-width:0;overflow:hidden;
|
||||||
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none">
|
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:pan-y">
|
||||||
${!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:-10px;right:-10px;width:30px;height:30px;
|
style="position:absolute;top:-10px;right:-10px;width:30px;height:30px;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
"display_override": ["window-controls-overlay", "standalone"],
|
"display_override": ["window-controls-overlay", "standalone"],
|
||||||
"launch_handler": { "client_mode": "focus-existing" },
|
"launch_handler": { "client_mode": "focus-existing" },
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"background_color": "#FAF7F2",
|
"background_color": "#0f1623",
|
||||||
"theme_color": "#C4843A",
|
"theme_color": "#0f1623",
|
||||||
"lang": "de",
|
"lang": "de",
|
||||||
"dir": "ltr",
|
"dir": "ltr",
|
||||||
"categories": [
|
"categories": [
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v785';
|
const CACHE_VERSION = 'by-v819';
|
||||||
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
|
||||||
|
|
@ -120,7 +120,7 @@ const _CACHEABLE_GET = [
|
||||||
/^\/api\/dogs(\/\d+)?$/,
|
/^\/api\/dogs(\/\d+)?$/,
|
||||||
/^\/api\/dogs\/\d+\/welcome-dashboard/,
|
/^\/api\/dogs\/\d+\/welcome-dashboard/,
|
||||||
/^\/api\/dogs\/\d+\/diary/,
|
/^\/api\/dogs\/\d+\/diary/,
|
||||||
/^\/api\/dogs\/\d+\/health/,
|
/^\/api\/dogs\/\d+\/health(?!\/ki-)/, // ki-berichte + ki-zusammenfassung nie cachen
|
||||||
/^\/api\/training\/exercises/,
|
/^\/api\/training\/exercises/,
|
||||||
/^\/api\/training\/progress/,
|
/^\/api\/training\/progress/,
|
||||||
/^\/api\/wiki\/rassen/,
|
/^\/api\/wiki\/rassen/,
|
||||||
|
|
@ -188,6 +188,12 @@ self.addEventListener('activate', event => {
|
||||||
self.addEventListener('fetch', event => {
|
self.addEventListener('fetch', event => {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// Force-Update: SW komplett umgehen → direkt vom Netzwerk
|
||||||
|
if (url.searchParams.has('_nocache')) {
|
||||||
|
event.respondWith(fetch(event.request.url.replace(/[?&]_nocache=[^&]*/,'') || '/', { cache: 'no-store' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// API-Calls mit Timeout, Caching und Write-Queue
|
// API-Calls mit Timeout, Caching und Write-Queue
|
||||||
if (url.pathname.startsWith('/api/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
const method = event.request.method;
|
const method = event.request.method;
|
||||||
|
|
@ -224,6 +230,10 @@ self.addEventListener('fetch', event => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Media-Uploads + KI-Endpoints: direkt ans Netzwerk — kein Clone, kein Timeout, kein Queue
|
||||||
|
// KI-Anfragen ans lokale LLM können mehrere Minuten dauern
|
||||||
|
if (method === 'POST' && (_isMediaUpload(event.request) || url.pathname.startsWith('/api/ki/') || url.pathname.includes('/health/ki-') || url.pathname.includes('/health/symptom'))) return;
|
||||||
|
|
||||||
// Mutationen (POST/PATCH/PUT/DELETE): mit Timeout, bei Offline → Queue
|
// Mutationen (POST/PATCH/PUT/DELETE): mit Timeout, bei Offline → Queue
|
||||||
if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
|
if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
|
||||||
event.respondWith((async () => {
|
event.respondWith((async () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue