Compare commits
No commits in common. "25417364d2b9a67053a5604541c248d866f3d8a7" and "e8cf742911b5370c6433de23aa55602cb550f8f4" have entirely different histories.
25417364d2
...
e8cf742911
18 changed files with 161 additions and 338 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,4 +11,3 @@ __pycache__/
|
|||
# Design-Quell-Dateien (nicht für Server)
|
||||
/icons/
|
||||
.claude/worktrees/
|
||||
Ban Yaro - Google Play package/
|
||||
|
|
|
|||
|
|
@ -1236,16 +1236,11 @@ def _migrate(conn_factory):
|
|||
CREATE TABLE IF NOT EXISTS ki_health_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bericht TEXT NOT NULL,
|
||||
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("""
|
||||
CREATE INDEX IF NOT EXISTS idx_ki_health_reports_dog
|
||||
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:
|
||||
"""Privilegierte Rollen (Admin, Moderator, Züchter, Manager) nutzen Cloud-KI primär."""
|
||||
if not user_id:
|
||||
if not user_id or not ANTHROPIC_KEY:
|
||||
return False
|
||||
try:
|
||||
from database import db
|
||||
|
|
@ -173,10 +173,8 @@ async def complete(
|
|||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Cloud-KI nicht erreichbar für privilegierten User, Fallback lokal: {e}")
|
||||
try:
|
||||
# Fallback auf lokales Modell
|
||||
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")
|
||||
if return_model:
|
||||
return (text, LOCAL_MODEL)
|
||||
|
|
@ -401,7 +399,7 @@ async def health_summary(health_data: list, dog_info: dict,
|
|||
if not subset:
|
||||
return " (keine Einträge)"
|
||||
lines = []
|
||||
for e in subset[:5]: # maximal 5 pro Typ — Kontextfenster schonen
|
||||
for e in subset[:10]: # maximal 10 pro Typ
|
||||
line = f" - {e.get('datum', '?')}: {e.get('bezeichnung', '?')}"
|
||||
if e.get("naechstes"):
|
||||
line += f" (nächste Fälligkeit: {e['naechstes']})"
|
||||
|
|
|
|||
|
|
@ -140,20 +140,6 @@ class _UploadSizeMiddleware(BaseHTTPMiddleware):
|
|||
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):
|
||||
"""Setzt Cache-Control-Header für statische Assets.
|
||||
CSS/JS: no-cache (ETag-Validierung) — iOS cached sonst ewig ohne Ablaufdatum.
|
||||
|
|
@ -341,7 +327,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
|||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
||||
|
||||
APP_VER = "819" # muss mit APP_VER in app.js übereinstimmen
|
||||
APP_VER = "785" # muss mit APP_VER in app.js übereinstimmen
|
||||
|
||||
@app.get("/.well-known/assetlinks.json")
|
||||
async def assetlinks():
|
||||
|
|
@ -1558,84 +1544,6 @@ async def presse():
|
|||
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
|
||||
# ------------------------------------------------------------------
|
||||
@app.get("/partner")
|
||||
|
|
|
|||
|
|
@ -454,21 +454,7 @@ async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)):
|
|||
user_is_premium=bool(user.get("is_premium")),
|
||||
user_id=user["id"],
|
||||
)
|
||||
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}
|
||||
return {"zusammenfassung": result}
|
||||
except KIPremiumRequired as e:
|
||||
raise HTTPException(402, str(e))
|
||||
except KIUnavailableError as e:
|
||||
|
|
|
|||
|
|
@ -232,7 +232,6 @@ async def ki_geburtstag(req: BirthdayRequest, request: Request,
|
|||
try:
|
||||
answer = await ki_module.complete(
|
||||
system=system, prompt=prompt, max_tokens=600, requires_premium=False,
|
||||
user_id=user["id"],
|
||||
)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
|
|
|
|||
|
|
@ -142,28 +142,3 @@ async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
|
|||
conn.execute("UPDATE users SET world_config=? WHERE id=?",
|
||||
(_json.dumps(body.config), user['id']))
|
||||
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,7 +8,6 @@
|
|||
1. TOKENS — Farben, Abstände, Typografie, Schatten
|
||||
------------------------------------------------------------ */
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
/* Primärfarben — Honig-Amber aus Ban Yaros Fell */
|
||||
--c-primary: #C4843A;
|
||||
--c-primary-dark: #9E6520;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0f1623" id="meta-theme-color">
|
||||
<meta name="theme-color" content="#C4843A">
|
||||
<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">
|
||||
<link rel="canonical" href="https://banyaro.app/">
|
||||
|
|
@ -67,9 +67,6 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<!-- Eigenes Farbschema — verhindert Browser-eigenen Dark-Mode-Filter -->
|
||||
<meta name="color-scheme" content="dark light">
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
|
||||
|
|
@ -85,25 +82,20 @@
|
|||
|
||||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<!-- Theme vor CSS setzen — verhindert Flash of unstyled content -->
|
||||
<script>
|
||||
(function() {
|
||||
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 === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
// 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');
|
||||
// 'system' (oder kein Wert) → kein data-theme → @media prefers-color-scheme greift
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=819">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=819">
|
||||
<link rel="stylesheet" href="/css/components.css?v=819">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=709">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=709">
|
||||
<link rel="stylesheet" href="/css/components.css?v=738">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -583,10 +575,10 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=819"></script>
|
||||
<script src="/js/ui.js?v=819"></script>
|
||||
<script src="/js/app.js?v=819"></script>
|
||||
<script src="/js/worlds.js?v=819"></script>
|
||||
<script src="/js/api.js?v=785"></script>
|
||||
<script src="/js/ui.js?v=785"></script>
|
||||
<script src="/js/app.js?v=785"></script>
|
||||
<script src="/js/worlds.js?v=785"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -633,36 +625,20 @@
|
|||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
|
||||
.then(reg => {
|
||||
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);
|
||||
// iOS PWA: Update sofort prüfen (Standalone-Modus prüft sonst nicht automatisch)
|
||||
reg.update();
|
||||
})
|
||||
.catch(err => console.warn('SW Registration failed:', err));
|
||||
});
|
||||
|
||||
// Backup: erneut prüfen wenn App aus dem Hintergrund kommt
|
||||
// iOS PWA: erneut prüfen wenn App aus dem Hintergrund kommt
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
navigator.serviceWorker.getRegistration().then(reg => reg?.update());
|
||||
}
|
||||
});
|
||||
|
||||
// Backup: controllerchange (falls updatefound nicht feuert)
|
||||
// Wenn neuer SW die Kontrolle übernimmt → Seite neu laden
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
window.location.replace('/?_t=' + Date.now());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,13 +45,6 @@ const API = (() => {
|
|||
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;
|
||||
|
||||
let data;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '819'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '785'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
||||
|
|
@ -119,18 +119,6 @@ const App = (() => {
|
|||
// ----------------------------------------------------------
|
||||
function navigate(pageId, pushHistory = true, params = {}) {
|
||||
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();
|
||||
|
||||
// Aktive Seite ausblenden
|
||||
|
|
@ -548,7 +536,6 @@ const App = (() => {
|
|||
if (window.Worlds) window.Worlds.init(state);
|
||||
|
||||
_showVerifyBanner();
|
||||
_showAndroidBetaBanner();
|
||||
_updateNotifBadge();
|
||||
_updateChatBadge();
|
||||
_checkNearbyAlerts();
|
||||
|
|
@ -625,34 +612,11 @@ const App = (() => {
|
|||
|
||||
function _applyUserTheme(user) {
|
||||
const theme = user?.preferred_theme;
|
||||
if (!theme || theme === 'system') { _syncThemeColor(); return; }
|
||||
if (!theme || theme === 'system') return; // System-Einstellung: nichts tun
|
||||
localStorage.setItem('by_theme', theme);
|
||||
const html = document.documentElement;
|
||||
if (theme === 'dark') html.setAttribute('data-theme', 'dark');
|
||||
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() {
|
||||
|
|
@ -893,7 +857,6 @@ const App = (() => {
|
|||
// INITIALISIERUNG
|
||||
// ----------------------------------------------------------
|
||||
async function init() {
|
||||
_syncThemeColor(); // Statusleisten-Farbe sofort setzen
|
||||
// Spezielle Hash-Parameter → in App bleiben (kein /info-Redirect)
|
||||
const _rawHash = location.hash.replace('#', '');
|
||||
const _hashQuery = _rawHash.split('?')[1] || '';
|
||||
|
|
@ -913,18 +876,6 @@ const App = (() => {
|
|||
|
||||
_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 (_) {}
|
||||
|
||||
_initVersionCheck();
|
||||
|
|
@ -1026,8 +977,122 @@ const App = (() => {
|
|||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// VERSION-CHECK — stilles Auto-Update beim nächsten Seitenwechsel
|
||||
function _initVersionCheck() { /* X-App-Version Header in api.js übernimmt das */ }
|
||||
// ----------------------------------------------------------
|
||||
// VERSION-CHECK — persistentes Banner wenn neue Version verfügbar
|
||||
// ----------------------------------------------------------
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2312,14 +2312,11 @@ window.Page_health = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// KI-GESUNDHEITSBERICHTE (gespeicherte automatische Berichte)
|
||||
// ----------------------------------------------------------
|
||||
async function _loadKiBerichte(dogId, force = false) {
|
||||
async function _loadKiBerichte(dogId) {
|
||||
const el = _container.querySelector('#health-ki-berichte');
|
||||
if (!el) return;
|
||||
try {
|
||||
// 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);
|
||||
const berichte = await API.health.kiBerichte(dogId);
|
||||
if (!berichte || berichte.length === 0) return;
|
||||
const neuester = berichte[0];
|
||||
const datum = neuester.erstellt_at
|
||||
|
|
@ -2346,34 +2343,19 @@ 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>` : ''}
|
||||
</div>`;
|
||||
el.querySelector('.health-ki-bericht-banner').addEventListener('click', () => {
|
||||
let idx = 0;
|
||||
const fmtDate = b => b.erstellt_at
|
||||
const listeHtml = berichte.map((b, i) => {
|
||||
const d = b.erstellt_at
|
||||
? new Date(b.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: '';
|
||||
|
||||
function showBericht() {
|
||||
const b = berichte[idx];
|
||||
const nav = berichte.length > 1 ? `
|
||||
<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>` : '';
|
||||
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>` : ''}
|
||||
<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(b.bericht)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('star')} KI-Gesundheitsberichte`,
|
||||
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>`,
|
||||
body: listeHtml,
|
||||
});
|
||||
}
|
||||
|
||||
window._kiPrev = () => { if (idx < berichte.length - 1) { idx++; showBericht(); } };
|
||||
window._kiNext = () => { if (idx > 0) { idx--; showBericht(); } };
|
||||
showBericht();
|
||||
});
|
||||
} catch (_) {
|
||||
// Silently ignore — Berichte sind optional
|
||||
|
|
@ -2808,16 +2790,11 @@ window.Page_health = (() => {
|
|||
UI.setLoading(btn, true);
|
||||
|
||||
try {
|
||||
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 });
|
||||
const { zusammenfassung } = await API.health.kiZusammenfassung(_appState.activeDog.id);
|
||||
UI.modal.open({
|
||||
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>`,
|
||||
});
|
||||
// Berichte-Liste nach Generierung frisch laden (Cache-Buster)
|
||||
_loadKiBerichte(_appState.activeDog.id, true);
|
||||
} catch (err) {
|
||||
if (err.status === 503) {
|
||||
UI.toast.error('KI ist momentan nicht verfügbar. Bitte später erneut versuchen.');
|
||||
|
|
|
|||
|
|
@ -287,15 +287,6 @@ window.Page_settings = (() => {
|
|||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
|
||||
Abmelden
|
||||
</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>
|
||||
|
|
@ -329,15 +320,6 @@ window.Page_settings = (() => {
|
|||
<option value="dark" ${(u.preferred_theme||localStorage.getItem('by_theme')) === 'dark' ? 'selected' : ''}>Dunkel</option>
|
||||
</select>
|
||||
</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 -->
|
||||
<div class="settings-toggle-row">
|
||||
|
|
@ -807,24 +789,6 @@ window.Page_settings = (() => {
|
|||
_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', () => {
|
||||
App.navigate('welcome', true, { install: true });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -479,7 +479,6 @@ window.Page_uebungen = (() => {
|
|||
_render();
|
||||
_helpHandle = UI.pageInfo(_container, {
|
||||
pageId: 'uebungen',
|
||||
defaultClosed: true,
|
||||
title: 'Übungsbibliothek',
|
||||
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.',
|
||||
|
|
|
|||
|
|
@ -316,8 +316,8 @@ const UI = (() => {
|
|||
// Kein automatischer absolut-positionierter Trigger mehr.
|
||||
// Aufrufer kann openModal() nutzen und den Button selbst platzieren.
|
||||
|
||||
// Banner beim ersten Besuch (nicht wenn defaultClosed gesetzt)
|
||||
if (!seen && !config.defaultClosed) {
|
||||
// Banner beim ersten Besuch
|
||||
if (!seen) {
|
||||
localStorage.setItem(seenKey, '1');
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'pinfo-banner';
|
||||
|
|
|
|||
|
|
@ -741,7 +741,7 @@ window.Worlds = (() => {
|
|||
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;
|
||||
cursor:grab;position:relative;min-width:0;overflow:hidden;
|
||||
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:pan-y">
|
||||
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none">
|
||||
${!c.pinned ? `
|
||||
<button class="wc-remove" data-page="${c.page}" data-zone="${w}"
|
||||
style="position:absolute;top:-10px;right:-10px;width:30px;height:30px;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
"launch_handler": { "client_mode": "focus-existing" },
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#0f1623",
|
||||
"theme_color": "#0f1623",
|
||||
"background_color": "#FAF7F2",
|
||||
"theme_color": "#C4843A",
|
||||
"lang": "de",
|
||||
"dir": "ltr",
|
||||
"categories": [
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v819';
|
||||
const CACHE_VERSION = 'by-v785';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||
|
|
@ -120,7 +120,7 @@ const _CACHEABLE_GET = [
|
|||
/^\/api\/dogs(\/\d+)?$/,
|
||||
/^\/api\/dogs\/\d+\/welcome-dashboard/,
|
||||
/^\/api\/dogs\/\d+\/diary/,
|
||||
/^\/api\/dogs\/\d+\/health(?!\/ki-)/, // ki-berichte + ki-zusammenfassung nie cachen
|
||||
/^\/api\/dogs\/\d+\/health/,
|
||||
/^\/api\/training\/exercises/,
|
||||
/^\/api\/training\/progress/,
|
||||
/^\/api\/wiki\/rassen/,
|
||||
|
|
@ -188,12 +188,6 @@ self.addEventListener('activate', event => {
|
|||
self.addEventListener('fetch', event => {
|
||||
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
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
const method = event.request.method;
|
||||
|
|
@ -230,10 +224,6 @@ self.addEventListener('fetch', event => {
|
|||
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
|
||||
if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
|
||||
event.respondWith((async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue