banyaro/backend/static/js/app.js
rene 553e9e7854 Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405
Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations

Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil

Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware

Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
2026-04-25 20:44:46 +02:00

851 lines
33 KiB
JavaScript

/* ============================================================
BAN YARO — App Core
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '385'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
// ----------------------------------------------------------
// PWA INSTALL PROMPT — frühzeitig abfangen, bevor es verloren geht
// ----------------------------------------------------------
let _installPrompt = null;
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
_installPrompt = e;
});
// ----------------------------------------------------------
// STATE — zentraler App-Zustand
// ----------------------------------------------------------
const state = {
user: null, // eingeloggter User (oder null)
dogs: [], // Hunde des Users
activeDog: null, // aktuell gewählter Hund
page: 'diary', // aktive Seite
};
// ----------------------------------------------------------
// SEITENDEFINITIONEN
// Jede Seite: { id, title, load() }
// load() wird beim ersten Aufruf einmalig ausgeführt
// ----------------------------------------------------------
const pages = {
welcome: { title: 'Willkommen', module: null },
onboarding: { title: 'Einrichtung', module: null, requiresAuth: true },
diary: { title: 'Tagebuch', module: null, requiresAuth: true },
health: { title: 'Gesundheit', module: null, requiresAuth: true },
'dog-profile': { title: 'Mein Hund', module: null, requiresAuth: true },
map: { title: 'Karte', module: null },
routes: { title: 'Routen', module: null },
events: { title: 'Events', module: null },
poison: { title: 'Giftköder-Alarm', module: null },
walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true },
sitting: { title: 'Sitting', module: null, requiresAuth: true },
forum: { title: 'Forum', module: null },
wiki: { title: 'Wiki', module: null },
knigge: { title: 'Knigge', module: null },
movies: { title: 'Filme', module: null },
trainingsplaene: { title: 'Trainingspläne', module: null },
uebungen: { title: 'Übungsbibliothek', module: null },
'erste-hilfe': { title: 'Erste Hilfe', module: null },
settings: { title: 'Einstellungen', module: null },
lost: { title: 'Verlorener Hund', module: null },
friends: { title: 'Freunde', module: null, requiresAuth: true },
chat: { title: 'Nachrichten', module: null, requiresAuth: true },
social: { title: 'Social Media', module: null, requiresAuth: true },
admin: { title: 'Admin', module: null, requiresAuth: true },
moderation: { title: 'Moderation', module: null, requiresAuth: true },
impressum: { title: 'Impressum', module: null },
datenschutz: { title: 'Datenschutz', module: null },
widget: { title: 'Widget', module: null, requiresAuth: true },
notifications: { title: 'Aktuelles', module: null, requiresAuth: true },
};
// ----------------------------------------------------------
// AUTH GUARD — Login-Gate Texte pro Seite
// ----------------------------------------------------------
const AUTH_GATE = {
diary: { icon: 'book-open', text: 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Meilensteine, Fotos.' },
health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Medikamente und Allergien deines Hundes immer im Blick.' },
'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund — mit Foto, Bio und NFC-Tag.' },
friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und baue ein Netzwerk auf.' },
chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.' },
walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde in deiner Gegend.' },
sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.' },
};
// ----------------------------------------------------------
// ROUTER
// ----------------------------------------------------------
function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return;
// Aktive Seite ausblenden
document.querySelector('.page.active')?.classList.remove('active');
document.querySelectorAll('.nav-item.active, .sidebar-item.active')
.forEach(el => el.classList.remove('active'));
// Neue Seite einblenden
document.getElementById(`page-${pageId}`)?.classList.add('active');
// Navigation markieren
document.querySelectorAll(`[data-page="${pageId}"]`)
.forEach(el => el.classList.add('active'));
// Header-Titel setzen (nur wenn kein Dog-Switcher aktiv ist)
const titleEl = document.getElementById('header-title');
if (titleEl) titleEl.textContent = pages[pageId].title;
// History
if (pushHistory) {
history.pushState({ page: pageId }, '', `#${pageId}`);
}
state.page = pageId;
UI.scrollTop();
// Seiten-Modul lazy laden (einmalig)
_loadPage(pageId, params);
}
async function _loadPage(pageId, params = {}) {
const page = pages[pageId];
// AUTH GUARD — geschützte Seiten für nicht-eingeloggte User
if (page.requiresAuth && !state.user) {
const container = document.querySelector(`#page-${pageId} .page-body`);
if (container) _renderLoginGate(container, pageId);
return;
}
if (page.module) {
const hasParams = params && Object.keys(params).length > 0;
if (hasParams) {
// Re-init mit neuen Params (z.B. Chat mit bestimmter Konversation)
const container = document.querySelector(`#page-${pageId} .page-body`);
page.module.init?.(container, state, params);
} else {
page.module.refresh?.();
}
return;
}
// Guard: verhindert doppelten Load bei gleichzeitigen navigate()-Aufrufen
if (page._loading) return;
page._loading = true;
const container = document.querySelector(`#page-${pageId} .page-body`);
if (!container) { page._loading = false; return; }
// Skeleton während Laden
container.innerHTML = UI.skeleton(4);
try {
// Seiten-Script dynamisch laden
await _loadScript(`/js/pages/${pageId}.js`);
const mod = window[`Page_${pageId.replace(/-/g, '_')}`];
if (mod?.init) {
await mod.init(container, state, params);
page.module = mod;
} else {
// Platzhalter wenn Seite noch nicht gebaut
container.innerHTML = UI.emptyState({
icon: '🚧',
title: pages[pageId].title,
text: 'Diese Seite ist noch in Entwicklung.',
});
page.module = {}; // verhindert erneutes Laden
}
} catch {
container.innerHTML = UI.emptyState({
icon: '🚧',
title: pages[pageId].title,
text: 'Diese Seite ist noch in Entwicklung.',
});
page.module = {};
} finally {
page._loading = false;
}
}
// ----------------------------------------------------------
// LOGIN GATE — wird statt Seiteninhalt angezeigt
// ----------------------------------------------------------
function _renderLoginGate(container, pageId) {
const gate = AUTH_GATE[pageId] || { icon: 'lock', text: 'Dieser Bereich ist nur für angemeldete Nutzer.' };
const title = pages[pageId]?.title || 'Dieser Bereich';
container.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
min-height:60vh;padding:var(--space-8) var(--space-5);text-align:center;gap:var(--space-5)">
<!-- Icon -->
<div style="width:72px;height:72px;border-radius:50%;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:36px;height:36px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${UI.escape(gate.icon)}"></use>
</svg>
</div>
<!-- Text -->
<div style="max-width:300px">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);
color:var(--c-text);margin:0 0 var(--space-2)">
${UI.escape(title)}
</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
line-height:1.6;margin:0">
${UI.escape(gate.text)}
</p>
</div>
<!-- CTAs -->
<div style="display:flex;flex-direction:column;gap:var(--space-3);width:100%;max-width:280px">
<button class="btn btn-primary" id="gate-login-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
Anmelden
</button>
<button class="btn btn-secondary" id="gate-register-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Kostenlos registrieren
</button>
</div>
<!-- Hinweis was sonst frei ist -->
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Karte, Wiki, Übungen, Erste Hilfe und mehr sind ohne Account zugänglich.
</p>
<!-- Install-Hinweis -->
<button id="gate-install-hint"
style="background:none;border:none;cursor:pointer;padding:0;
font-size:var(--text-xs);color:var(--c-primary);
display:flex;align-items:center;gap:4px;text-decoration:underline">
<svg style="width:13px;height:13px" aria-hidden="true">
<use href="/icons/phosphor.svg#download-simple"></use>
</svg>
Ban Yaro als App installieren
</button>
</div>
`;
container.querySelector('#gate-login-btn')?.addEventListener('click', () => {
navigate('settings');
});
container.querySelector('#gate-register-btn')?.addEventListener('click', () => {
navigate('settings');
});
container.querySelector('#gate-install-hint')?.addEventListener('click', () => {
navigate('welcome');
});
}
function _loadScript(src) {
// Versionierter URL damit SW/Browser-Cache bei jedem Deploy invalidiert wird
const versioned = `${src}?v=${APP_VER}`;
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${versioned}"]`)) { resolve(); return; }
const s = document.createElement('script');
s.src = versioned;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
// ----------------------------------------------------------
// NAVIGATION EVENTS
// ----------------------------------------------------------
function _bindNavigation() {
// Bottom Nav + Sidebar Klicks
document.addEventListener('click', e => {
const item = e.target.closest('[data-page]');
if (item) {
navigate(item.dataset.page);
if (item.closest('#sidebar')) _closeSidebar();
return;
}
// Header-User-Button → Settings
if (e.target.closest('#header-user-btn')) {
navigate('settings');
return;
}
// Sidebar-Logo → Willkommensseite (oder Hund-Profil wenn Hund aktiv)
// Hinweis: dog-sw-title hat eigenen Listener; dieser Fallback greift nur
// wenn kein Hund aktiv ist (statischer "Ban Yaro"-Text ohne dog-sw-title).
if (e.target.closest('.sidebar-logo-text') && !e.target.closest('.dog-sw-title')) {
navigate('welcome');
_closeSidebar();
return;
}
// Sidebar-User (kein data-page, damit keine Aktiv-Markierung)
if (e.target.closest('#sidebar-user')) {
navigate('settings');
_closeSidebar();
return;
}
// + Button (Mobile Bottom-Nav + Desktop Sidebar)
if (e.target.closest('#nav-add') || e.target.closest('#sidebar-add')) {
_closeSidebar();
_showQuickAdd();
return;
}
// Hamburger-Menü (Mobile)
if (e.target.closest('#header-menu-btn')) {
_toggleSidebar();
return;
}
// Backdrop → Sidebar schließen
if (e.target.closest('#sidebar-backdrop')) {
_closeSidebar();
return;
}
// Sidebar-Item auf Mobile → schließen nach Navigation
if (e.target.closest('#sidebar .sidebar-item')) {
_closeSidebar();
}
});
// Browser Back/Forward
window.addEventListener('popstate', e => {
_closeSidebar();
const page = e.state?.page || 'diary';
navigate(page, false);
});
// Hash-Navigation wird in init() nach _checkAuth() behandelt (nicht hier),
// damit kein doppelter _loadPage()-Aufruf entsteht.
}
// ----------------------------------------------------------
// MOBILE SIDEBAR DRAWER
// ----------------------------------------------------------
function _toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const backdrop = document.getElementById('sidebar-backdrop');
const isOpen = sidebar?.classList.contains('open');
isOpen ? _closeSidebar() : _openSidebar();
}
function _openSidebar() {
document.getElementById('sidebar')?.classList.add('open');
document.getElementById('sidebar-backdrop')?.classList.add('visible');
}
function _closeSidebar() {
document.getElementById('sidebar')?.classList.remove('open');
document.getElementById('sidebar-backdrop')?.classList.remove('visible');
}
// ----------------------------------------------------------
// SCHNELL-HINZUFÜGEN (+ Button)
// ----------------------------------------------------------
function _showQuickAdd() {
const loggedIn = !!state.user;
const authBtn = (quick, cls, icon, label) => loggedIn
? `<button class="btn ${cls} w-full" data-quick="${quick}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${icon}"></use></svg> ${label}
</button>`
: `<button class="btn ${cls} w-full" style="opacity:0.5" data-quick="auth-${quick}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#lock"></use></svg> ${label}
</button>`;
UI.modal.open({
title: 'Schnellmeldung',
body: `
<div class="flex flex-col gap-3">
${authBtn('diary', 'btn-primary', 'book-open', 'Tagebucheintrag schreiben')}
<button class="btn btn-danger w-full" data-quick="poison">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder melden
</button>
${authBtn('walk', 'btn-secondary', 'paw-print', 'Gassi-Treffen erstellen')}
${authBtn('lost', 'btn-secondary', 'magnifying-glass','Verlorener Hund melden')}
</div>
${!loggedIn ? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin-top:var(--space-3)">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
Einige Funktionen erfordern einen Account.
</p>` : ''}
`,
});
// Quick-Add Aktionen
document.querySelector('#modal-container').addEventListener('click', e => {
const btn = e.target.closest('[data-quick]');
if (!btn) return;
const action = btn.dataset.quick;
UI.modal.close();
// Kurzes Delay wegen iOS Ghost-Click: nach modal.close() feuert iOS
// ~300ms später ein synthetisches Click-Event an derselben Position.
// Ohne Delay trifft es das neu geöffnete Modal und schließt es sofort.
setTimeout(() => {
if (action.startsWith('auth-')) { navigate('settings'); return; }
if (action === 'diary') { navigate('diary'); setTimeout(() => pages['diary'].module?.openNew?.(), 400); }
if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); }
if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); }
if (action === 'lost') { navigate('lost'); setTimeout(() => pages['lost'].module?.openNew?.(), 400); }
}, 350);
}, { once: true });
}
// ----------------------------------------------------------
// AUTH
// ----------------------------------------------------------
async function _checkAuth() {
try {
const user = await API.auth.me();
state.user = user;
await _onLoggedIn();
} catch {
_onLoggedOut();
}
}
async function _onLoggedIn() {
document.getElementById('sidebar-username').textContent = state.user.name;
document.getElementById('header-login-btn')?.remove();
_updateHeaderUserBtn(true);
// Admin/Moderator-Item einblenden
const adminItem = document.getElementById('sidebar-admin');
if (adminItem) {
const isMod = state.user.rolle === 'admin' || state.user.rolle === 'moderator'
|| state.user.is_moderator;
adminItem.style.display = isMod ? '' : 'none';
}
const moderationItem = document.getElementById('sidebar-moderation');
if (moderationItem) {
const isMod = state.user.rolle === 'admin' || state.user.rolle === 'moderator'
|| state.user.is_moderator;
moderationItem.style.display = isMod ? '' : 'none';
}
const socialItem = document.getElementById('sidebar-social');
if (socialItem) {
const isSocial = state.user.is_social_media || state.user.rolle === 'admin';
socialItem.style.display = isSocial ? '' : 'none';
}
await _loadDogs();
// Eingeloggter User ohne Hund → Onboarding-Wizard (einmalig)
if (state.dogs.length === 0 && !localStorage.getItem('by_onboarding_done')) {
navigate('onboarding');
}
_updateNotifBadge();
_updateChatBadge();
_checkNearbyAlerts();
setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000);
setInterval(_checkNearbyAlerts, 5 * 60_000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
_updateNotifBadge();
_updateChatBadge();
_checkNearbyAlerts();
if (state.page === 'chat') {
pages['chat']?.module?.refresh?.();
}
}
});
const pendingInvite = sessionStorage.getItem('pending_invite');
if (pendingInvite) {
sessionStorage.removeItem('pending_invite');
_handleInvite(pendingInvite);
}
}
async function _checkNearbyAlerts() {
const nav = document.getElementById('bottom-nav');
if (!nav) return;
try {
const pos = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 5000, maximumAge: 120_000 })
);
const { latitude: lat, longitude: lon } = pos.coords;
const data = await API.get(`/alerts?lat=${lat}&lon=${lon}`);
nav.classList.toggle('alert-poison', !!data.poison);
nav.classList.toggle('alert-lost', !data.poison && !!data.lost);
// Burger-Badge: Giftköder/Verlorener Hund in der Nähe
document.getElementById('notif-nav-badge')?.classList.toggle('hidden', !data.poison && !data.lost);
} catch {
// Kein Standort verfügbar — kein Alert anzeigen
}
}
async function _updateNotifBadge() {
if (!state.user) return;
try {
const b = await API.notifications.badge();
document.getElementById('chat-nav-badge')?.classList.toggle('hidden', !b.personal);
} catch { /* ignorieren */ }
}
async function _updateChatBadge() {
if (!state.user) return;
try {
const convs = await API.chat.conversations();
const total = convs.reduce((s, c) => s + (c.unread_count || 0), 0);
const badge = document.getElementById('chat-badge');
if (badge) { badge.textContent = total; badge.style.display = total > 0 ? '' : 'none'; }
} catch { /* ignorieren */ }
}
function _onLoggedOut() {
state.user = null;
state.dogs = [];
state.activeDog = null;
// Gecachte Module geschützter Seiten leeren, damit sie beim nächsten Login
// sauber neu initialisiert werden statt den alten Zustand zu refreshen.
Object.entries(pages).forEach(([, page]) => {
if (page.requiresAuth) page.module = null;
});
_renderDogSwitcher();
_updateHeaderUserBtn(false);
// Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln
if (pages[state.page]?.requiresAuth) {
navigate('map', false);
} else {
// Bleib auf der Seite, zeige aber den Gate-Screen
_loadPage(state.page);
}
}
function _updateHeaderUserBtn(loggedIn) {
const btn = document.getElementById('header-user-btn');
const icon = document.getElementById('header-user-icon');
if (!btn) return;
if (loggedIn) {
const av = state.user?.avatar_url;
if (av) {
btn.innerHTML = `<img src="${av}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
} else {
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px;color:var(--c-primary)">
<use href="/icons/phosphor.svg#user"></use></svg>`;
}
btn.style.borderColor = 'var(--c-primary)';
btn.title = 'Mein Profil';
} else {
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px;color:var(--c-text-muted)">
<use href="/icons/phosphor.svg#user"></use></svg>
<div style="position:absolute;bottom:0;right:0;width:10px;height:10px;border-radius:50%;
background:var(--c-danger);border:2px solid var(--c-surface)"></div>`;
btn.style.borderColor = 'var(--c-border)';
btn.title = 'Anmelden';
}
}
async function _loadDogs() {
try {
state.dogs = await API.dogs.list();
if (state.dogs.length > 0) {
// Zuletzt aktiven Hund aus localStorage wiederherstellen
const savedId = parseInt(localStorage.getItem('by_active_dog') || '0');
state.activeDog = state.dogs.find(d => d.id === savedId) || state.dogs[0];
}
_renderDogSwitcher();
_notifyDogChange();
} catch { /* kein Hund vorhanden */ }
}
// ----------------------------------------------------------
// ONBOARDING — Willkommens-Modal für neue User
// ----------------------------------------------------------
function _showOnboardingModal() {
UI.modal.open({
title: `${UI.icon('paw-print')} Willkommen bei Ban Yaro!`,
body: `
<div style="display:flex;flex-direction:column;align-items:center;
gap:var(--space-4);text-align:center;padding:var(--space-2) 0">
<div style="width:64px;height:64px;border-radius:50%;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#dog"></use>
</svg>
</div>
<div style="max-width:300px">
<p style="margin:0 0 var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary);line-height:1.6">
Schön, dass du dabei bist! Ban Yaro hilft dir, alles rund um
deinen Hund im Blick zu behalten — Spaziergänge, Gesundheit,
Termine und vieles mehr.
</p>
<p style="margin:0;font-size:var(--text-sm);
color:var(--c-text-secondary);line-height:1.6">
Fang jetzt an und leg ein Profil für deinen Hund an.
</p>
</div>
</div>
`,
footer: `
<button type="button" class="btn btn-primary" id="onboarding-start-btn">
${UI.icon('dog')} Meinen ersten Hund anlegen
</button>
<button type="button" class="btn btn-ghost" data-modal-close>Später</button>
`,
});
document.getElementById('onboarding-start-btn')?.addEventListener('click', () => {
UI.modal.close();
navigate('dog-profile');
});
}
function _notifyDogChange() {
Object.values(pages).forEach(p => p.module?.onDogChange?.(state.activeDog));
}
// ----------------------------------------------------------
// HUNDE-SWITCHER (Header Mobile + Sidebar-Logo Desktop)
// ----------------------------------------------------------
function _renderDogSwitcher() {
_renderSwitcherInto(document.getElementById('header-dog-switcher'), 'hdr');
_renderSwitcherInto(document.getElementById('sidebar-dog-switcher'), 'sb');
}
function _renderSwitcherInto(el, ctxId) {
if (!el) return;
const dog = state.activeDog;
const others = state.dogs.filter(d => d.id !== dog?.id);
// Fallback: kein User oder kein Hund → Standardlogo
if (!state.user || !dog) {
if (ctxId === 'sb') {
el.innerHTML = `
<img class="sidebar-logo-img" src="/icons/icon-180.png" alt="Ban Yaro">
<span class="sidebar-logo-text">Ban Yaro</span>`;
} else {
el.innerHTML = `<span class="header-title" id="header-title">Ban Yaro</span>`;
}
return;
}
const avHtml = d => d.foto_url
? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
style="transform:scale(${d.foto_zoom||1}) translate(${d.foto_offset_x||0}%,${d.foto_offset_y||0}%)">`
: `<span>🐕</span>`;
// Inaktive Hunde rechts
let othersHtml = '';
if (others.length === 1) {
othersHtml = `
<div class="dog-sw-others">
<div class="dog-sw-other" data-dog-id="${others[0].id}" title="${UI.escape(others[0].name)}">
${avHtml(others[0])}
</div>
</div>`;
} else if (others.length >= 2) {
const visible = others.slice(0, 3);
const extraCount = others.length - 3;
othersHtml = `
<div class="dog-sw-others">
<div class="dog-sw-stack" id="dog-sw-stack-${ctxId}">
${visible.map((d, i) => `
<div class="dog-sw-other dog-sw-other--${i}" data-dog-id="${d.id}" title="${UI.escape(d.name)}">
${avHtml(d)}
</div>`).join('')}
${extraCount > 0 ? `<div class="dog-sw-more">+${extraCount}</div>` : ''}
</div>
<div class="dog-quickpick hidden" id="dog-qp-${ctxId}">
${others.map(d => `
<div class="dog-qp-item" data-dog-id="${d.id}">
<div class="dog-qp-av">${avHtml(d)}</div>
<span>${UI.escape(d.name)}</span>
</div>`).join('')}
</div>
</div>`;
}
const titleClass = ctxId === 'sb' ? 'sidebar-logo-text' : 'header-title';
el.innerHTML = `
<div class="dog-sw-active" id="dog-sw-active-${ctxId}" title="${UI.escape(dog.name)} bearbeiten"
style="position:relative">
${avHtml(dog)}
${dog.is_guest ? `<span style="position:absolute;bottom:-2px;right:-2px;
background:var(--c-primary);color:#fff;border-radius:var(--radius-full);
font-size:8px;font-weight:700;padding:1px 4px;line-height:1.4;
border:1px solid var(--c-surface)">GAST</span>` : ''}
</div>
<span class="${titleClass} dog-sw-title" style="cursor:pointer" title="${UI.escape(dog.name)} bearbeiten">${UI.escape(dog.name)}</span>
${othersHtml}`;
// Klick aktiver Avatar → Welcome-Seite; Klick Name → Hund-Profil
el.querySelector(`#dog-sw-active-${ctxId}`)?.addEventListener('click', () => {
navigate('welcome');
});
el.querySelector(`.dog-sw-title`)?.addEventListener('click', () => {
navigate('dog-profile');
if (ctxId === 'sb') _closeSidebar();
});
// 1 anderer Hund → direkter Tausch
if (others.length === 1) {
el.querySelector('.dog-sw-other')?.addEventListener('click', () => {
setActiveDog(others[0].id);
});
}
// 2+ andere Hunde → Stack klick öffnet Quickpicker
if (others.length >= 2) {
const stack = el.querySelector(`#dog-sw-stack-${ctxId}`);
const qp = el.querySelector(`#dog-qp-${ctxId}`);
stack?.addEventListener('click', e => {
e.stopPropagation();
// Alle anderen Quickpicker schließen
document.querySelectorAll('.dog-quickpick').forEach(q => {
if (q !== qp) q.classList.add('hidden');
});
qp?.classList.toggle('hidden');
});
el.querySelectorAll('.dog-qp-item').forEach(item => {
item.addEventListener('click', e => {
e.stopPropagation();
setActiveDog(parseInt(item.dataset.dogId));
qp?.classList.add('hidden');
});
});
}
}
// Quickpicker schließen bei Klick außerhalb
document.addEventListener('click', () => {
document.querySelectorAll('.dog-quickpick').forEach(q => q.classList.add('hidden'));
});
function setActiveDog(dogId) {
const dog = state.dogs.find(d => d.id === dogId);
if (!dog || dog.id === state.activeDog?.id) return;
state.activeDog = dog;
localStorage.setItem('by_active_dog', String(dogId));
_renderDogSwitcher();
_notifyDogChange();
}
// ----------------------------------------------------------
// INITIALISIERUNG
// ----------------------------------------------------------
async function init() {
// Referral-Code aus URL ?ref=CODE speichern
const urlParams = new URLSearchParams(window.location.search);
const refCode = urlParams.get('ref');
if (refCode) {
sessionStorage.setItem('by_ref_code', refCode.toUpperCase());
// URL bereinigen ohne Reload
history.replaceState({}, '', window.location.pathname + window.location.hash);
}
_bindNavigation();
try { localStorage.removeItem('by_wissen_open'); } catch (_) {}
await _checkAuth();
// Einladungslink /teilen/{token} → direkt annehmen
const inviteMatch = location.pathname.match(/^\/teilen\/([A-Za-z0-9_-]+)$/);
if (inviteMatch) {
const token = inviteMatch[1];
navigate('diary', false);
_handleInvite(token);
return;
}
// Erste Seite laden: Hash aus URL oder Standard 'diary'.
const rawHash = location.hash.replace('#', '');
const [hashPage, hashQuery] = rawHash.split('?');
const hashParams = {};
if (hashQuery) {
new URLSearchParams(hashQuery).forEach((v, k) => {
hashParams[k] = isNaN(v) ? v : Number(v);
});
}
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome';
navigate(startPage, false, hashParams);
}
async function _handleInvite(token) {
try {
const info = await API.sharing.info(token);
if (info.accepted_at) {
UI.toast.success(`Du hast bereits Zugriff auf ${info.dog_name}.`);
history.replaceState(null, '', '/');
return;
}
if (!state.user) {
sessionStorage.setItem('pending_invite', token);
history.replaceState(null, '', '/');
navigate('settings', false);
UI.toast.info('Bitte melde dich an, um die Einladung anzunehmen.');
return;
}
const ok = await UI.modal.confirm({
message: `<strong>${UI.escape(info.owner_name)}</strong> möchte das Profil von
<strong>${UI.escape(info.dog_name)}</strong> mit dir teilen
(${info.role === 'editor' ? 'Lesen & Schreiben' : 'Nur lesen'}).
Möchtest du die Einladung annehmen?`,
});
if (!ok) { history.replaceState(null, '', '/'); return; }
await API.sharing.accept(token);
state.dogs = await API.dogs.list();
const newDog = state.dogs.find(d => d.name === info.dog_name);
if (newDog) {
state.activeDog = newDog;
localStorage.setItem('by_active_dog', String(newDog.id));
_renderDogSwitcher();
}
history.replaceState(null, '', '/');
UI.toast.success(`${UI.escape(info.dog_name)} wurde deiner Liste hinzugefügt!`);
} catch (e) {
UI.toast.error(e.message || 'Einladungslink ungültig.');
history.replaceState(null, '', '/');
}
}
// ----------------------------------------------------------
// AUTH-GATE HELPER — einheitlicher "Bitte anmelden"-Block
// ----------------------------------------------------------
function requireAuth(container, { icon = 'user', text = 'Melde dich an, um diese Funktion zu nutzen.' } = {}) {
container.innerHTML = UI.emptyState({
icon: UI.icon(icon),
title: 'Anmelden erforderlich',
text,
action: `<button class="btn btn-primary" onclick="App.navigate('settings')">Anmelden</button>`,
});
}
// ----------------------------------------------------------
// ÖFFENTLICHE API
// (andere Module können App.state, App.navigate etc. nutzen)
// ----------------------------------------------------------
function callModule(pageId, method, ...args) {
navigate(pageId);
setTimeout(() => pages[pageId]?.module?.[method]?.(...args), 500);
}
return { init, navigate, callModule, state, setActiveDog,
renderDogSwitcher: _renderDogSwitcher,
getInstallPrompt: () => _installPrompt, requireAuth,
showOnboarding: _showOnboardingModal,
updateNotifBadge: _updateNotifBadge,
checkNearbyAlerts: _checkNearbyAlerts };
})();
// App starten
document.addEventListener('DOMContentLoaded', () => App.init());