/* ============================================================ BAN YARO — Drei Welten Navigation JETZT | HUND | WELT — horizontales Swipe-System ============================================================ */ window.Worlds = (() => { let _state = null; let _cur = 1; // 0=JETZT 1=HUND 2=WELT let _visible = false; let _map = null; let _weltInited = false; let _refreshPending = false; // gesetzt wenn refresh() während !_visible aufgerufen wird let _lastUserId = undefined; let _dogs = []; // gecachte Hundesliste let _dogIdx = 0; // aktuell angezeigter Hund let _hasBgPhoto = false; // Hintergrund-Foto vorhanden? let _setupDone = false; // Swipe/Button-Listener nur einmal registrieren // Touch-Tracking const _t = { x:0, y:0, active:false, vert:null, moved:0 }; // ── OFFLINE-CACHE (localStorage) ──────────────────────────── // Letzten bekannten Zustand speichern → sofort zeigen, dann updaten function _wSave(key, data) { try { localStorage.setItem('w3_' + key, JSON.stringify({ ts: Date.now(), data })); } catch {} } function _wLoad(key) { try { const p = JSON.parse(localStorage.getItem('w3_' + key) || 'null'); return p ? { data: p.data, ageMin: Math.round((Date.now() - p.ts) / 60000) } : null; } catch { return null; } } // API-Aufruf mit Cache-Fallback: sofort Cache, dann Netz async function _cachedGet(cacheKey, path) { const cached = _wLoad(cacheKey); let fresh = null; try { fresh = await API.get(path); _wSave(cacheKey, fresh); } catch {} return { data: fresh ?? cached?.data ?? null, fromCache: !fresh, ageMin: fresh ? 0 : (cached?.ageMin ?? null) }; } // ── PUBLIC ────────────────────────────────────────────────── async function init(appState) { _state = appState; _lastUserId = undefined; // Neurender erzwingen _cur = 1; if (!_setupDone) { _setupDone = true; _setupSwipe(); _setupButtons(); _showSwipeHints(); } _goTo(_cur, false); show(); } function _showSwipeHints() { if (localStorage.getItem('worlds_swipe_seen')) return; localStorage.setItem('worlds_swipe_seen', '1'); const ov = document.getElementById('worlds-overlay'); if (!ov) return; const hint = document.createElement('div'); hint.style.cssText = [ 'position:absolute;inset:0;pointer-events:none;z-index:55', 'display:flex;align-items:center;justify-content:space-between', 'padding:0 8px;transition:opacity 1s ease', ].join(';'); const arrowStyle = ` display:flex;flex-direction:column;align-items:center;gap:4px; background:rgba(0,0,0,0.42);backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px); border:1px solid rgba(255,255,255,0.18);border-radius:14px; padding:10px 10px;animation:worlds-pulse 1.2s ease infinite alternate; `; hint.innerHTML = `
JETZT
WELT
`; ov.appendChild(hint); setTimeout(() => { hint.style.opacity = '0'; }, 2800); setTimeout(() => hint.remove(), 3900); } function show(worldIdx) { const ov = document.getElementById('worlds-overlay'); if (!ov) return; ov.style.display = 'block'; requestAnimationFrame(() => ov.classList.add('worlds-visible')); _visible = true; document.getElementById('app-header')?.classList.add('worlds-hidden'); document.getElementById('bottom-nav')?.classList.add('worlds-hidden'); document.getElementById('worlds-back')?.classList.remove('worlds-back-visible'); if (worldIdx != null) _goTo(worldIdx, false); if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } // Ausstehender Refresh (z.B. nach Foto-Upload während Worlds unsichtbar) if (_refreshPending) { _refreshPending = false; _renderJetzt(); _renderHund(); return; } // Nach Login/Logout: Config aus DB laden, dann rendern const currentUserId = _state?.user?.id ?? null; if (currentUserId !== _lastUserId) { _lastUserId = currentUserId; if (currentUserId) { _loadConfigFromServer().then(() => { _renderJetzt(); _renderHund(); }); } else { _cfgCache = null; _renderJetzt(); _renderHund(); } } } function hide() { const ov = document.getElementById('worlds-overlay'); if (!ov) return; ov.classList.remove('worlds-visible'); ov.style.display = 'none'; _visible = false; document.getElementById('app-header')?.classList.remove('worlds-hidden'); document.getElementById('bottom-nav')?.classList.remove('worlds-hidden'); document.getElementById('worlds-back')?.classList.add('worlds-back-visible'); } function navigateTo(pageId) { hide(); if (window.App?.navigate) window.App.navigate(pageId); } // ── SWIPE ──────────────────────────────────────────────────── function _setupSwipe() { const track = document.getElementById('worlds-track'); if (!track) return; track.addEventListener('touchstart', e => { _t.x = e.touches[0].clientX; _t.y = e.touches[0].clientY; _t.active = true; _t.vert = null; _t.moved = 0; track.style.transition = 'none'; }, { passive: true }); track.addEventListener('touchmove', e => { if (!_t.active) return; const dx = e.touches[0].clientX - _t.x; const dy = e.touches[0].clientY - _t.y; if (_t.vert === null) _t.vert = Math.abs(dy) > Math.abs(dx) + 4; if (_t.vert) return; e.preventDefault(); // Nicht über erste/letzte Seite hinausziehen const cdx = _cur === 0 ? Math.min(0, dx) : _cur === 2 ? Math.max(0, dx) : dx; _t.moved = cdx; const base = -_cur * (100 / 3); track.style.transform = `translateX(calc(${base}% + ${cdx}px))`; }, { passive: false }); track.addEventListener('touchend', () => { if (!_t.active || _t.vert) { _t.active = false; return; } _t.active = false; let next = _cur; if (_t.moved < -55 && _cur < 2) next = _cur + 1; else if (_t.moved > 55 && _cur > 0) next = _cur - 1; _goTo(next, true); if (next === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } }); // Mausrad-Navigation (Desktop) let _wheelCooldown = false; track.addEventListener('wheel', e => { e.preventDefault(); if (_wheelCooldown) return; const next = e.deltaX > 30 || e.deltaY > 30 ? Math.min(2, _cur + 1) : e.deltaX < -30 || e.deltaY < -30 ? Math.max(0, _cur - 1) : _cur; if (next === _cur) return; _wheelCooldown = true; setTimeout(() => { _wheelCooldown = false; }, 500); _goTo(next, true); if (next === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } }, { passive: false }); } function _goTo(idx, animated) { _cur = Math.max(0, Math.min(2, idx)); const track = document.getElementById('worlds-track'); if (!track) return; track.style.transition = animated ? 'transform 0.32s cubic-bezier(0.25, 0.46, 0.45, 0.94)' : 'none'; track.style.transform = `translateX(${-_cur * (100 / 3)}%)`; _updateDots(); _updateFab(); // Karte neu rendern nachdem Transition abgeschlossen if (_cur === 2 && _map) { setTimeout(() => _map.invalidateSize(), animated ? 380 : 50); } } function _updateDots() { document.querySelectorAll('.wdot').forEach((d, i) => d.classList.toggle('active', i === _cur)); document.querySelectorAll('.wlabel').forEach((l, i) => l.classList.toggle('active', i === _cur)); } function _fabOptions() { const worldNames = ['jetzt', 'hund', 'welt']; const chips = _chipsForWorld(worldNames[_cur]); const opts = []; for (const chip of chips) { if (chip.fab) for (const o of chip.fab) { if (opts.length < 6) opts.push(o); } } return opts; } function _updateFab() { const fab = document.getElementById('worlds-fab'); if (!fab) return; const opts = _fabOptions(); if (!opts.length) { fab.style.display = 'none'; return; } fab.style.display = ''; fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#paw-print`); fab.title = 'Schnellaktion'; } function _setupButtons() { document.getElementById('worlds-fab')?.addEventListener('click', _openFab); document.getElementById('worlds-back')?.addEventListener('click', () => { if (_state?.user) show(); else if (window.App) window.App.navigate('welcome'); }); document.querySelectorAll('.wdot').forEach((dot, i) => { dot.style.pointerEvents = 'auto'; dot.addEventListener('click', () => { _goTo(i, true); if (i === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } }); }); document.querySelectorAll('.wlabel').forEach((lbl, i) => { lbl.style.pointerEvents = 'auto'; lbl.style.cursor = 'pointer'; lbl.addEventListener('click', () => { _goTo(i, true); if (i === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } }); }); } function _openFab() { const options = _fabOptions(); const meldenPages = new Set(['poison','lost','recalls','map']); const meldenCount = options.filter(o => meldenPages.has(o.page)).length; const title = meldenCount > options.length / 2 ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'; const ov = document.createElement('div'); ov.id = 'fab-overlay'; ov.className = 'w3-sheet-overlay'; ov.innerHTML = `
${options.length ? title : 'Schnellzugriff'}
${options.map(o => ` `).join('')}
`; document.body.appendChild(ov); const _close = () => ov.remove(); ov.querySelector('#fab-backdrop').addEventListener('click', _close); ov.querySelector('#fab-close').addEventListener('click', _close); ov.querySelector('#fab-all-btn').addEventListener('click', () => { _close(); _openAllChips(); }); ov.querySelectorAll('.fab-option').forEach(btn => { btn.addEventListener('click', () => { _close(); const page = btn.dataset.page; const action = btn.dataset.action; if (action === 'quickGassi') { _openQuickGassi(); return; } navigateTo(page); if (action === 'openNew') { setTimeout(() => window.App?.callModule?.(page, 'openNew'), 400); } }); }); } function _openAllChips() { const worldNames = ['jetzt', 'hund', 'welt']; const worldLabels = { jetzt: 'JETZT', hund: 'HUND', welt: 'WELT' }; // Alle Seiten die aktuell in irgendeiner Welt konfiguriert sind const cfg = _getConfig(); const configured = new Set(worldNames.flatMap(w => cfg[w] || [])); const sections = worldNames.map(w => { const chips = (_DEFAULT_CONFIG[w] || []).map(_chipMeta) .filter(c => c && _chipAllowed(c) && !configured.has(c.page)); if (!chips.length) return ''; return `
${worldLabels[w]}
${chips.map(c => ` `).join('')}
`; }).join(''); const ov = document.createElement('div'); ov.id = 'fab-overlay'; ov.className = 'w3-sheet-overlay'; ov.innerHTML = `
Ausgeblendete Funktionen
${sections || `
Alle Funktionen sind bereits in deinen Welten sichtbar.
`}
Einzelne Funktionen ausblenden oder zwischen den Welten verschieben: in deinem Profil.
`; document.body.appendChild(ov); const _close = () => ov.remove(); ov.querySelector('#fab-backdrop').addEventListener('click', _close); ov.querySelector('#fab-close').addEventListener('click', _close); ov.querySelectorAll('.all-chip-btn').forEach(btn => { btn.addEventListener('click', () => { _close(); navigateTo(btn.dataset.page); }); }); ov.querySelector('#fab-all-goto-worlds')?.addEventListener('click', () => { _close(); _openConfigModal(); }); } // ── SCHNELL-GASSI ───────────────────────────────────────────── async function _openQuickGassi() { const dog = _dogs[_dogIdx] || null; if (!dog) { UI.toast?.error('Kein Hund gefunden. Bitte zuerst ein Profil anlegen.'); navigateTo('dog-profile'); return; } // Wetter aus Cache holen (kein Wait nötig) let weatherData = null; try { const wc = _wLoad('weather'); if (wc?.data) weatherData = wc.data; } catch {} let selectedMin = 30; const durations = [15, 30, 45, 60]; const ov = document.createElement('div'); ov.id = 'quick-gassi-overlay'; ov.className = 'w3-sheet-overlay'; ov.style.zIndex = '400'; const weatherLine = weatherData ? `
🌡 ${Math.round(weatherData.temp_c)}° · ${_esc(weatherData.desc?.split(' ')[0] || '')}
` : ''; ov.innerHTML = `
🐾 Schnell-Gassi
${_esc(dog.name)} · ohne GPS
${weatherLine}
Dauer
${durations.map(d => ` `).join('')}
`; document.body.appendChild(ov); const _close = () => ov.remove(); ov.querySelector('#qg-backdrop').addEventListener('click', _close); ov.querySelector('#qg-close').addEventListener('click', _close); // Dauer-Toggle ov.querySelectorAll('.qg-dur').forEach(btn => { btn.addEventListener('click', () => { selectedMin = parseInt(btn.dataset.min); ov.querySelectorAll('.qg-dur').forEach(b => { b.classList.toggle('active', parseInt(b.dataset.min) === selectedMin); }); }); }); // Eintragen ov.querySelector('#qg-submit').addEventListener('click', async () => { const submitBtn = ov.querySelector('#qg-submit'); submitBtn.disabled = true; submitBtn.textContent = 'Wird eingetragen…'; try { // Kein Tagebucheintrag — nur Streak pingen await API.post(`/streak/${dog.id}/ping`); _close(); UI.toast?.success(`Gassi eingetragen! ${selectedMin} min 🐾`); // Streak-Cache invalidieren try { localStorage.removeItem('w3_streak_' + dog.id); } catch {} // JETZT-Welt neu rendern für aktuellen Streak setTimeout(() => _renderJetzt(), 300); } catch (err) { submitBtn.disabled = false; submitBtn.innerHTML = ' Eintragen'; UI.toast?.error('Fehler beim Eintragen. Bitte erneut versuchen.'); } }); } // ── CHIP-KONFIGURATION ────────────────────────────────────── // Alle verfügbaren Chips mit Metadaten const _ALL_CHIPS = [ { icon:'note-pencil', label:'Notizblock', page:'notes', pro: true, fab:[{ icon:'note-pencil', color:'#10B981', label:'Neue Notiz', sub:'Schnellnotiz erstellen', page:'notes', action:'openNew' }] }, { icon:'currency-eur', label:'Ausgaben', page:'expenses', fab:[{ icon:'currency-eur', color:'#3B82F6', label:'Ausgabe eintragen', sub:'Einmalig oder Dauerauftrag', page:'expenses', action:'openNew' }] }, { icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' }, { icon:'handshake', label:'Playdate', page:'playdate', pro: true, fab:[{ icon:'handshake', color:'#F59E0B', label:'Playdate anfragen', sub:'Treffen mit anderen Hunden', page:'playdate', action:'openNew' }] }, { icon:'chat-circle-dots', label:'Nachrichten', page:'chat', pro: true }, { icon:'sun', label:'Wetter', page:'wetter' }, { icon:'book-open', label:'Tagebuch', page:'diary', fab:[{ icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' }] }, { icon:'heartbeat', label:'Gesundheit', page:'health', fab:[{ icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' }, { icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }] }, { icon:'target', label:'Übungen', page:'uebungen', fab:[{ icon:'target', color:'#F59E0B', label:'Training aufzeichnen', sub:'Übung absolviert', page:'uebungen' }] }, { icon:'list-checks', label:'Trainingspläne', page:'trainingsplaene', fab:[{ icon:'list-checks', color:'#10B981', label:'Plan erstellen', sub:'Neuen Trainingsplan anlegen', page:'trainingsplaene', action:'openNew' }] }, { icon:'heart', label:'Adoption', page:'adoption', fab:[{ icon:'heart', color:'#EF4444', label:'Hund anbieten', sub:'Zur Adoption freigeben', page:'adoption', action:'openNew' }] }, { icon:'house-line', label:'Sitting', page:'sitting', fab:[{ icon:'house-line', color:'#8B5CF6', label:'Sitter anfragen', sub:'Betreuung buchen', page:'sitting', action:'openNew' }] }, { icon:'books', label:'Wiki', page:'wiki' }, { icon:'scales', label:'Wurfbörse', page:'wurfboerse' }, { icon:'map-trifold', label:'Karte', page:'map', fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] }, { icon:'push-pin', label:'Forum', page:'forum', fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] }, { icon:'users', label:'Freunde', page:'friends', fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] }, { icon:'paw-print', label:'Gassi', page:'walks', pro: true, fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' }, { icon:'paw-print', color:'#10B981', label:'Schnell-Gassi', sub:'Kurze Runde ohne GPS eintragen', page:'walks', action:'quickGassi' }] }, { icon:'skull', label:'Giftköder', page:'poison', fab:[{ icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' }] }, { icon:'warning-circle', label:'Rückrufe', page:'recalls', fab:[{ icon:'warning-circle', color:'#EF4444', label:'Rückruf melden', sub:'Produkt oder Futter', page:'recalls', action:'openNew' }] }, { icon:'dog', label:'Verlorene', page:'lost', fab:[{ icon:'dog', color:'#3B82F6', label:'Verlorenen melden', sub:'Hilf beim Wiederfinden', page:'lost' }] }, { icon:'path', label:'Routen', page:'routes', fab:[{ icon:'path', color:'#10B981', label:'Route aufzeichnen', sub:'GPS-Tracking starten', page:'routes', action:'openNew' }] }, { icon:'calendar-dots', label:'Events', page:'events', fab:[{ icon:'calendar-dots', color:'#06B6D4', label:'Event erstellen', sub:'Veranstaltung ankündigen', page:'events', action:'openNew' }] }, { icon:'sparkle', label:'Jobs', page:'jobs' }, { icon:'book-open', label:'Knigge', page:'knigge' }, { icon:'film-slate', label:'Filme', page:'movies' }, { icon:'tree-structure', label:'Zuchtkartei', page:'zuchthunde', role:'breeder', fab:[{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] }, { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder', fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' }] }, { icon:'thermometer', label:'Läufigkeit', page:'laeufi', role:'breeder' }, { icon:'sparkle', label:'Social', page:'social', role:'social', fab:[{ icon:'sparkle', color:'#EC4899', label:'Social-Post', sub:'Beitrag erstellen', page:'social', action:'openNew' }] }, { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' }, { icon:'gear', label:'Admin', page:'admin', role:'admin' }, // ── NEUE FEATURES ──────────────────────────────────────────── { icon:'fork-knife', label:'Ernährung', page:'ernaehrung', pro: true, fab:[{ icon:'fork-knife', color:'#F97316', label:'Futter-Tagebuch', sub:'Mahlzeit oder Futtercheck', page:'ernaehrung' }] }, { icon:'airplane', label:'Reise', page:'reise', pro: true }, { icon:'smiley', label:'Persönlichkeit', page:'personality' }, ]; const _DEFAULT_CONFIG = { jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','social','moderation','admin'], hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse', 'litters','zuchthunde','laeufi','ernaehrung','personality'], welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events', 'jobs','knigge','movies','reise'], }; // _cfgCache: wird beim Init aus DB geladen, Fallback localStorage → Default let _cfgCache = null; function _mergeDefaults(cfg) { const result = JSON.parse(JSON.stringify(cfg)); const hidden = new Set(result.hidden || []); // Chips die bereits einer Welt zugewiesen sind, nicht nochmal einfügen const allAssigned = new Set([ ...(result.jetzt || []), ...(result.hund || []), ...(result.welt || []), ]); for (const world of ['jetzt', 'hund', 'welt']) { const def = _DEFAULT_CONFIG[world] || []; const saved = result[world] || []; for (const page of def) { // Nur echte Neu-Chips anhängen: nicht zugewiesen UND nicht bewusst ausgeblendet if (!allAssigned.has(page) && !hidden.has(page)) { saved.push(page); allAssigned.add(page); } } result[world] = saved; } return result; } async function _loadConfigFromServer() { try { const res = await API.get('/profile/world-config'); if (res?.config) { _cfgCache = _mergeDefaults(res.config); try { localStorage.setItem('world_chips', JSON.stringify(_cfgCache)); } catch {} return; } // Noch nichts in DB: lokale Config hochladen (einmalige Migration) const local = (() => { try { return JSON.parse(localStorage.getItem('world_chips') || 'null'); } catch { return null; } })(); if (local) { _cfgCache = _mergeDefaults(local); API.put('/profile/world-config', { config: _cfgCache }).catch(() => {}); return; } } catch {} // Fallback: localStorage → Default try { _cfgCache = _mergeDefaults(JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG); } catch { _cfgCache = _DEFAULT_CONFIG; } } function _getConfig() { return _cfgCache || _DEFAULT_CONFIG; } function _saveConfig(cfg) { // Bewusst ausgeblendete Chips tracken: Default-Chips die keiner Welt zugewiesen sind const allAssigned = new Set([...(cfg.jetzt||[]), ...(cfg.hund||[]), ...(cfg.welt||[])]); const allDefault = [..._DEFAULT_CONFIG.jetzt, ..._DEFAULT_CONFIG.hund, ..._DEFAULT_CONFIG.welt]; cfg.hidden = allDefault.filter(p => !allAssigned.has(p)); _cfgCache = cfg; try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {} if (_state?.user) { API.put('/profile/world-config', { config: cfg }).catch(() => {}); } } function _chipMeta(page) { return _ALL_CHIPS.find(c => c.page === page) || null; } function _chipAllowed(chip) { const u = _state?.user; const tier = u?.subscription_tier || 'standard'; const isTest = tier.endsWith('_test'); // Pro-Chips: komplett ausblenden wenn kein Zugriff if (chip.pro) { if (!u) return false; if (isTest) return ['pro_test','breeder_test'].includes(tier); if (u.rolle === 'admin' || u.rolle === 'moderator') return true; if (u.is_moderator || u.is_social_media) return true; return ['pro','breeder'].includes(tier); } // Role-Checks (hart — komplett ausblenden) if (!chip?.role) return true; if (chip.role === 'breeder') { if (isTest) return tier === 'breeder_test'; return u?.rolle === 'breeder' || u?.rolle === 'admin'; } if (chip.role === 'social') return u?.is_social_media || u?.rolle === 'admin'; if (chip.role === 'mod') return u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator; if (chip.role === 'admin') return u?.rolle === 'admin'; return true; } // Gibt true zurück wenn User vollen Pro-Zugriff hat function _hasProAccess() { const u = _state?.user; if (!u) return false; const tier = u.subscription_tier || 'standard'; if (tier.endsWith('_test')) return ['pro_test','breeder_test'].includes(tier); if (u.rolle === 'admin' || u.rolle === 'moderator') return true; if (u.is_moderator || u.is_social_media) return true; return ['pro','breeder'].includes(tier); } function _chipsForWorld(world) { const cfg = _getConfig(); const pages = cfg[world] || _DEFAULT_CONFIG[world]; // Deduplizieren + filtern — _chipAllowed entscheidet ob angezeigt const seen = new Set(); const chips = pages .filter(p => { if (seen.has(p)) return false; seen.add(p); return true; }) .map(_chipMeta) .filter(c => c && _chipAllowed(c)); return chips; } // ── KONFIGURATIONS-MODAL ───────────────────────────────────── function _openConfigModal() { let cfg = JSON.parse(JSON.stringify(_getConfig())); // deep copy let _drag = null; // { page, fromWorld, ghost } const isAdmin = _state?.user?.rolle === 'admin'; const worldColors = { jetzt:'rgba(196,132,58,0.6)', hund:'rgba(196,132,58,0.8)', welt:'rgba(99,130,220,0.6)' }; const worldLabels = { jetzt:'JETZT', hund:'HUND', welt:'WELT', pool:'Nicht verwendet' }; const allAssigned = () => new Set([...cfg.jetzt, ...cfg.hund, ...cfg.welt]); const poolChips = () => _ALL_CHIPS.filter(c => _chipAllowed(c) && !allAssigned().has(c.page) && !c.pinned); const bottomNav = document.getElementById('bottom-nav'); if (bottomNav) bottomNav.style.display = 'none'; const _isDesktop = window.innerWidth >= 768; const ov = document.createElement('div'); ov.id = 'wc-overlay'; ov.style.cssText = _isDesktop ? 'position:fixed;inset:0;z-index:500;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)' : 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end'; document.body.appendChild(ov); const _removeDragListeners = () => { document.removeEventListener('pointermove', _onDragMove); document.removeEventListener('pointerup', _onDragEnd); document.removeEventListener('pointercancel', _onDragEnd); }; const _cancelDrag = () => { if (!_drag) return; _removeDragListeners(); _drag.ghost?.remove(); if (_drag.chipEl) _drag.chipEl.style.opacity = ''; ov.querySelectorAll('.wc-zone').forEach(z => z.style.borderColor = 'transparent'); _drag = null; }; const _closeModal = () => { _cancelDrag(); // laufenden Drag abbrechen ov.remove(); if (bottomNav) bottomNav.style.removeProperty('display'); }; function _render() { const _sheetStyle = _isDesktop ? 'position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:20px;width:90%;max-width:1100px;max-height:88vh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 0 20px' : 'position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:24px 24px 0 0;max-height:92vh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 0 calc(env(safe-area-inset-bottom,16px)+16px)'; const _gridCols = _isDesktop ? 'repeat(auto-fill,minmax(120px,1fr))' : 'repeat(4,1fr)'; const _chipH = _isDesktop ? '64px' : '80px'; ov.innerHTML = ` ${!_isDesktop ? '
' : ''}
Welten einrichten
Lang drücken & ziehen zum Verschieben. ✕ zum Entfernen.
${['jetzt','hund','welt','pool'].map(w => { const chips = w === 'pool' ? poolChips() : (cfg[w] || []).map(_chipMeta).filter(c => c && _chipAllowed(c)); const col = worldColors[w] || 'var(--c-border)'; return `
${worldLabels[w]} ${w !== 'pool' ? `(${chips.length})` : ''}
${chips.map(c => `
${!c.pinned ? ` ` : `
`} ${isAdmin && c.pro ? `P` : ''} ${isAdmin && c.role === 'breeder' ? `Z` : ''} ${c.label.replace('\n','
')}
`).join('')} ${chips.length === 0 ? `
${w==='pool'?'Alle Chips sind zugeordnet':'Leer — ziehe Chips hierher'}
` : ''}
`; }).join('')}
`; _bindEvents(); } function _bindEvents() { ov.querySelector('#wc-bg')?.addEventListener('click', _closeModal); ov.querySelector('#wc-cancel')?.addEventListener('click', _closeModal); ov.querySelector('#wc-reset')?.addEventListener('click', () => { cfg = JSON.parse(JSON.stringify(_DEFAULT_CONFIG)); _render(); }); ov.querySelector('#wc-save')?.addEventListener('click', () => { _saveConfig(cfg); _closeModal(); _renderJetzt(); _renderHund(); _renderWelt(); }); // Remove-Buttons ov.querySelectorAll('.wc-remove').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const page = btn.dataset.page, zone = btn.dataset.zone; const meta = _chipMeta(page); if (meta?.pinned) return; // gepinnte Chips können nicht entfernt werden if (zone !== 'pool') cfg[zone] = cfg[zone].filter(p => p !== page); _render(); }); }); // Pointer-Drag (funktioniert auf Mouse + Touch) ov.querySelectorAll('.wc-chip').forEach(chip => { chip.addEventListener('pointerdown', e => _onDragStart(e, chip)); }); } function _onDragStart(e, chipEl) { if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste if (_drag) _cancelDrag(); chipEl.setPointerCapture(e.pointerId); _drag = { page: chipEl.dataset.page, zone: chipEl.dataset.zone, chipEl, ghost: null, dropZone: null, active: false, startX: e.clientX, startY: e.clientY, ox: 0, oy: 0, }; document.addEventListener('pointermove', _onDragMove); document.addEventListener('pointerup', _onDragEnd); document.addEventListener('pointercancel', _onDragEnd); } function _activateDrag(e) { const rect = _drag.chipEl.getBoundingClientRect(); _drag.ox = _drag.startX - rect.left; _drag.oy = _drag.startY - rect.top; _drag.active = true; const ghost = _drag.chipEl.cloneNode(true); ghost.querySelectorAll('button').forEach(b => b.style.display = 'none'); ghost.style.position = 'fixed'; ghost.style.zIndex = '9999'; ghost.style.opacity = '0.9'; ghost.style.pointerEvents= 'none'; ghost.style.transform = 'scale(1.08) rotate(-2deg)'; ghost.style.width = rect.width + 'px'; ghost.style.height = rect.height + 'px'; ghost.style.left = (e.clientX - _drag.ox) + 'px'; ghost.style.top = (e.clientY - _drag.oy) + 'px'; ghost.style.transition = 'none'; ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)'; document.body.appendChild(ghost); _drag.ghost = ghost; _drag.chipEl.style.opacity = '0.2'; } function _onDragMove(e) { if (!_drag) return; if (!_drag.active) { const dx = Math.abs(e.clientX - _drag.startX); const dy = Math.abs(e.clientY - _drag.startY); if (dx < 8 && dy < 8) return; _activateDrag(e); } _drag.ghost.style.left = (e.clientX - _drag.ox) + 'px'; _drag.ghost.style.top = (e.clientY - _drag.oy) + 'px'; let foundZone = null; ov.querySelectorAll('.wc-zone').forEach(z => { const r = z.getBoundingClientRect(); const over = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom; z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent'; if (over) foundZone = z.dataset.zone; }); _drag.dropZone = foundZone; } function _onDragEnd() { if (!_drag) return; _removeDragListeners(); const { ghost, chipEl, dropZone, page, zone: fromZone, active } = _drag; _drag = null; ov.querySelectorAll('.wc-zone').forEach(z => z.style.borderColor = 'transparent'); ghost?.remove(); if (chipEl) chipEl.style.opacity = ''; if (!active) return; // nur Tap, kein Drag — nichts verschieben const meta = _chipMeta(page); if (dropZone && dropZone !== fromZone && !(meta?.pinned && dropZone === 'pool')) { if (fromZone !== 'pool') cfg[fromZone] = cfg[fromZone].filter(p => p !== page); if (dropZone !== 'pool' && !cfg[dropZone].includes(page)) cfg[dropZone].push(page); _render(); } } _render(); } // ── PANORAMA HINTERGRUNDBILD ───────────────────────────────── async function _loadDailyImage(dog) { if (!dog) return null; const userId = _state?.user?.id || 'anon'; const todayKey = `bg3_${userId}_` + new Date().toISOString().slice(0, 10); const cached = _wLoad(todayKey); if (cached?.data) return cached.data; try { const dash = await API.dogs.welcomeDashboard(dog.id); const url = dash?.random_photo?.url || dog.foto_url || null; if (url) _wSave(todayKey, url); return url; } catch { return dog.foto_url || null; } } let _bgUrl = null; // aktuell gesetztes Hintergrundbild function _isDarkMode() { const t = document.documentElement.getAttribute('data-theme'); if (t === 'dark') return true; if (t === 'light') return false; return window.matchMedia('(prefers-color-scheme: dark)').matches; } function _bgWithOverlay(url) { return _isDarkMode() ? `linear-gradient(rgba(0,0,0,0.45),rgba(0,0,0,0.45)), url('${url}')` : `url('${url}')`; } function _applyBgOrientation() { const ov = document.getElementById('worlds-overlay'); const track = document.getElementById('worlds-track'); if (!ov || !track || !_bgUrl) return; const portrait = window.matchMedia('(orientation: portrait)').matches; if (portrait) { // Panorama: Bild über alle drei Welten, scrollt mit dem Swipe ov.style.backgroundImage = ''; track.style.backgroundImage = _bgWithOverlay(_bgUrl); track.style.backgroundSize = 'cover'; track.style.backgroundPosition = 'center 40%'; track.style.backgroundRepeat = 'no-repeat'; } else { // Vollbild pro Welt (Landscape / Desktop) track.style.backgroundImage = ''; ov.style.backgroundImage = _bgWithOverlay(_bgUrl); ov.style.backgroundSize = 'cover'; ov.style.backgroundPosition = 'center 40%'; ov.style.backgroundRepeat = 'no-repeat'; } } // Orientierungswechsel → Bild neu setzen window.matchMedia('(orientation: portrait)').addEventListener('change', _applyBgOrientation); // Theme-Wechsel → Overlay-Intensität anpassen new MutationObserver(_applyBgOrientation) .observe(document.documentElement, { attributeFilter: ['data-theme'] }); function _applyBgImage(url) { const ov = document.getElementById('worlds-overlay'); const track = document.getElementById('worlds-track'); if (!ov || !track) return; if (url) { const toLoad = new Image(); toLoad.onload = () => { _hasBgPhoto = true; _bgUrl = url; _applyBgOrientation(); document.getElementById('wh-photo-hint')?.remove(); }; toLoad.onerror = () => _applyBgImage(null); toLoad.src = url; } else { _hasBgPhoto = false; _bgUrl = null; track.style.backgroundImage = ''; ov.style.backgroundImage = 'linear-gradient(160deg,#1a1f35 0%,#16213e 33%,#1a2535 67%,#0f1921 100%)'; ov.style.backgroundSize = '100% 100%'; } } // ── CHIP-HELPER ────────────────────────────────────────────── function _isRoleBasedPro() { const u = _state?.user; if (!u) return false; const t = u.subscription_tier || 'standard'; if (t.endsWith('_test') || ['pro','breeder'].includes(t)) return false; return u.rolle === 'admin' || u.rolle === 'moderator' || u.is_moderator || u.is_social_media; } function _chip(icon, label, page, locked = false, proBadge = false, breederBadge = false) { const style = locked ? 'opacity:0.25;cursor:default;' : ''; const badge = proBadge ? `P` : breederBadge ? `Z` : ''; return `
${badge} ${label}
`; } // ── JETZT WORLD ────────────────────────────────────────────── async function _renderJetzt() { const el = document.getElementById('wj-content'); if (!el) return; const user = _state?.user; el.innerHTML = _skeleton(3); const [weatherRes, dogsRes, alertsRes, achRes] = await Promise.allSettled([ _getCachedWeather(), user ? _cachedGet('dogs', '/dogs') : Promise.resolve({ data: [], fromCache: false, ageMin: 0 }), user ? _getNearbyAlerts() : Promise.resolve([]), user ? _cachedGet('achievements_me', '/achievements/me') : Promise.resolve({ data: null }), ]); const weatherObj = weatherRes.value || { data: null, fromCache: false, ageMin: 0 }; const dogsObj = dogsRes.value || { data: [], fromCache: false, ageMin: 0 }; const w = weatherObj.data; const dogList = dogsObj.data || []; const dog = dogList[0] || null; const alertList = alertsRes.value || []; const totalKm = achRes.value?.data?.stats?.total_km ?? null; const isOffline = weatherObj.fromCache && dogsObj.fromCache; const staleMin = Math.max(weatherObj.ageMin || 0, dogsObj.ageMin || 0); // Panorama-Bild setzen (nur wenn noch kein Bild vorhanden) if (dog && !_bgUrl) { _loadDailyImage(dog).then(_applyBgImage); } else if (!dog) { _applyBgImage(null); } const hour = new Date().getHours(); const firstName = user?.name?.split(' ')[0] || ''; const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' }); // User-Geburtstag heute? const _todayDdMm = (() => { const d = new Date(); return String(d.getDate()).padStart(2,'0')+'.'+String(d.getMonth()+1).padStart(2,'0'); })(); const userBdayToday = user?.geburtstag && user.geburtstag === _todayDdMm; const greet = userBdayToday ? `Herzlichen Glückwunsch` : (hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend'); const stale = isOffline && staleMin > 5 ? `· Offline` : ''; // Gassi-Score aus Wetterdaten berechnen function _calcGassiScore(wd) { if (!wd) return null; let s = 10; const t = wd.temp_c ?? 20, p = wd.precip_prob ?? 0, wind = wd.wind_kmh ?? 0; if (t > 30) s -= 3; else if (t > 25) s -= 1; else if (t < 0) s -= 3; else if (t < 5) s -= 1; if (p > 70) s -= 3; else if (p > 40) s -= 2; else if (p > 20) s -= 1; if (wind > 60) s -= 2; else if (wind > 40) s -= 1; if (wd.thunderstorm) s -= 3; return Math.max(1, Math.min(10, s)); } const gassiScore = _calcGassiScore(w); const gassiColor = gassiScore >= 8 ? '#10B981' : gassiScore >= 5 ? '#F59E0B' : '#EF4444'; const weatherEmoji = !w ? '🌤️' : w.thunderstorm ? '⛈️' : (w.precip_prob ?? 0) > 70 ? '🌧️' : (w.precip_prob ?? 0) > 30 ? '🌦️' : (w.temp_c ?? 20) > 28 ? '☀️🔥' : (w.temp_c ?? 20) < 2 ? '🌨️' : '☀️'; // User-Geburtstag Reminder const userBdayHtml = userBdayToday ? `
Alles Gute zum Geburtstag, ${_esc(firstName)}!
Wir wünschen dir und deinem Hund einen wunderschönen Tag 🐾
` : ''; // Alert-Reminder const alertHtml = alertList.slice(0,1).map(a => `
${_esc(a.title)}
`).join(''); // Feature Chips aus Config const features = user ? _chipsForWorld('jetzt') : [ { icon:'sun', label:'Wetter', page:'wetter' }, { icon:'first-aid',label:'Erste Hilfe', page:'erste-hilfe' }, { icon:'gear', label:'Anmelden', page:'settings' }, ]; // User-Avatar für JETZT Info-Card const uFoto = _state?.user?.avatar_url; const uInitial = firstName ? firstName.charAt(0).toUpperCase() : '?'; const userAvatarHtml = `
${uFoto ? `` : `
${uInitial}
`}
`; el.innerHTML = `
${_esc(greet)}${firstName ? `, ${_esc(firstName)}` : ''}${stale}
${_esc(dayStr)}
${user ? userAvatarHtml : ''}
${userBdayHtml} ${alertHtml} ${user && dog ? `
${weatherEmoji}
Gassi-Score
${gassiScore ?? '—'} ${gassiScore ? `/10` : ''}
${w ? `
${w.location_name ? `
${w.location_name}
` : ''}
${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen
${w.rain_warning_time ? `
⚠ Umschwung ab ${w.rain_warning_time}
` : w.next_rain_time ? `
ab ${w.next_rain_time} Uhr
` : ''}
` : ''}
Gassirunde ${totalKm != null ? `∑ ${totalKm} km` : ''}
Übung
` : ''}
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
`; el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); if (user && dog) { _loadJetztExercise(dog); _loadJetztRoute(); } } async function _loadJetztExercise(dog) { const valEl = document.getElementById('wj-exercise-val'); if (!valEl) return; try { const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`); const ex = res.data?.daily_exercise; valEl.textContent = ex?.name || '—'; // Chip-Klick mit exercise_id/name damit übungen.js direkt dorthin scrollt const chip = document.getElementById('wj-exercise-chip'); if (chip) { chip.style.cursor = 'pointer'; chip.onclick = () => { hide(); if (window.App) window.App.navigate('uebungen', true, ex ? { exercise_id: ex.exercise_id || '', name: ex.name || '' } : {} ); }; } } catch { valEl.textContent = '—'; } } async function _loadJetztRoute() { const valEl = document.getElementById('wj-route-val'); if (!valEl) return; // Tages-Cache (gleicher Key wie welcome.js) const today = new Date().toISOString().slice(0, 10); const cacheKey = 'by_daily_route_' + today; const cached = localStorage.getItem(cacheKey); if (cached) { try { _applyJetztRoute(valEl, JSON.parse(cached)); return; } catch {} } let loc; try { loc = await API.getLocation({ timeout: 5000, maximumAge: 300000 }); } catch { valEl.textContent = 'Standort nötig'; return; } const dayIdx = Math.floor(Date.now() / 86400000); const km = [2, 4, 6][dayIdx % 3]; const seed = dayIdx % 5; try { const result = await API.post('/routes/suggest', { lat: loc.lat, lon: loc.lon, distance_km: km, seed }); if (!result?.gps_track?.length) { valEl.textContent = 'Keine Route gefunden'; return; } localStorage.setItem(cacheKey, JSON.stringify(result)); // alte Einträge aufräumen Object.keys(localStorage) .filter(k => k.startsWith('by_daily_route_') && k !== cacheKey) .forEach(k => localStorage.removeItem(k)); _applyJetztRoute(valEl, result); } catch { valEl.textContent = 'Route nicht verfügbar'; } } function _applyJetztRoute(valEl, result) { const durStr = result.dauer_min < 60 ? `${result.dauer_min} min` : `${Math.floor(result.dauer_min / 60)}h ${result.dauer_min % 60 > 0 ? ' ' + (result.dauer_min % 60) + 'min' : ''}`; valEl.textContent = `${result.distanz_km} km · ${durStr}`; } // ── HUND WORLD ─────────────────────────────────────────────── async function _renderHund() { const el = document.getElementById('wh-content'); if (!el) return; const user = _state?.user; if (!user) { el.innerHTML = `
🐾
Dein Hund wartet
Melde dich an um loszulegen
`; return; } el.innerHTML = _skeleton(4); const dogsRes = await _cachedGet('dogs', '/dogs'); const dogs = dogsRes.data || []; if (!dogs.length) { const features = [ { icon:'book-open', color:'#8B5CF6', title:'Tagebuch', sub:'Fotos & Erlebnisse' }, { icon:'heartbeat', color:'#EF4444', title:'Gesundheit', sub:'Impfungen & Gewicht' }, { icon:'target', color:'#F59E0B', title:'Training', sub:'104 Übungen' }, { icon:'books', color:'#10B981', title:'Wiki', sub:'Alle Rassen' }, { icon:'scales', color:'#3B82F6', title:'Wurfbörse', sub:'Welpen finden' }, { icon:'currency-eur', color:'#06B6D4', title:'Ausgaben', sub:'Budget im Blick' }, ]; el.innerHTML = `
🐶
Dein Hund wartet!
Lege ein Profil an und schalte alle Features frei
Was dich erwartet
${features.map(f => `
${f.title}
`).join('')}
`; el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); return; } // Hunde global cachen für schnelles Cycling _dogs = dogs; if (_dogIdx >= _dogs.length) _dogIdx = 0; const dog = _dogs[_dogIdx]; // Geburtstag prüfen (heute oder morgen → Feature sichtbar) function _birthdayState(geb) { if (!geb) return null; const today = new Date(); const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1); const mm = String(today.getMonth() + 1).padStart(2, '0'); const dd = String(today.getDate()).padStart(2, '0'); const mt = String(tomorrow.getMonth() + 1).padStart(2, '0'); const dt = String(tomorrow.getDate()).padStart(2, '0'); const mmdd = geb.slice(5); // 'MM-DD' if (mmdd === `${mm}-${dd}`) return 'today'; if (mmdd === `${mt}-${dt}`) return 'tomorrow'; return null; } const bdayDog = _dogs.find(d => _birthdayState(d.geburtstag)) || null; // Großes Banner nur wenn der AKTIVE Hund Geburtstag hat const bday = (bdayDog && bdayDog.id === dog.id) ? _birthdayState(dog.geburtstag) : null; const bdayYear = bday && dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null; // Hinweis in Info-Karte wenn ein ANDERER Hund Geburtstag hat const otherBdayDog = (bdayDog && bdayDog.id !== dog.id) ? bdayDog : null; const [streakRes, diaryRes] = await Promise.allSettled([ _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`), _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=1`), ]); const streak = streakRes.value?.data ?? streakRes.value; const diaryData = diaryRes.value?.data ?? diaryRes.value; const lastEntry = diaryData?.entries?.[0] || diaryData?.[0] || null; const ageStr = dog.alter_jahre ? _fmtAlter(dog.alter_jahre) : ''; const stats = [dog.rasse, ageStr, dog.gewicht_kg ? dog.gewicht_kg + ' kg' : null].filter(Boolean).join(' · '); const otherDogs = _dogs.filter((_, i) => i !== _dogIdx); const chips = _chipsForWorld('hund'); // Avatar des aktuellen Hundes (links, → Hundeprofil) const dogAvatarHtml = dog.foto_url ? `` : `
🐶
`; // Andere Hunde-Avatare (rechts, überlagernd, → Hund wechseln) const otherAvatarsHtml = otherDogs.length > 0 ? `
${otherDogs.slice(0, 4).map((d, i) => { const targetIdx = _dogs.indexOf(d); return `
${d.foto_url ? `` : `
${_esc(d.name?.charAt(0)?.toUpperCase() || '?')}
`}
`; }).join('')}
` : ''; el.innerHTML = `
${dogAvatarHtml}
${_esc(dog.name)}
${stats ? `
${_esc(stats)}
` : ''}
${otherAvatarsHtml}
${otherBdayDog ? `
${_esc(otherBdayDog.name)} hat ${_birthdayState(otherBdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag!
` : ''}
${bday ? `
${bday === 'today' ? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(bdayDog.name)}!` : `Morgen hat ${_esc(bdayDog.name)} Geburtstag!`}
${bdayYear ? `
${bday === 'today' ? `${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} gemeinsam` : `Wird ${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} alt`}
` : ''}
${bday === 'today' ? `Was hat sich ${_esc(bdayDog.name)} gewünscht?` : 'KI-Überraschungsideen'}
${bday === 'today' && new Date().getHours() >= 18 ? `
Halte den besonderen Tag im Tagebuch fest 🐾
` : ''}` : ''}
${!_hasBgPhoto ? `
Hintergrund-Foto hinzufügen
Tagebuchfotos erscheinen hier als Panorama
` : ''}
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
`; // Avatar → Hundeprofil (aktiven Hund auf den angezeigten setzen) el.querySelector('#wh-avatar')?.addEventListener('click', () => { const shown = _dogs[_dogIdx]; if (shown && shown.id !== _state?.activeDog?.id) App.setActiveDog(shown.id); navigateTo('dog-profile'); }); // Chips el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); // Name → nächster Hund if (_dogs.length > 1) { el.querySelector('#wh-cycle-btn')?.addEventListener('click', () => { _dogIdx = (_dogIdx + 1) % _dogs.length; _renderHund(); }); } // Andere Hund-Avatare → zu diesem Hund wechseln el.querySelectorAll('.wh-other-dog').forEach(btn => { btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.idx); if (!isNaN(idx) && idx !== _dogIdx) { _dogIdx = idx; _renderHund(); } }); }); // Geburtstag-Hinweis → zum Geburtstagshund wechseln if (otherBdayDog) { if (!document.getElementById('by-bday-anim-style')) { const s = document.createElement('style'); s.id = 'by-bday-anim-style'; s.textContent = '@keyframes by-bday-bounce{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-5px) scale(1.3)}}'; document.head.appendChild(s); } el.querySelector('#wh-other-bday-hint')?.addEventListener('click', () => { const idx = _dogs.indexOf(otherBdayDog); if (idx >= 0) { _dogIdx = idx; _renderHund(); } }); } // Geburtstags-Banner → KI el.querySelector('#wh-bday-banner')?.addEventListener('click', () => _openBdayKI(dog, bday)); // Abend-Banner: nach 18 Uhr am echten Geburtstag → Tagebucheintrag anregen if (bday === 'today' && new Date().getHours() >= 18) { el.querySelector('#wh-bday-evening')?.addEventListener('click', () => { navigateTo('diary'); setTimeout(() => window.App?.callModule?.('diary', 'openNew'), 400); }); } } async function _openBdayKI(dog, bdayMode) { const isToday = bdayMode === 'today'; const title = isToday ? `🎂 ${_esc(dog.name)}s Geburtstagstraum` : `🎁 Überraschungen für ${_esc(dog.name)}`; const ov = document.createElement('div'); ov.className = 'w3-sheet-overlay'; ov.innerHTML = `
${title}
KI denkt nach…
${isToday ? ` ` : ''}
`; document.body.appendChild(ov); const _close = () => ov.remove(); ov.querySelector('.w3-backdrop').addEventListener('click', _close); ov.querySelector('#bday-ki-close').addEventListener('click', _close); ov.querySelector('#bday-diary-btn')?.addEventListener('click', () => { _close(); navigateTo('diary'); setTimeout(() => window.App?.callModule?.('diary', 'openNew'), 400); }); try { const res = await API.post('/ki/geburtstag', { dog_id: bdayDog.id, name: bdayDog.name, rasse: bdayDog.rasse || null, alter: bdayDog.alter_jahre ? Math.round(bdayDog.alter_jahre) : null, mode: bdayMode, }); const body = ov.querySelector('#bday-ki-body'); if (body) { const html = _esc(res.answer || '') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\n/g, '
'); body.innerHTML = `
${html}
`; } } catch (err) { const body = ov.querySelector('#bday-ki-body'); if (body) { const msg = err?.status === 429 ? 'Heute bereits abgerufen — morgen gibt es neue Ideen 🐾' : 'KI momentan nicht verfügbar. Versuch es später nochmal 🐾'; body.innerHTML = `
${msg}
`; } } } // ── WELT WORLD ─────────────────────────────────────────────── const _QUOTES = [ { t:'Ein Hund ist die einzige Kreatur, die dich mehr liebt als sich selbst.', a:'Josh Billings' }, { t:'Hunde haben Besitzer. Katzen haben Personal.', a:'Unbekannt' }, { t:'Bis man einen Hund geliebt hat, ist ein Teil der Seele unerwacht.', a:'Anatole France' }, { t:'Hunde sind nicht unser ganzes Leben — aber sie machen unser Leben ganz.', a:'Roger Caras' }, { t:'Der Hund hat nur einen Fehler: Er vertraut dem Menschen zu sehr.', a:'Unbekannt' }, { t:'Ein treuer Hund ist besser als ein unzuverlässiger Mensch.', a:'Deutsches Sprichwort' }, { t:'Wer einen Hund hat, gibt nie allein zu Tisch.', a:'Unbekannt' }, { t:'Die reinste Form der Freude ist die Freude eines Hundes.', a:'Milan Kundera' }, { t:'Hunde lachen nicht mit dem Mund, sondern mit dem Schwanz.', a:'Max Eastman' }, { t:'Ein Haus ohne Hund ist ein leeres Haus.', a:'Unbekannt' }, { t:'Wenn du verstehen willst, was Liebe ist, beobachte einen Hund.', a:'Unbekannt' }, { t:'Der Hund fragt nicht warum. Er fragt nur: wann gehen wir?', a:'Unbekannt' }, { t:'Hunde riechen nicht schlecht. Sie riechen nur anders als wir denken.', a:'Unbekannt' }, { t:'In einer Welt voller Unsicherheiten ist der Hund das Verlässlichste.', a:'Unbekannt' }, { t:'Ein Spaziergang mit einem Hund ist nie wirklich ein Umweg.', a:'Unbekannt' }, { t:'Hunde bringen das Beste im Menschen hervor.', a:'Unbekannt' }, { t:'Wer mit Hunden liegt, steht mit Flöhen auf — und mit einem Lächeln.', a:'Unbekannt' }, { t:'Ein Hund weiß, wann du traurig bist. Er weiß nicht warum. Aber er ist da.', a:'Unbekannt' }, { t:'Das Geheimnis eines Hundes: Er beurteilt dich nie.', a:'Unbekannt' }, { t:'Der Hund ist Gattung: Mensch.', a:'Charles M. Schulz' }, { t:'Hunde haben viel zu sagen. Wir haben verlernt zuzuhören.', a:'Unbekannt' }, { t:'Ein guter Hund macht aus einem schlechten Tag einen erträglichen.', a:'Unbekannt' }, { t:'Der Hund ist der philosophischste aller Haustiere.', a:'George Graham Vest' }, { t:'Wer einen Hund hat, braucht keinen Therapeuten.', a:'Unbekannt' }, { t:'Hunde lieben bedingungslos. Das ist ihr größtes Geschenk.', a:'Unbekannt' }, { t:'Der schönste Empfang ist der eines Hundes an der Tür.', a:'Unbekannt' }, { t:'Ein Hund ändert dein Leben. Meistens zum Besseren.', a:'Unbekannt' }, { t:'Hunde altern in Würde. Menschen könnten von ihnen lernen.', a:'Unbekannt' }, { t:'Kein Psychiater der Welt kann so gut zuhören wie ein Hund.', a:'Unbekannt' }, { t:'Wo Hunde sind, da ist das Zuhause.', a:'Unbekannt' }, { t:'Der Hund hat keinen Begriff von Vergangenheit oder Zukunft. Er lebt.', a:'Milan Kundera' }, ]; function _renderWelt() { const el = document.getElementById('ww-content'); if (!el) return; const user = _state?.user; const isMod = user?.rolle === 'admin' || user?.rolle === 'moderator' || user?.is_moderator; const isAdmin = user?.rolle === 'admin'; const isSocial = user?.is_social_media || isAdmin; // Tagesbasierter Spruch const day = Math.floor(Date.now() / 86400000); const quote = _QUOTES[day % _QUOTES.length]; const chips = _chipsForWorld('welt'); el.innerHTML = `
Gedanke des Tages
»${_esc(quote.t)}«
— ${_esc(quote.a)}
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
`; el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); } // ── HELPERS ────────────────────────────────────────────────── async function _getCachedWeather() { const cached = _wLoad('weather'); let fresh = null, pos = null; try { pos = await API.getLocation({ timeout: 5000, maximumAge: 600_000 }); fresh = await API.weather.get(pos.lat, pos.lon); _wSave('weather', fresh); } catch {} const data = fresh ?? cached?.data ?? null; return { data, fromCache: !fresh, ageMin: fresh ? 0 : (cached?.ageMin ?? null) }; } async function _getWeather() { const r = await _getCachedWeather(); return r.data; } async function _getNearbyAlerts() { const out = []; try { const pos = await API.getLocation({ timeout: 4000, maximumAge: 600_000 }); const [p, l] = await Promise.allSettled([ API.get(`/poison/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []), API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=20`).catch(() => []), ]); if (p.value?.length) out.push({ icon:'skull', color:'#EF4444', title:'Giftköder in der Nähe', sub:`${p.value.length} Meldung${p.value.length>1?'en':''}`, page:'poison' }); if (l.value?.length) out.push({ icon:'dog', color:'#3B82F6', title:'Verlorener Hund', sub:`${l.value.length} Meldung${l.value.length>1?'en':''}`, page:'lost' }); } catch {} return out; } function _skeleton(n) { return Array.from({length: n}, (_, i) => `
`).join(''); } function _updateBdayTabIndicator(bdayDog) { if (bdayDog && !document.getElementById('by-bday-tab-style')) { const s = document.createElement('style'); s.id = 'by-bday-tab-style'; s.textContent = '@keyframes by-bday-bounce{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-5px) scale(1.25)}}' + '.wlabel-bday-ic{display:inline-block;animation:by-bday-bounce 1.2s ease-in-out infinite;margin-left:3px;font-size:.85em}'; document.head.appendChild(s); } const hundTab = document.querySelectorAll('#world-labels .wlabel')[1]; if (!hundTab) return; hundTab.querySelector('.wlabel-bday-ic')?.remove(); if (bdayDog) { const ic = document.createElement('span'); ic.className = 'wlabel-bday-ic'; ic.textContent = '🎂'; ic.title = `${bdayDog.name} hat ${_birthdayState(bdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag!`; hundTab.appendChild(ic); } } function _fmtDate(d) { if (!d) return ''; try { return new Date(d).toLocaleDateString('de-DE', { day:'numeric', month:'short' }); } catch { return d; } } function _fmtAlter(j) { if (!j) return ''; if (j < 1) return `${Math.round(j * 12)} Monate`; return j < 2 ? '1 Jahr' : `${Math.round(j)} Jahre`; } function _esc(s) { if (s == null) return ''; return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function refresh(appState) { if (appState) _state = appState; localStorage.removeItem('w3_dogs'); _bgUrl = null; if (_visible) { if (_cur === 0) _renderJetzt(); else if (_cur === 1) _renderHund(); else _renderWelt(); } else { _refreshPending = true; } } return { init, show, hide, navigateTo, refresh, openConfig: _openConfigModal, get _visible() { return _visible; } }; })();