- 7 englische Suno-Pro-Songs als *-en.mp3 in static/sounds/ (MD5-geprüft, eigene Generierungen, alle != deutsche Tracks) - worlds.js _anthem: SONGS_DE/SONGS_EN, _lang-State (localStorage by_album_lang), DE/EN-Segmented-Control (_fillAlbum/_setLang), EN_READY=true, Modal-Chrome zweisprachig - components.css: .album-lang / .album-lang-btn - UX: DE bleibt Default, keine Auto-Vorwahl, User schaltet selbst, beides anhörbar, Wahl gemerkt - LIVE auf Prod + Staging v1301
2263 lines
117 KiB
JavaScript
2263 lines
117 KiB
JavaScript
/* ============================================================
|
||
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 = `
|
||
<style>
|
||
@keyframes worlds-pulse {
|
||
from { opacity:0.75; transform:translateX(0); }
|
||
to { opacity:1; transform:translateX(-3px); }
|
||
}
|
||
.wsh-right { animation-name:worlds-pulse-r !important; }
|
||
@keyframes worlds-pulse-r {
|
||
from { opacity:0.75; transform:translateX(0); }
|
||
to { opacity:1; transform:translateX(3px); }
|
||
}
|
||
</style>
|
||
<div style="${arrowStyle}">
|
||
<svg style="width:20px;height:20px;color:white" viewBox="0 0 256 256">
|
||
<path fill="currentColor" d="M165.66 202.34a8 8 0 0 1-11.32 11.32l-80-80a8 8 0 0 1 0-11.32l80-80a8 8 0 0 1 11.32 11.32L91.31 128Z"/>
|
||
</svg>
|
||
<span style="font-size:8px;font-weight:800;letter-spacing:.12em;color:rgba(255,255,255,0.7);text-transform:uppercase">JETZT</span>
|
||
</div>
|
||
<div class="wsh-right" style="${arrowStyle}">
|
||
<svg style="width:20px;height:20px;color:white" viewBox="0 0 256 256">
|
||
<path fill="currentColor" d="M90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32l-80 80a8 8 0 0 1-11.32-11.32L164.69 128Z"/>
|
||
</svg>
|
||
<span style="font-size:8px;font-weight:800;letter-spacing:.12em;color:rgba(255,255,255,0.7);text-transform:uppercase">WELT</span>
|
||
</div>
|
||
`;
|
||
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();
|
||
_anthem.updateButton();
|
||
// 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 = `
|
||
<div id="fab-backdrop" class="w3-backdrop"></div>
|
||
<div class="w3-sheet-panel">
|
||
<div class="w3-sheet-header">
|
||
<div class="w3-sheet-title">${options.length ? title : 'Schnellzugriff'}</div>
|
||
<button id="fab-close" class="w3-close-btn">
|
||
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
|
||
</button>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px">
|
||
${options.map(o => `
|
||
<button class="fab-option w3-fab-option" data-page="${o.page}" data-action="${o.action || ''}">
|
||
<div class="w3-icon-dot" style="background:${o.color}18">
|
||
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:${o.color}">
|
||
<use href="/icons/phosphor.svg#${o.icon}"></use>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<div style="font-size:var(--text-sm);font-weight:700">${o.label}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${o.sub}</div>
|
||
</div>
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
<button id="fab-all-btn" class="w3-all-btn" style="margin-top:${options.length ? '14px' : '0'}">
|
||
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#squares-four"></use></svg>
|
||
Weitere Funktionen
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
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 `
|
||
<div style="margin-bottom:20px">
|
||
<div class="w3-section-label">${worldLabels[w]}</div>
|
||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
|
||
${chips.map(c => `
|
||
<button class="all-chip-btn w3-chip-btn" data-page="${c.page}" style="position:relative">
|
||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)">
|
||
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
||
</svg>
|
||
<span class="w3-chip-label">${c.label}</span>
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
const ov = document.createElement('div');
|
||
ov.id = 'fab-overlay';
|
||
ov.className = 'w3-sheet-overlay';
|
||
ov.innerHTML = `
|
||
<div id="fab-backdrop" class="w3-backdrop"></div>
|
||
<div class="w3-sheet-panel w3-sheet-panel--scroll">
|
||
<div class="w3-sheet-header w3-sheet-header--mb20">
|
||
<div class="w3-sheet-title">Ausgeblendete Funktionen</div>
|
||
<button id="fab-close" class="w3-close-btn">
|
||
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
|
||
</button>
|
||
</div>
|
||
${sections || `<div style="text-align:center;padding:24px 0;color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||
Alle Funktionen sind bereits in deinen Welten sichtbar.
|
||
</div>`}
|
||
<div style="margin-top:16px;padding:12px 14px;border-radius:12px;
|
||
background:var(--c-surface-raised,rgba(0,0,0,.04));
|
||
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.6;
|
||
display:flex;align-items:flex-start;gap:10px">
|
||
<svg class="ph-icon" style="width:1rem;height:1rem;flex-shrink:0;margin-top:1px;color:var(--c-primary)">
|
||
<use href="/icons/phosphor.svg#info"></use>
|
||
</svg>
|
||
<span>Einzelne Funktionen ausblenden oder zwischen den Welten verschieben:
|
||
<button id="fab-all-goto-worlds" style="background:none;border:none;padding:0;cursor:pointer;
|
||
color:var(--c-primary);font-size:inherit;font-family:inherit;font-weight:600;
|
||
text-decoration:underline;text-underline-offset:2px">
|
||
Welten bearbeiten
|
||
</button>
|
||
in deinem Profil.
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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
|
||
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:6px">
|
||
🌡 ${Math.round(weatherData.temp_c)}° · ${_esc(weatherData.desc?.split(' ')[0] || '')}
|
||
</div>` : '';
|
||
|
||
ov.innerHTML = `
|
||
<div id="qg-backdrop" class="w3-backdrop" style="background:rgba(0,0,0,0.6);backdrop-filter:blur(3px)"></div>
|
||
<div class="w3-sheet-panel" style="padding:24px 16px calc(env(safe-area-inset-bottom,16px) + 20px)">
|
||
<div class="w3-sheet-header w3-sheet-header--mb20">
|
||
<div>
|
||
<div class="w3-sheet-title">🐾 Schnell-Gassi</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||
${_esc(dog.name)} · ohne GPS
|
||
</div>
|
||
${weatherLine}
|
||
</div>
|
||
<button id="qg-close" class="w3-close-btn--lg">
|
||
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#x"></use></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div style="font-size:var(--text-sm);font-weight:600;margin-bottom:10px">Dauer</div>
|
||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:24px">
|
||
${durations.map(d => `
|
||
<button class="qg-dur w3-dur-btn${d === selectedMin ? ' active' : ''}" data-min="${d}">
|
||
${d} min
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<button id="qg-submit" class="w3-submit-btn">
|
||
<svg class="ph-icon" style="width:1.2rem;height:1.2rem"><use href="/icons/phosphor.svg#check"></use></svg>
|
||
Eintragen
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
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 = '<svg class="ph-icon" style="width:1.2rem;height:1.2rem"><use href="/icons/phosphor.svg#check"></use></svg> 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:'certificate', label:'Züchter', page:'breeder-dashboard', role:'breeder',
|
||
fab:[{ icon:'notebook', color:'#10B981', label:'Wurf anlegen', sub:'Neuen Wurf eintragen', page:'litters', action:'openNew' },
|
||
{ icon:'tree-structure', color:'#8B5CF6', label:'Zuchthund eintragen', sub:'Neuen Hund anlegen', page:'zuchthunde', action:'openNew' }] },
|
||
{ 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:'handshake', label:'Partner', page:'partner-dashboard', role:'partner' },
|
||
{ 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','partner-dashboard','admin'],
|
||
hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse',
|
||
'breeder-dashboard','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 === 'partner') return !!u?.is_partner || u?.rolle === 'admin';
|
||
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 }
|
||
let _removeHintShown = false; // „ausblenden ≠ löschen"-Toast nur einmal pro Session
|
||
|
||
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 ? '<div id="wc-bg" style="position:absolute;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px)"></div>' : ''}
|
||
<div id="wc-sheet" style="${_sheetStyle}">
|
||
<!-- Header -->
|
||
<div style="display:flex;align-items:center;justify-content:space-between;
|
||
padding:20px 20px 16px;position:sticky;top:0;background:rgba(18,22,32,0.97);
|
||
border-bottom:1px solid rgba(255,255,255,0.1);z-index:2">
|
||
<button id="wc-cancel" style="background:none;border:none;color:rgba(255,255,255,0.5);
|
||
cursor:pointer;font-size:var(--text-sm);padding:8px">Abbrechen</button>
|
||
<div style="font-size:var(--text-base);font-weight:700;color:white">Welten einrichten</div>
|
||
<button id="wc-save" style="background:var(--c-primary);color:white;border:none;
|
||
border-radius:999px;padding:7px 16px;cursor:pointer;font-weight:700;
|
||
font-size:var(--text-sm)">Fertig</button>
|
||
</div>
|
||
<!-- Hinweis + Reset -->
|
||
<div style="padding:10px 20px 6px;display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||
<div style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);flex:1">
|
||
Lang drücken & ziehen zum Verschieben. ✕ blendet aus (löscht nicht) —
|
||
ausgeblendete Funktionen bleiben über „Weitere Funktionen" abrufbar.
|
||
</div>
|
||
<button id="wc-reset" style="background:none;border:1px solid rgba(255,255,255,0.2);
|
||
color:rgba(255,255,255,0.5);border-radius:999px;padding:5px 12px;
|
||
cursor:pointer;font-size:11px;white-space:nowrap;flex-shrink:0">
|
||
Zurücksetzen
|
||
</button>
|
||
</div>
|
||
${['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 `
|
||
<div style="padding:12px 14px 6px">
|
||
<div style="font-size:10px;font-weight:800;letter-spacing:.1em;color:rgba(255,255,255,0.5);
|
||
margin-bottom:8px;display:flex;align-items:center;gap:8px">
|
||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;
|
||
background:${col}"></span>
|
||
${worldLabels[w]}
|
||
${w !== 'pool' ? `<span style="opacity:.5">(${chips.length})</span>` : ''}
|
||
</div>
|
||
<div class="wc-zone" data-zone="${w}"
|
||
style="display:grid;grid-template-columns:${_gridCols};grid-auto-rows:${_chipH};gap:${_isDesktop?'6px':'8px'};
|
||
min-height:${w==='pool'&&chips.length===0?'40px':'auto'};
|
||
border:2px dashed transparent;border-radius:16px;padding:4px;
|
||
transition:border-color .2s">
|
||
${chips.map(c => `
|
||
<div class="wc-chip" data-page="${c.page}" data-zone="${w}"
|
||
style="background:rgba(38,46,62,0.95);border:1.5px solid ${col};
|
||
border-radius:16px;padding:${_isDesktop?'6px 4px':'10px 4px 8px'};height:${_chipH};box-sizing:border-box;
|
||
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${_isDesktop?'3px':'5px'};
|
||
cursor:grab;position:relative;min-width:0;overflow:hidden;
|
||
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:pan-y">
|
||
${!c.pinned ? `
|
||
<button class="wc-remove" data-page="${c.page}" data-zone="${w}"
|
||
style="position:absolute;top:-10px;right:-10px;width:30px;height:30px;
|
||
border-radius:50%;background:#EF4444;border:3px solid rgba(18,22,32,0.95);
|
||
cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||
z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.7)">
|
||
<svg class="ph-icon" style="width:17px;height:17px;color:white;stroke:white;stroke-width:1.5">
|
||
<use href="/icons/phosphor.svg#x"></use>
|
||
</svg>
|
||
</button>` : `
|
||
<div style="position:absolute;top:-4px;right:-4px;width:14px;height:14px;
|
||
border-radius:50%;background:rgba(196,132,58,0.9);
|
||
display:flex;align-items:center;justify-content:center"
|
||
title="Immer sichtbar">
|
||
<svg class="ph-icon" style="width:8px;height:8px;color:white">
|
||
<use href="/icons/phosphor.svg#lock-simple"></use>
|
||
</svg>
|
||
</div>`}
|
||
${isAdmin && c.pro ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">P</span>` : ''}
|
||
${isAdmin && c.role === 'breeder' ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#1d4ed8;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">Z</span>` : ''}
|
||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0">
|
||
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
||
</svg>
|
||
<span style="font-size:9px;font-weight:600;color:rgba(255,255,255,0.9);
|
||
line-height:1.2;text-align:center;width:100%;
|
||
overflow:hidden;display:-webkit-box;
|
||
-webkit-line-clamp:2;-webkit-box-orient:vertical;
|
||
padding:0 2px">${c.label.replace('\n','<br>')}</span>
|
||
</div>
|
||
`).join('')}
|
||
${chips.length === 0 ? `<div style="grid-column:1/-1;text-align:center;
|
||
font-size:var(--text-xs);color:rgba(255,255,255,0.35);padding:12px">
|
||
${w==='pool'?'Alle Chips sind zugeordnet':'Leer — ziehe Chips hierher'}
|
||
</div>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
_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);
|
||
// Klarstellen: ausblenden ≠ löschen (einmal pro Session)
|
||
if (!_removeHintShown) {
|
||
_removeHintShown = true;
|
||
UI.toast?.info('Ausgeblendet, nicht gelöscht — über „Weitere Funktionen" jederzeit wieder einblendbar.');
|
||
}
|
||
}
|
||
_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
|
||
let _bgBrightness = null; // { top, bottom } Roh-Helligkeit (0–255) je Bildhälfte für adaptive Abdunklung
|
||
|
||
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 + adaptive Abdunklung neu anwenden
|
||
new MutationObserver(() => { _applyBgOrientation(); _applyAdaptiveDim(); })
|
||
.observe(document.documentElement, { attributeFilter: ['data-theme'] });
|
||
|
||
// Helligkeit (0–255) der oberen und unteren Bildhälfte via Mini-Canvas.
|
||
// Oben = Begrüßungs-Banner + JETZT-Chip-Reihe, unten = Feature-Chips.
|
||
// Gibt null zurück bei CORS-Tainting oder Fehler (→ Fallback-Abdunklung).
|
||
function _measureBrightnessRegions(img) {
|
||
try {
|
||
const c = document.createElement('canvas');
|
||
const w = c.width = 32, h = c.height = 32;
|
||
const ctx = c.getContext('2d', { willReadFrequently: true });
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
const data = ctx.getImageData(0, 0, w, h).data;
|
||
const half = h / 2;
|
||
let sumTop = 0, sumBot = 0, nTop = 0, nBot = 0;
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
const i = (y * w + x) * 4;
|
||
const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||
if (y < half) { sumTop += lum; nTop++; } else { sumBot += lum; nBot++; }
|
||
}
|
||
}
|
||
return { top: sumTop / nTop, bottom: sumBot / nBot };
|
||
} catch { return null; }
|
||
}
|
||
|
||
// Helligkeit (0–255) → Abdunklungs-Alpha. Im Dark Mode liegt zusätzlich
|
||
// ein 0.45-Overlay über dem Bild, daher wirkt es dort dunkler.
|
||
function _dimForBrightness(b) {
|
||
if (_isDarkMode()) b *= 0.55;
|
||
if (b <= 90) return 0.14;
|
||
if (b >= 190) return 0.48;
|
||
return 0.14 + (b - 90) / 100 * (0.48 - 0.14);
|
||
}
|
||
|
||
// Setzt --wbg-dim-top/-bottom adaptiv pro Bildbereich: heller Bereich →
|
||
// mehr Abdunklung (Lesbarkeit), dunkler → wenig.
|
||
function _applyAdaptiveDim() {
|
||
const r = _bgBrightness || { top: 110, bottom: 110 };
|
||
const dimTop = _dimForBrightness(r.top).toFixed(2);
|
||
const dimBot = _dimForBrightness(r.bottom).toFixed(2);
|
||
['wp-jetzt', 'wp-hund', 'wp-welt'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.style.setProperty('--wbg-dim-top', dimTop);
|
||
el.style.setProperty('--wbg-dim-bottom', dimBot);
|
||
});
|
||
}
|
||
|
||
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;
|
||
_bgBrightness = _measureBrightnessRegions(toLoad);
|
||
_applyAdaptiveDim();
|
||
_applyBgOrientation();
|
||
const hint = document.getElementById('wh-photo-hint');
|
||
if (hint) {
|
||
const seen = parseInt(localStorage.getItem('banyaro_wh_hint_seen') || '0');
|
||
if (seen < 2) {
|
||
localStorage.setItem('banyaro_wh_hint_seen', String(seen + 1));
|
||
setTimeout(() => {
|
||
hint.style.transition = 'opacity 0.6s';
|
||
hint.style.opacity = '0';
|
||
setTimeout(() => hint.remove(), 650);
|
||
}, 4000);
|
||
} else {
|
||
hint.remove();
|
||
}
|
||
}
|
||
};
|
||
toLoad.onerror = () => _applyBgImage(null);
|
||
toLoad.src = url;
|
||
} else {
|
||
_hasBgPhoto = false;
|
||
_bgUrl = null;
|
||
_bgBrightness = { top: 20, bottom: 20 }; // dunkler Fallback-Gradient → minimale Abdunklung
|
||
_applyAdaptiveDim();
|
||
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
|
||
? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;
|
||
color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px">P</span>`
|
||
: breederBadge
|
||
? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;
|
||
color:#fff;background:#1d4ed8;border-radius:3px;padding:0 3px;line-height:14px">Z</span>`
|
||
: '';
|
||
return `
|
||
<div class="world-chip" ${locked ? '' : `data-wnav="${page}"`}
|
||
style="${style}position:relative">
|
||
${badge}
|
||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem">
|
||
<use href="/icons/phosphor.svg#${icon}"></use>
|
||
</svg>
|
||
<span class="world-chip-label">${label}</span>
|
||
</div>`;
|
||
}
|
||
|
||
// ── 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
|
||
? `<span style="font-size:9px;opacity:0.5;margin-left:6px">· Offline</span>` : '';
|
||
|
||
// 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 ? `
|
||
<div class="world-reminder" style="border-color:rgba(196,132,58,0.6);
|
||
flex-direction:column;align-items:center;text-align:center;gap:6px;padding:12px 14px">
|
||
<div style="display:flex;gap:8px;align-items:center;justify-content:center">
|
||
<svg class="ph-icon bday-fw1" style="width:1.3rem;height:1.3rem;color:#f59e0b">
|
||
<use href="/icons/phosphor.svg#confetti"></use></svg>
|
||
<svg class="ph-icon bday-pop" style="width:1.8rem;height:1.8rem;color:#fff">
|
||
<use href="/icons/phosphor.svg#cake"></use></svg>
|
||
<svg class="ph-icon bday-fw2" style="width:1.3rem;height:1.3rem;color:#f59e0b">
|
||
<use href="/icons/phosphor.svg#confetti"></use></svg>
|
||
</div>
|
||
<div style="font-weight:800;font-size:var(--text-sm);color:#fff">
|
||
Alles Gute zum Geburtstag, ${_esc(firstName)}!
|
||
</div>
|
||
<div style="font-size:10px;color:rgba(255,255,255,0.55)">
|
||
Wir wünschen dir und deinem Hund einen wunderschönen Tag 🐾
|
||
</div>
|
||
</div>` : '';
|
||
|
||
// Alert-Reminder
|
||
const alertHtml = alertList.slice(0,1).map(a => `
|
||
<div class="world-reminder" data-wnav="${a.page}" style="border-color:rgba(239,68,68,0.5)">
|
||
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:#EF4444">
|
||
<use href="/icons/phosphor.svg#${a.icon}"></use>
|
||
</svg>
|
||
<span style="font-size:var(--text-xs);font-weight:700;color:#FCA5A5">${_esc(a.title)}</span>
|
||
</div>`).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 = `
|
||
<div data-wnav="settings" style="flex-shrink:0;cursor:pointer"
|
||
title="Mein Profil">
|
||
${uFoto
|
||
? `<img src="${_esc(uFoto)}" style="width:40px;height:40px;border-radius:50%;
|
||
object-fit:cover;border:2px solid rgba(255,255,255,0.35);display:block">`
|
||
: `<div style="width:40px;height:40px;border-radius:50%;background:var(--c-primary);
|
||
display:flex;align-items:center;justify-content:center;
|
||
font-size:var(--text-base);font-weight:800;color:white;
|
||
border:2px solid rgba(255,255,255,0.35)">${uInitial}</div>`}
|
||
</div>`;
|
||
|
||
el.innerHTML = `
|
||
<div class="world-top">
|
||
<div class="world-info-card">
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<div style="flex:1;min-width:0">
|
||
<div class="world-info-title">
|
||
${_esc(greet)}${firstName ? `, <span style="color:var(--c-primary)">${_esc(firstName)}</span>` : ''}${stale}
|
||
</div>
|
||
<div class="world-info-sub">${_esc(dayStr)}</div>
|
||
</div>
|
||
${user ? userAvatarHtml : ''}
|
||
</div>
|
||
</div>
|
||
${userBdayHtml}
|
||
${alertHtml}
|
||
${user && dog ? `
|
||
<div class="wj-chip-row">
|
||
<div class="wj-chip" data-wnav="wetter">
|
||
<div style="display:flex;align-items:center;gap:6px;width:100%">
|
||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||
gap:1px;flex-shrink:0;font-size:1.4rem;line-height:1">
|
||
${weatherEmoji.map(e => `<span>${e}</span>`).join('')}
|
||
</div>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:9px;color:rgba(255,255,255,0.55);font-weight:600;letter-spacing:.05em;text-transform:uppercase">Gassi-Score</div>
|
||
<div style="display:flex;align-items:baseline;gap:3px;margin-top:1px">
|
||
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
|
||
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
|
||
</div>
|
||
${w ? `<div style="font-size:9px;font-weight:500;margin-top:2px;line-height:1.5">
|
||
${w.location_name ? `<div style="color:rgba(255,255,255,0.5)">${w.location_name}</div>` : ''}
|
||
<div style="color:rgba(255,255,255,0.75)">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>
|
||
${w.rain_warning_time ? `<div style="color:#fbbf24;font-weight:700">⚠ Umschwung ab ${w.rain_warning_time}</div>` : w.next_rain_time ? `<div style="color:rgba(255,255,255,0.5)">ab ${w.next_rain_time} Uhr</div>` : ''}
|
||
</div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="wj-chip" data-wnav="routes">
|
||
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
|
||
<use href="/icons/phosphor.svg#path"></use></svg>
|
||
<span class="wj-chip-label">Gassirunde</span>
|
||
<span class="wj-chip-val" id="wj-route-val">…</span>
|
||
${totalKm != null ? `<span style="font-size:9px;color:rgba(255,255,255,0.4);margin-top:1px">∑ ${totalKm} km</span>` : ''}
|
||
</div>
|
||
<div class="wj-chip" id="wj-exercise-chip">
|
||
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
|
||
<use href="/icons/phosphor.svg#barbell"></use></svg>
|
||
<span class="wj-chip-label">Übung</span>
|
||
<span class="wj-chip-val" id="wj-exercise-val">…</span>
|
||
</div>
|
||
</div>` : ''}
|
||
</div>
|
||
<div class="world-bottom">
|
||
<div class="world-chips-grid">
|
||
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
|
||
</div>
|
||
<div class="world-footer-links">
|
||
<span data-wnav="impressum">Impressum</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 || 'Stand erfassen →';
|
||
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 = 'Stand erfassen →'; }
|
||
}
|
||
|
||
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 = `
|
||
<div class="world-info-card" style="text-align:center">
|
||
<div style="font-size:4rem;margin-bottom:12px">🐾</div>
|
||
<div class="world-info-title">Dein Hund wartet</div>
|
||
<div class="world-info-sub" style="margin-bottom:20px">Melde dich an um loszulegen</div>
|
||
<button class="btn btn-primary" id="world-login-btn">Anmelden</button>
|
||
</div>`;
|
||
el.querySelector('#world-login-btn')?.addEventListener('click', () => navigateTo('settings'));
|
||
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 = `
|
||
<div class="world-top">
|
||
<div class="world-info-card" style="text-align:center">
|
||
<div style="font-size:3.2rem;margin-bottom:10px">🐶</div>
|
||
<div class="world-info-title">Dein Hund wartet!</div>
|
||
<div class="world-info-sub" style="margin-bottom:16px">
|
||
Lege ein Profil an und schalte alle Features frei
|
||
</div>
|
||
<button class="btn btn-primary" id="welcome-add-dog-btn" style="width:100%">
|
||
Hund anlegen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="world-bottom">
|
||
<div class="world-section-label">Was dich erwartet</div>
|
||
<div class="world-chips-grid">
|
||
${features.map(f => `
|
||
<div class="world-chip" style="opacity:0.7;cursor:default">
|
||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:${f.color}">
|
||
<use href="/icons/phosphor.svg#${f.icon}"></use>
|
||
</svg>
|
||
<span class="world-chip-label">${f.title}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>`;
|
||
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
|
||
el.querySelector('#welcome-add-dog-btn')?.addEventListener('click', () => navigateTo('dog-profile'));
|
||
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
|
||
? `<img src="${_esc(dog.foto_url)}" style="width:44px;height:44px;border-radius:50%;
|
||
object-fit:cover;border:2px solid rgba(255,255,255,0.35);
|
||
cursor:pointer;flex-shrink:0" id="wh-avatar">`
|
||
: `<div id="wh-avatar" style="width:44px;height:44px;border-radius:50%;background:var(--c-primary);
|
||
display:flex;align-items:center;justify-content:center;
|
||
font-size:1.3rem;flex-shrink:0;cursor:pointer;
|
||
border:2px solid rgba(255,255,255,0.25)">🐶</div>`;
|
||
|
||
// Andere Hunde-Avatare (rechts, überlagernd, → Hund wechseln)
|
||
const otherAvatarsHtml = otherDogs.length > 0 ? `
|
||
<div style="display:flex;align-items:center;flex-shrink:0">
|
||
${otherDogs.slice(0, 4).map((d, i) => {
|
||
const targetIdx = _dogs.indexOf(d);
|
||
return `
|
||
<div class="wh-other-dog" data-idx="${targetIdx}"
|
||
style="width:30px;height:30px;border-radius:50%;overflow:hidden;
|
||
border:2px solid rgba(255,255,255,0.4);
|
||
margin-left:${i > 0 ? '-10px' : '0'};
|
||
cursor:pointer;position:relative;z-index:${10 - i}">
|
||
${d.foto_url
|
||
? `<img src="${_esc(d.foto_url)}" style="width:100%;height:100%;object-fit:cover">`
|
||
: `<div style="width:100%;height:100%;background:rgba(100,70,40,0.85);
|
||
display:flex;align-items:center;justify-content:center;
|
||
font-size:10px;font-weight:700;color:white">
|
||
${_esc(d.name?.charAt(0)?.toUpperCase() || '?')}
|
||
</div>`}
|
||
</div>`;
|
||
}).join('')}
|
||
</div>` : '';
|
||
|
||
el.innerHTML = `
|
||
<div class="world-top">
|
||
<div class="world-info-card">
|
||
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center">
|
||
<div style="justify-self:start">${dogAvatarHtml}</div>
|
||
<div id="wh-cycle-btn" style="text-align:center;
|
||
cursor:${_dogs.length > 1 ? 'pointer' : 'default'};padding:0 8px">
|
||
<div class="world-info-title">${_esc(dog.name)}</div>
|
||
${stats ? `<div class="world-info-sub">${_esc(stats)}</div>` : ''}
|
||
</div>
|
||
<div style="justify-self:end;display:flex;align-items:center">${otherAvatarsHtml}</div>
|
||
</div>
|
||
${otherBdayDog ? `
|
||
<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.12);
|
||
display:flex;align-items:center;gap:8px;cursor:pointer"
|
||
id="wh-other-bday-hint">
|
||
<span style="animation:by-bday-bounce 1.2s ease-in-out infinite;display:inline-flex">
|
||
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:#f59e0b" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#cake"></use>
|
||
</svg>
|
||
</span>
|
||
<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.75);font-weight:600">
|
||
${_esc(otherBdayDog.name)} hat ${_birthdayState(otherBdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag!
|
||
</span>
|
||
<svg class="ph-icon" style="width:.9rem;height:.9rem;color:rgba(196,132,58,0.8);margin-left:auto" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#arrow-circle-right"></use>
|
||
</svg>
|
||
</div>` : ''}
|
||
</div>
|
||
${bday ? `
|
||
<style>
|
||
@keyframes bday-pop {
|
||
0% { transform: scale(0.7) rotate(-8deg); opacity:0; }
|
||
60% { transform: scale(1.15) rotate(4deg); }
|
||
100% { transform: scale(1) rotate(0deg); opacity:1; }
|
||
}
|
||
@keyframes bday-fw1 { 0%,100%{transform:translateY(0) scale(1);opacity:1} 50%{transform:translateY(-8px) scale(1.3);opacity:0.7} }
|
||
@keyframes bday-fw2 { 0%,100%{transform:translateY(0) scale(1);opacity:1} 50%{transform:translateY(-12px) scale(1.4);opacity:0.6} }
|
||
@keyframes bday-fw3 { 0%,100%{transform:translateY(0) scale(1);opacity:1} 50%{transform:translateY(-6px) scale(1.2);opacity:0.8} }
|
||
.bday-pop { animation: bday-pop .5s cubic-bezier(.34,1.56,.64,1) both; }
|
||
.bday-fw1 { display:inline-block; animation: bday-fw1 1.4s ease-in-out infinite; }
|
||
.bday-fw2 { display:inline-block; animation: bday-fw2 1.1s ease-in-out infinite .2s; }
|
||
.bday-fw3 { display:inline-block; animation: bday-fw3 1.6s ease-in-out infinite .4s; }
|
||
</style>
|
||
<div class="world-reminder bday-pop" id="wh-bday-banner" style="flex-direction:column;align-items:center;
|
||
text-align:center;gap:6px;padding:14px 16px;cursor:pointer;
|
||
background:rgba(0,0,0,0.42);border-color:rgba(196,132,58,0.6)">
|
||
<div style="display:flex;gap:10px;align-items:center;justify-content:center">
|
||
<svg class="ph-icon bday-fw1" style="width:1.6rem;height:1.6rem;color:#f59e0b"><use href="/icons/phosphor.svg#confetti"></use></svg>
|
||
<svg class="ph-icon bday-pop" style="width:2.2rem;height:2.2rem;color:#fff"><use href="/icons/phosphor.svg#${bday === 'today' ? 'cake' : 'gift'}"></use></svg>
|
||
<svg class="ph-icon bday-fw2" style="width:1.6rem;height:1.6rem;color:#f59e0b"><use href="/icons/phosphor.svg#confetti"></use></svg>
|
||
</div>
|
||
<div style="font-weight:800;font-size:var(--text-sm);color:#fff;letter-spacing:0.01em">
|
||
${bday === 'today'
|
||
? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(bdayDog.name)}!`
|
||
: `Morgen hat ${_esc(bdayDog.name)} Geburtstag!`}
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<svg class="ph-icon bday-fw3" style="width:1rem;height:1rem;color:#e8c96e"><use href="/icons/phosphor.svg#sparkle"></use></svg>
|
||
<svg class="ph-icon bday-fw1" style="width:1rem;height:1rem;color:#f59e0b"><use href="/icons/phosphor.svg#star"></use></svg>
|
||
<svg class="ph-icon bday-fw2" style="width:1.1rem;height:1.1rem;color:#e8c96e"><use href="/icons/phosphor.svg#balloon"></use></svg>
|
||
<svg class="ph-icon bday-fw3" style="width:1rem;height:1rem;color:#f59e0b"><use href="/icons/phosphor.svg#star"></use></svg>
|
||
<svg class="ph-icon bday-fw1" style="width:1rem;height:1rem;color:#e8c96e"><use href="/icons/phosphor.svg#sparkle"></use></svg>
|
||
</div>
|
||
${bdayYear ? `<div style="font-size:10px;color:rgba(255,255,255,0.55)">
|
||
${bday === 'today' ? `${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} gemeinsam` : `Wird ${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} alt`}
|
||
</div>` : ''}
|
||
<div style="display:flex;align-items:center;gap:4px;font-size:10px;color:rgba(196,132,58,0.9);font-weight:700;margin-top:2px">
|
||
<svg class="ph-icon" style="width:11px;height:11px"><use href="/icons/phosphor.svg#magic-wand"></use></svg>
|
||
${bday === 'today' ? `Was hat sich ${_esc(bdayDog.name)} gewünscht?` : 'KI-Überraschungsideen'}
|
||
</div>
|
||
</div>
|
||
${bday === 'today' && new Date().getHours() >= 18 ? `
|
||
<div class="world-reminder" id="wh-bday-evening" style="cursor:pointer;
|
||
background:rgba(0,0,0,0.32);border-color:rgba(196,132,58,0.4)">
|
||
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:var(--c-primary)">
|
||
<use href="/icons/phosphor.svg#book-open"></use>
|
||
</svg>
|
||
<span style="font-size:var(--text-xs);font-weight:700;color:rgba(255,255,255,0.85)">
|
||
Halte den besonderen Tag im Tagebuch fest 🐾
|
||
</span>
|
||
</div>` : ''}` : ''}
|
||
</div>
|
||
<div class="world-bottom">
|
||
${!_hasBgPhoto ? `
|
||
<div id="wh-photo-hint" data-wnav="diary"
|
||
style="background:rgba(0,0,0,0.32);backdrop-filter:blur(12px);
|
||
-webkit-backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.12);
|
||
border-radius:16px;padding:11px 14px;display:flex;align-items:center;
|
||
gap:10px;cursor:pointer;color:white;flex-shrink:0">
|
||
<svg class="ph-icon" style="width:1.1rem;height:1.1rem;color:rgba(196,132,58,0.9);flex-shrink:0">
|
||
<use href="/icons/phosphor.svg#camera"></use>
|
||
</svg>
|
||
<div>
|
||
<div style="font-size:var(--text-xs);font-weight:700;color:rgba(255,255,255,0.85)">
|
||
Hintergrund-Foto hinzufügen
|
||
</div>
|
||
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:1px">
|
||
Tagebuchfotos im Querformat erscheinen hier als Panorama
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
<div class="world-chips-grid">
|
||
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
|
||
</div>
|
||
<div class="world-footer-links">
|
||
<span data-wnav="gruender">Die 100 Gründer</span>
|
||
<span>·</span>
|
||
<span data-wnav="partner">Unsere Partner</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 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 = `
|
||
<div class="w3-backdrop"></div>
|
||
<div class="w3-sheet-panel w3-sheet-panel--scroll">
|
||
<div class="w3-sheet-header w3-sheet-header--mb20">
|
||
<div class="w3-sheet-title">${title}</div>
|
||
<button id="bday-ki-close" class="w3-close-btn">
|
||
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
|
||
</button>
|
||
</div>
|
||
<div id="bday-ki-body" style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7">
|
||
<div style="display:flex;align-items:center;gap:10px;color:var(--c-text-secondary);padding:var(--space-4) 0">
|
||
<svg class="ph-icon" style="width:20px;height:20px"><use href="/icons/phosphor.svg#sparkle"></use></svg>
|
||
KI denkt nach…
|
||
</div>
|
||
</div>
|
||
${isToday ? `
|
||
<button id="bday-diary-btn" style="margin-top:var(--space-4);width:100%;display:flex;align-items:center;
|
||
justify-content:center;gap:var(--space-2);padding:var(--space-3);border-radius:var(--radius-md);
|
||
border:1px solid var(--c-primary);background:transparent;color:var(--c-primary);
|
||
font-size:var(--text-sm);font-weight:600;cursor:pointer">
|
||
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#book-open"></use></svg>
|
||
Besonderen Tagebucheintrag anlegen
|
||
</button>` : ''}
|
||
</div>`;
|
||
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, '<strong>$1</strong>')
|
||
.replace(/\n/g, '<br>');
|
||
body.innerHTML = `<div style="padding-bottom:var(--space-2)">${html}</div>`;
|
||
}
|
||
} 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 = `<div style="color:var(--c-text-secondary)">${msg}</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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' },
|
||
{ t:"Wenn du einen verhungernden Hund aufnimmst und es ihm gutgehen lässt, wird er dich nicht beißen. Das ist der wesentliche Unterschied zwischen Hund und Mensch.", a:"Mark Twain" },
|
||
{ t:"Der Hund ist ein Gentleman. Ich hoffe, in seinen Himmel zu kommen, nicht in den der Menschen.", a:"Mark Twain" },
|
||
{ t:"Je mehr ich über die Menschen lerne, desto mehr liebe ich meinen Hund.", a:"Mark Twain" },
|
||
{ t:"Der Himmel vergibt nach Gunst. Ginge es nach Verdienst, bliebest du draußen und dein Hund käme hinein.", a:"Mark Twain" },
|
||
{ t:"Die Treue eines Hundes ist ein kostbares Geschenk, das nicht weniger bindende Pflichten auferlegt als die Freundschaft eines Menschen.", a:"Konrad Lorenz" },
|
||
{ t:"Es gibt keine Treue, die nicht schon einmal gebrochen worden wäre, außer der eines wahrhaft treuen Hundes.", a:"Konrad Lorenz" },
|
||
{ t:"Die Bindung an einen echten Hund ist so beständig, wie es Bande auf dieser Erde nur sein können.", a:"Konrad Lorenz" },
|
||
{ t:"Der Wunsch, einen Hund zu halten, entspringt der uralten Sehnsucht des zivilisierten Menschen nach dem verlorenen Paradies.", a:"Konrad Lorenz" },
|
||
{ t:"Wenn ich daran denke, dass mein Hund mich mehr liebt, als ich ihn, dann beschämt mich das.", a:"Konrad Lorenz" },
|
||
{ t:"Der Hund ist, mit Recht, das Sinnbild der Treue.", a:"Arthur Schopenhauer" },
|
||
{ t:"Woran sollte man sich von der Falschheit der Menschen erholen, wenn die Hunde nicht wären, in deren ehrliches Gesicht man ohne Misstrauen blicken kann.", a:"Arthur Schopenhauer" },
|
||
{ t:"Der Anblick jedes Tieres erfreut mich unmittelbar und mir geht dabei das Herz auf, am meisten bei dem der Hunde.", a:"Arthur Schopenhauer" },
|
||
{ t:"Hunde lieben ihre Freunde und beißen ihre Feinde, ganz anders als Menschen, die niemals rein lieben, sondern stets Liebe und Hass vermengen.", a:"Sigmund Freud" },
|
||
{ t:"Hunde schenken Zuneigung ohne Zwiespalt und die Schönheit eines Daseins, das ganz in sich ruht.", a:"Sigmund Freud" },
|
||
{ t:"Gibt es im Himmel keine Hunde, dann will ich, wenn ich sterbe, dorthin gehen, wo sie hingekommen sind.", a:"Will Rogers" },
|
||
{ t:"Für seinen Hund ist jeder Mensch ein Napoleon, daher die anhaltende Beliebtheit der Hunde.", a:"Aldous Huxley" },
|
||
{ t:"Bevor du einen Hund hast, kannst du dir kaum vorstellen, wie das Leben mit ihm wäre. Danach kannst du dir kein anderes Leben mehr vorstellen.", a:"Caroline Knapp" },
|
||
{ t:"Wer einmal einen wundervollen Hund hatte, dessen Leben ist ohne einen ärmer.", a:"Dean Koontz" },
|
||
{ t:"Einen Hund zu streicheln und zu kraulen kann den Geist so beruhigen wie tiefe Meditation und ist fast so gut für die Seele wie ein Gebet.", a:"Dean Koontz" },
|
||
{ t:"Tausendmal hat er mir gesagt, dass ich sein Grund zu leben bin, durch die Art, wie er sich an mein Bein lehnt.", a:"Gene Hill" },
|
||
{ t:"Hunde sind unser Bindeglied zum Paradies. Sie kennen weder Bosheit noch Neid noch Unzufriedenheit.", a:"Milan Kundera" },
|
||
{ t:"Mit einem Hund an einem schönen Nachmittag auf einem Hügel zu sitzen ist wie eine Rückkehr nach Eden.", a:"Milan Kundera" },
|
||
{ t:"Hunde sprechen sehr wohl, doch nur zu denen, die zu lauschen verstehen.", a:"Orhan Pamuk" },
|
||
{ t:"Tiere sind so angenehme Freunde, sie stellen keine Fragen und üben keine Kritik.", a:"George Eliot" },
|
||
{ t:"Das größte Vergnügen mit einem Hund ist, dass man sich vor ihm zum Narren machen kann und er nicht nur nicht tadelt, sondern selbst mitmacht.", a:"Samuel Butler" },
|
||
{ t:"Bedenke, dass der Allmächtige, der uns den Hund zum Gefährten gab, ihm ein edles Wesen verlieh, das des Betrugs unfähig ist.", a:"Sir Walter Scott" },
|
||
{ t:"Ich habe oft über den Grund nachgedacht, warum Hunde so kurz leben, und bin überzeugt, es geschieht aus Mitleid mit dem Menschen.", a:"Sir Walter Scott" },
|
||
{ t:"Hunde beißen mich nie. Nur Menschen.", a:"Marilyn Monroe" },
|
||
{ t:"Wer hält dich für so großartig wie dein Hund.", a:"Audrey Hepburn" },
|
||
{ t:"Ich gehe mit meinen Hunden, das hält mich fit. Ich rede mit meinen Hunden, das hält mich gesund.", a:"Audrey Hepburn" },
|
||
{ t:"Sobald er seinen Herrn erblickte, ließ Argos die Ohren sinken und wedelte mit dem Schwanz, doch zu ihm hin zu kommen vermochte er nicht mehr.", a:"Homer" },
|
||
{ t:"Hunde und Philosophen tun das meiste Gute und erhalten den geringsten Lohn.", a:"Diogenes" },
|
||
{ t:"Hunde sind besser als Menschen, denn sie wissen, doch sie verraten es nicht.", a:"Emily Dickinson" },
|
||
{ t:"Bei Schlachten, die das Schicksal von Völkern entschieden, blieb ich ungerührt. Hier aber, beim Kummer eines einzigen Hundes, war ich zu Tränen gerührt.", a:"Napoleon Bonaparte" },
|
||
{ t:"Wenn Hunde in den Himmel kommen, brauchen sie keine Flügel, denn Gott weiß, dass Hunde das Laufen am meisten lieben.", a:"Cynthia Rylant" },
|
||
{ t:"Meine ganze Bestimmung war es, ihn zu lieben, bei ihm zu sein und ihn glücklich zu machen.", a:"W. Bruce Cameron" },
|
||
{ t:"Meine Hundefreunde scheinen meine Grenzen zu verstehen und bleiben immer dicht an meiner Seite.", a:"Helen Keller" },
|
||
{ t:"Gib einem Hund dein Herz, das er zerreißen kann.", a:"Rudyard Kipling" },
|
||
{ t:"Ein Hund lehrt einen Jungen Treue, Beharrlichkeit und sich dreimal zu drehen, bevor man sich hinlegt.", a:"Robert Benchley" },
|
||
{ t:"Du kannst zu einem Hund den größten Unsinn sagen, und er schaut dich an, als wollte er sagen: Donnerwetter, du hast recht, darauf wäre ich nie gekommen.", a:"Dave Barry" },
|
||
{ t:"Wenn ich an die Unsterblichkeit glaube, dann daran, dass gewisse Hunde in den Himmel kommen, und nur sehr, sehr wenige Menschen.", a:"James Thurber" },
|
||
{ t:"Eine Tür ist das, auf deren falscher Seite ein Hund sich ständig befindet.", a:"Ogden Nash" },
|
||
{ t:"Natürlich kann man ohne Hund leben, es lohnt sich nur nicht.", a:"Heinz Rühmann" },
|
||
{ t:"Ein Leben ohne Mops ist möglich, aber sinnlos.", a:"Loriot" },
|
||
{ t:"Die Welt wäre ein schönerer Ort, wenn jeder so bedingungslos lieben könnte wie ein Hund.", a:"M.K. Clinton" },
|
||
{ t:"Der durchschnittliche Hund ist ein netterer Mensch als der durchschnittliche Mensch.", a:"Andy Rooney" },
|
||
{ t:"Niemand schätzt das ganz besondere Genie deiner Unterhaltung so sehr wie dein Hund.", a:"Christopher Morley" },
|
||
{ t:"Ich habe meinem Schmerz einen Namen gegeben und nenne ihn Hund, denn er ist ebenso treu und klug wie jeder andere Hund.", a:"Friedrich Nietzsche" },
|
||
{ t:"Dem Hunde, wenn er gut gezogen, wird selbst ein weiser Mann gewogen.", a:"Johann Wolfgang von Goethe" },
|
||
{ t:"Verstößt ein Herr seinen Hund, weil er ihm das Brot nicht mehr verdienen kann, so zeigt das stets eine sehr kleine Seele des Herrn an.", a:"Immanuel Kant" },
|
||
{ t:"Wenn du keinen Hund besitzt, ist nicht unbedingt etwas mit dir verkehrt, aber vielleicht stimmt etwas mit deinem Leben nicht.", a:"Roger Caras" },
|
||
{ t:"Alte Hunde sind wie alte Schuhe, bequem. Sie sind vielleicht etwas aus der Form, aber sie passen einfach gut.", a:"Bonnie Wilcox" },
|
||
{ t:"Kommt ein Hund nicht zu dir, nachdem er dir ins Gesicht gesehen hat, so solltest du heimgehen und dein Gewissen prüfen.", a:"Woodrow Wilson" },
|
||
{ t:"Springt ein Hund auf deinen Schoß, dann weil er dich mag. Tut eine Katze dasselbe, ist es nur, weil dein Schoß wärmer ist.", a:"Alfred North Whitehead" },
|
||
{ t:"Geld kann dir einen feinen Hund kaufen, aber nur Liebe bringt ihn dazu, mit dem Schwanz zu wedeln.", a:"Kinky Friedman" },
|
||
{ t:"Wenn ich für eine Reise den Koffer hervorhole, weiß er es lange vorher und gerät in einen Zustand milder Aufregung.", a:"John Steinbeck" },
|
||
{ t:"Ein Hund ist ein Band zwischen Fremden.", a:"John Steinbeck" },
|
||
{ t:"Mein kleiner Hund, ein Herzschlag zu meinen Füßen.", a:"Edith Wharton" },
|
||
{ t:"Geschaffen wurde der Hund eigens für die Kinder. Er ist der Gott des Übermuts.", a:"Henry Ward Beecher" },
|
||
{ t:"Hunde sind klug. Sie kriechen in eine stille Ecke und lecken ihre Wunden und kehren erst in die Welt zurück, wenn sie wieder heil sind.", a:"Agatha Christie" },
|
||
{ t:"Schönheit ohne Eitelkeit, Stärke ohne Übermut, Mut ohne Wildheit und alle Tugenden des Menschen ohne seine Laster.", a:"Lord Byron" },
|
||
{ t:"Gott dreht Wolken um und um, um den Hunden im Hundehimmel flauschige Betten zu bereiten.", a:"Cynthia Rylant" },
|
||
{ t:"Hunde besitzen eine Eigenschaft, die unter Menschen selten ist, nämlich zu erkennen, wer Hilfe braucht, und sie zu geben.", a:"Caroline Knapp" },
|
||
{ t:"In manchen Dingen ist mein Hund klüger als ich, in anderen ist er bodenlos unwissend.", a:"John Steinbeck" },
|
||
{ t:"Ein Hund zur Hand ist besser als ein Bruder in der Ferne.", a:"Persisches Sprichwort" },
|
||
{ t:"Wenn du bei jedem bellenden Hund stehen bleibst, beendest du deine Reise nie.", a:"Arabisches Sprichwort" },
|
||
{ t:"Es ist schwer, einen so treuen Gefährten zu finden wie einen Hund.", a:"Mongolisches Sprichwort" },
|
||
{ t:"Ein Hund ohne Schwanz kann nicht zeigen, dass er sich freut.", a:"Albanisches Sprichwort" },
|
||
{ t:"Ein Hund, der mit dem Schwanz wedelt, bezieht keine Prügel.", a:"Japanisches Sprichwort" },
|
||
{ t:"Der hungrige Hund fürchtet den Stock nicht.", a:"Japanisches Sprichwort" },
|
||
{ t:"Treffen sich im Paradies eine Menschenseele und eine Hundeseele, verneigt sich der Mensch vor dem Hund.", a:"Sibirisches Sprichwort" },
|
||
{ t:"Solange der Mensch denkt, Tiere fühlten nicht, fühlen Tiere, dass der Mensch nicht denkt.", a:"Indianische Weisheit" },
|
||
{ t:"Mit Hunden zu leben tut dem Menschen gut.", a:"Tibetisches Sprichwort" },
|
||
{ t:"Ein Hund ist ein Herz auf vier Beinen.", a:"Irisches Sprichwort" },
|
||
{ t:"Der Hund vergisst den einen Bissen nicht, und wirfst du ihm auch hundert Steine nach.", a:"Chinesisches Sprichwort" },
|
||
{ t:"Sei der Freund meines Hundes, dann bist du auch der meine.", a:"Indianische Weisheit" },
|
||
{ t:"Hüte dich vor dem Menschen, der nicht spricht, und vor dem Hund, der nicht bellt.", a:"Indianische Weisheit" },
|
||
{ t:"Ein kluger Hund bellt nicht ohne Grund.", a:"Französisches Sprichwort" },
|
||
{ t:"In seiner eigenen Hütte ist jeder Hund ein Löwe.", a:"Französisches Sprichwort" },
|
||
{ t:"Hunde, die sich beißen, halten gegen den Wolf zusammen.", a:"Armenisches Sprichwort" },
|
||
{ t:"Der Hund bellt, doch die Karawane zieht weiter.", a:"Türkisches Sprichwort" },
|
||
{ t:"Hat ein Armer den Hund großgezogen, folgt er keinem Reichen mehr.", a:"Mongolisches Sprichwort" },
|
||
{ t:"Hat der Hund zu viele Herren, schläft er hungrig ein.", a:"Afrikanisches Sprichwort" },
|
||
{ t:"Ich hoffe, einmal der Mensch zu werden, für den mein Hund mich hält.", a:"Ungarisches Sprichwort" },
|
||
{ t:"Eines Hundes Treue währt ein ganzes Leben lang.", a:"Spanisches Sprichwort" },
|
||
{ t:"Faule Schäfer haben die besten Hunde.", a:"Deutsches Sprichwort" },
|
||
{ t:"Hunde, die viel bellen, beißen selten.", a:"Italienisches Sprichwort" },
|
||
{ t:"Wer einen guten Hund hat, braucht keinen Wächter.", a:"Italienisches Sprichwort" },
|
||
{ t:"Wo der Hund frei laufen darf, ist das Glück nicht weit.", a:"Unbekannt" },
|
||
{ t:"Ein Hund schaut nicht auf deinen Stand, nur auf dein Herz.", a:"Unbekannt" },
|
||
{ t:"Dem Hund ist gleich, ob du reich bist; ihm reicht, dass du heimkommst.", a:"Unbekannt" },
|
||
{ t:"Ein Hund braucht keine Worte, um dich zu trösten.", a:"Unbekannt" },
|
||
{ t:"Der beste Platz der Welt ist neben einem Hund.", a:"Unbekannt" },
|
||
{ t:"Ein Tag mit Hund ist nie ganz verloren.", a:"Unbekannt" },
|
||
{ t:"Ein Hund füllt die Stille im Haus mit leisem Glück.", a:"Unbekannt" },
|
||
{ t:"Hunde messen die Zeit nicht in Stunden, sondern in Spaziergängen.", a:"Unbekannt" },
|
||
{ t:"Ein Hund findet zum Glück immer den kürzesten Weg.", a:"Unbekannt" },
|
||
{ t:"Mit einem Hund an der Seite läuft man nie allein.", a:"Unbekannt" },
|
||
{ t:"Ein Hund vergibt schneller, als wir uns entschuldigen können.", a:"Unbekannt" },
|
||
{ t:"Wer die Sprache der Hunde lernt, hört auf zu reden und beginnt zu fühlen.", a:"Unbekannt" },
|
||
{ t:"Ein Hund kennt deinen Namen nicht, aber er kennt dein Herz.", a:"Unbekannt" },
|
||
{ t:"Hunde sind die Pünktlichsten, wenn es ums Glücklichsein geht.", a:"Unbekannt" },
|
||
{ t:"Ein Hund wartet nicht auf morgen, um dich heute zu lieben.", a:"Unbekannt" },
|
||
{ t:"Manche Engel haben Fell und kalte Pfoten.", a:"Unbekannt" },
|
||
{ t:"Ein Hund nimmt dich, wie du bist, und macht dich trotzdem besser.", a:"Unbekannt" },
|
||
{ t:"Was ein Hund über Freundschaft weiß, lernt der Mensch ein Leben lang.", a:"Unbekannt" },
|
||
{ t:"Hunde haben kurze Leben, weil sie das Lieben so gut können, dass sie keine Zeit verschwenden.", a:"Unbekannt" },
|
||
{ t:"Glück hat vier Pfoten und einen wedelnden Schwanz.", a:"Unbekannt" },
|
||
{ t:"Ein Hund teilt dein Schweigen, ohne es zu füllen.", a:"Unbekannt" },
|
||
{ t:"Der treueste Blick der Welt kommt von unten und wedelt dabei.", a:"Unbekannt" },
|
||
{ t:"Ein Hund braucht keinen Sonntag, jeder Tag mit dir ist ihm Feiertag.", a:"Unbekannt" },
|
||
{ t:"Wo ein Hund die Stiefel bringt, fehlt es nie an Liebe.", a:"Unbekannt" },
|
||
{ t:"Hunde rechnen nicht nach, wie viel du gibst; sie geben einfach alles zurück.", a:"Unbekannt" },
|
||
{ t:"Ein Hund sieht nicht, wie du aussiehst, sondern wer du bist.", a:"Unbekannt" },
|
||
{ t:"Ein Hund macht aus einem Spaziergang ein kleines Abenteuer.", a:"Unbekannt" },
|
||
{ t:"Wer einem Hund ins Auge sieht, schaut der Ehrlichkeit beim Atmen zu.", a:"Unbekannt" },
|
||
{ t:"Ein Hund spürt deinen schlechten Tag und bleibt trotzdem.", a:"Unbekannt" },
|
||
{ t:"Das Schwierigste am Hundeleben ist, dass es zu kurz für so viel Liebe ist.", a:"Unbekannt" },
|
||
{ t:"Ein Hund braucht wenig und schenkt davon das Meiste.", a:"Unbekannt" },
|
||
{ t:"Hunde sind der Beweis, dass Treue keine Worte braucht.", a:"Unbekannt" },
|
||
{ t:"Ein wedelnder Schwanz hat schon manchen Tag gerettet.", a:"Unbekannt" },
|
||
{ t:"Wer einen Hund versteht, braucht den Menschen weniger zu erklären.", a:"Unbekannt" },
|
||
{ t:"Ein Hund spart seine Liebe nicht auf, er verschenkt sie sofort und vollständig.", a:"Unbekannt" },
|
||
{ t:"Die kürzeste Verbindung zwischen zwei Menschen ist manchmal eine Hundeleine.", a:"Unbekannt" },
|
||
{ t:"Ein Hund kennt keinen Stolz, nur Wiedersehensfreude.", a:"Unbekannt" },
|
||
{ t:"Wer mit einem Hund alt wird, lernt das Glück im Kleinen.", a:"Unbekannt" },
|
||
{ t:"Ein Hund hält dir keine Reden, er hält dir die Treue.", a:"Unbekannt" },
|
||
{ t:"Vor einem Hund muss man nichts vorgeben; er liebt das Echte.", a:"Unbekannt" },
|
||
{ t:"Ein Hund schreibt keine Briefe, doch sein Schwanz erzählt alles.", a:"Unbekannt" },
|
||
{ t:"Hunde nehmen die kleinen Dinge ernst, darum sind sie so groß im Lieben.", a:"Unbekannt" },
|
||
{ t:"Ein Hund verzeiht dir den Regen, solange du mit ihm hinausgehst.", a:"Unbekannt" },
|
||
{ t:"Wer das Vertrauen eines Hundes gewinnt, hat etwas Selteneres als Gold.", a:"Unbekannt" },
|
||
{ t:"Ein Hund macht stille Tage warm und laute Tage leiser.", a:"Unbekannt" },
|
||
{ t:"Die treueste Uhr im Haus ist der Hund vor dem Futternapf.", a:"Unbekannt" },
|
||
{ t:"Ein Hund fragt nicht, wie der Tag war; er macht ihn einfach besser.", a:"Unbekannt" },
|
||
{ t:"Wer einen Hund an der Seite hat, ist nirgends ganz fremd.", a:"Unbekannt" },
|
||
{ t:"Ein Hund kennt nur ein Tempo bei der Liebe: sofort.", a:"Unbekannt" },
|
||
{ t:"Ein Hund am Feuer wärmt mehr als das Feuer selbst.", a:"Unbekannt" },
|
||
{ t:"Wer den Hund gut behandelt, dem öffnet sich das Herz von selbst.", a:"Unbekannt" },
|
||
{ t:"Ein Hund braucht keinen Kalender, er weiß genau, wann du nach Hause kommst.", a:"Unbekannt" },
|
||
];
|
||
|
||
// ── ALBUM (Ban Yaro — eigene Songs) ──────────────────────────
|
||
// Banner in der WELT-Welt lädt zum Entdecken ein und verschwindet nach erstem
|
||
// Öffnen. Ab dann: dezenter runder Button unten links (Gegenspieler zum FAB),
|
||
// der das Album-Modal öffnet (Liste mit Play je Titel). Audio-Element lebt
|
||
// zentral in index.html → übersteht Re-Renders & Welt-Wechsel.
|
||
const _anthem = (() => {
|
||
const KEY = 'by_anthem_heard';
|
||
const LANG_KEY = 'by_album_lang';
|
||
// EN-Album erst sichtbar, wenn die 7 *-en.mp3 in static/sounds/ liegen.
|
||
// Aktivieren: Dateien ablegen → EN_READY = true → make bump → deploy.
|
||
const EN_READY = true; // 2026-06-16: 7 *-en.mp3 liegen in static/sounds/
|
||
const SONGS_DE = [
|
||
{ title: 'Ban Yaro Blues', sub: 'Die Hymne', file: '/sounds/ban-yaro-blues.mp3?v=2' },
|
||
{ title: 'Ban Yaro Mobil', sub: 'Erste Fahrt im Anhänger', file: '/sounds/ban-yaro-mobil.mp3' },
|
||
{ title: 'Amy', sub: 'Eine Liebesromanze', file: '/sounds/amy.mp3' },
|
||
{ title: 'Beim Friseur', sub: 'Halbes Fell, Energie pur', file: '/sounds/beim-friseur.mp3' },
|
||
{ title: 'Leckerli-Paradies', sub: 'Voller Napf, volles Glück', file: '/sounds/leckerli-paradies.mp3' },
|
||
{ title: 'Platsch!', sub: 'Ab ins kühle Nass', file: '/sounds/platsch.mp3' },
|
||
{ title: 'Bester Freund', sub: 'Du und ich', file: '/sounds/bester-freund.mp3' },
|
||
];
|
||
const SONGS_EN = [
|
||
{ title: 'Ban Yaro Blues', sub: 'The anthem', file: '/sounds/ban-yaro-blues-en.mp3' },
|
||
{ title: 'Ban Yaro Mobile', sub: 'First ride in the trailer', file: '/sounds/ban-yaro-mobil-en.mp3' },
|
||
{ title: 'Amy', sub: 'A love duet', file: '/sounds/amy-en.mp3' },
|
||
{ title: "At the Groomer's", sub: 'Half the fur, all the energy', file: '/sounds/at-the-groomers-en.mp3' },
|
||
{ title: 'Treat Paradise', sub: 'Full bowl, full heart', file: '/sounds/treat-paradise-en.mp3' },
|
||
{ title: 'Splash!', sub: 'Into the cool water', file: '/sounds/splash-en.mp3' },
|
||
{ title: 'Best Friend', sub: 'You and me', file: '/sounds/best-friend-en.mp3' },
|
||
];
|
||
let _lang = (() => {
|
||
try { return (EN_READY && localStorage.getItem(LANG_KEY) === 'en') ? 'en' : 'de'; } catch (_) { return 'de'; }
|
||
})();
|
||
const _songs = () => (_lang === 'en' && EN_READY) ? SONGS_EN : SONGS_DE;
|
||
let _bound = false, _curIdx = -1;
|
||
const _audio = () => document.getElementById('anthem-audio');
|
||
// Entdeckt? Server-Flag (geräteübergreifend, deploy-fest) ODER lokal (sofort/offline).
|
||
const heard = () => {
|
||
if (_state?.user?.anthem_heard) return true;
|
||
try { return localStorage.getItem(KEY) === '1'; } catch (_) { return false; }
|
||
};
|
||
|
||
function _markHeard() {
|
||
try { localStorage.setItem(KEY, '1'); } catch (_) {}
|
||
if (!_state?.user?.anthem_heard) { // server-seitig genau einmal merken
|
||
if (_state?.user) _state.user.anthem_heard = 1;
|
||
try { API.post('/profile/anthem-heard', {}).catch(() => {}); } catch (_) {}
|
||
}
|
||
document.getElementById('ww-anthem-card')?.classList.add('hidden'); // Banner live ausblenden
|
||
updateButton();
|
||
}
|
||
|
||
// Runder Button (orange wenn etwas läuft) + Modal-Songzeilen an Wiedergabe angleichen.
|
||
function _sync() {
|
||
const a = _audio();
|
||
const playing = !!(a && !a.paused && !a.ended);
|
||
document.getElementById('worlds-anthem')?.classList.toggle('playing', playing);
|
||
document.querySelectorAll('#album-modal .album-song').forEach((row, i) => {
|
||
const active = (i === _curIdx) && playing;
|
||
row.classList.toggle('album-song--active', active);
|
||
row.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${active ? 'pause' : 'play'}`);
|
||
});
|
||
}
|
||
|
||
function _bindAudio() {
|
||
if (_bound) return;
|
||
const a = _audio();
|
||
if (!a) return;
|
||
_bound = true;
|
||
a.addEventListener('play', _sync);
|
||
a.addEventListener('pause', _sync);
|
||
a.addEventListener('ended', () => { // automatisch zum nächsten Song
|
||
if (_curIdx >= 0 && _curIdx < _songs().length - 1) _play(_curIdx + 1);
|
||
else { _curIdx = -1; _sync(); }
|
||
});
|
||
}
|
||
|
||
function _play(i) {
|
||
const a = _audio();
|
||
const songs = _songs();
|
||
if (!a || !songs[i]) return;
|
||
if (i === _curIdx && !a.paused) { a.pause(); return; } // aktiven Song pausieren
|
||
_curIdx = i;
|
||
a.src = songs[i].file;
|
||
a.play().catch(() => {});
|
||
_markHeard();
|
||
_sync();
|
||
}
|
||
|
||
function _closeAlbum() { document.getElementById('album-modal')?.remove(); }
|
||
|
||
// Sprache wechseln: aktuelle Wiedergabe stoppen (andere Datei) und Liste neu zeichnen.
|
||
function _setLang(l) {
|
||
if (l === _lang || !EN_READY) return;
|
||
_lang = l;
|
||
try { localStorage.setItem(LANG_KEY, l); } catch (_) {}
|
||
const a = _audio(); if (a) a.pause();
|
||
_curIdx = -1;
|
||
_fillAlbum();
|
||
_sync();
|
||
}
|
||
|
||
// Inhalt des Sheets (neu) rendern + innere Controls binden — auch bei Sprachwechsel.
|
||
function _fillAlbum() {
|
||
const sheet = document.querySelector('#album-modal .album-sheet');
|
||
if (!sheet) return;
|
||
const songs = _songs();
|
||
const en = _lang === 'en';
|
||
sheet.innerHTML = `
|
||
<div class="album-head">
|
||
<div>
|
||
<div class="album-title">${en ? 'Ban Yaro — The Album' : 'Ban Yaro — das Album'}</div>
|
||
<div class="album-subtitle">${songs.length} ${en ? 'songs · homemade' : 'Songs · selbst gemacht'} 🎸</div>
|
||
</div>
|
||
<div class="album-head-actions">
|
||
${EN_READY ? `
|
||
<div class="album-lang" role="group" aria-label="Sprache / Language">
|
||
<button class="album-lang-btn ${en ? '' : 'is-active'}" data-lang="de" type="button">DE</button>
|
||
<button class="album-lang-btn ${en ? 'is-active' : ''}" data-lang="en" type="button">EN</button>
|
||
</div>` : ''}
|
||
<button class="album-close" aria-label="${en ? 'Close' : 'Schließen'}">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="album-list">
|
||
${songs.map((s, i) => `
|
||
<div class="album-song" data-i="${i}" role="button" tabindex="0">
|
||
<span class="album-song-play"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#play"></use></svg></span>
|
||
<span class="album-song-meta">
|
||
<span class="album-song-title">${_esc(s.title)}</span>
|
||
<span class="album-song-sub">${_esc(s.sub)}</span>
|
||
</span>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
sheet.querySelector('.album-close').addEventListener('click', _closeAlbum);
|
||
sheet.querySelectorAll('.album-lang-btn').forEach(b =>
|
||
b.addEventListener('click', () => _setLang(b.dataset.lang)));
|
||
sheet.querySelectorAll('.album-song').forEach(row => {
|
||
const i = parseInt(row.dataset.i, 10);
|
||
row.addEventListener('click', () => _play(i));
|
||
row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _play(i); } });
|
||
});
|
||
}
|
||
|
||
function openAlbum() {
|
||
_markHeard();
|
||
if (document.getElementById('album-modal')) return;
|
||
const ov = document.createElement('div');
|
||
ov.id = 'album-modal';
|
||
ov.innerHTML = `<div class="album-sheet"></div>`;
|
||
document.body.appendChild(ov);
|
||
ov.addEventListener('click', e => { if (e.target === ov) _closeAlbum(); });
|
||
_fillAlbum();
|
||
_sync();
|
||
}
|
||
|
||
function updateButton() {
|
||
document.getElementById('worlds-anthem')?.classList.toggle('hidden', !(_cur === 2 && heard()));
|
||
}
|
||
|
||
// Bei jedem WELT-Render: Audio-Listener sichern, Klicks binden, Status setzen.
|
||
function initWelt() {
|
||
_bindAudio();
|
||
const card = document.getElementById('ww-anthem-card');
|
||
if (card && !card._anthemBound) {
|
||
card._anthemBound = true;
|
||
card.addEventListener('click', openAlbum);
|
||
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openAlbum(); } });
|
||
}
|
||
const btn = document.getElementById('worlds-anthem');
|
||
if (btn && !btn._anthemBound) { btn._anthemBound = true; btn.addEventListener('click', openAlbum); }
|
||
_sync();
|
||
updateButton();
|
||
}
|
||
|
||
return { heard, toggle: openAlbum, updateButton, initWelt, count: SONGS_DE.length };
|
||
})();
|
||
|
||
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 = `
|
||
<div class="world-top">
|
||
<div class="world-info-card">
|
||
<div style="font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
|
||
color:rgba(255,255,255,0.45);margin-bottom:10px">Gedanke des Tages</div>
|
||
<div style="font-size:var(--text-sm);color:white;line-height:1.55;font-style:italic">
|
||
»${_esc(quote.t)}«
|
||
</div>
|
||
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:8px;text-align:right">
|
||
— ${_esc(quote.a)}
|
||
</div>
|
||
</div>
|
||
${_anthem.heard() ? '' : `
|
||
<div class="world-info-card" id="ww-anthem-card" role="button" tabindex="0"
|
||
style="margin-top:10px;display:flex;align-items:center;gap:14px;cursor:pointer">
|
||
<div style="width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,0.12);
|
||
display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||
<svg class="ph-icon" style="width:20px;height:20px;color:#fff" aria-hidden="true"><use href="/icons/phosphor.svg#music-notes"></use></svg>
|
||
</div>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:var(--text-sm);font-weight:600;color:#fff">Ban Yaro — das Album</div>
|
||
<div style="font-size:11px;color:rgba(255,255,255,0.45);margin-top:2px">${_anthem.count} Songs · zum Anhören</div>
|
||
</div>
|
||
<svg class="ph-icon" style="width:15px;height:15px;color:rgba(255,255,255,0.3);flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#caret-right"></use></svg>
|
||
</div>`}
|
||
</div>
|
||
<div class="world-bottom">
|
||
<div class="world-chips-grid">
|
||
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
|
||
</div>
|
||
<div class="world-footer-links">
|
||
<span data-wnav="datenschutz">Datenschutz</span>
|
||
<span style="color:var(--c-border)">·</span>
|
||
<span data-wnav="agb">AGB</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav)));
|
||
|
||
_anthem.initWelt();
|
||
}
|
||
|
||
// ── 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 });
|
||
// /poison + /lost filtern per lat/lon serverseitig (poison: Radius in METERN,
|
||
// lost: radius_km). Die früheren /nearby-Pfade existierten nie (459cd42-Verlust)
|
||
// — das doppelte catch hat den 404 jahrelang verschluckt.
|
||
const [p, l] = await Promise.allSettled([
|
||
API.get(`/poison?lat=${pos.lat}&lon=${pos.lon}&radius=5000`).catch(() => []),
|
||
API.get(`/lost?lat=${pos.lat}&lon=${pos.lon}&radius_km=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) => `
|
||
<div style="height:${i===0?80:60}px;background:var(--c-border);border-radius:12px;margin:0 16px 10px;opacity:${0.7 - i*0.1};animation:pulse 1.5s ease-in-out infinite"></div>
|
||
`).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,'>').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; } };
|
||
|
||
})();
|