Frontend Sprint 3+4: Dog-Switcher, Health-Seite, Multi-Dog Tagebuch

- app.js: vollständiger Dog-Switcher (Avatar im Header/Sidebar, Quickpicker
  bei 3+ Hunden, setActiveDog, localStorage-Persistenz), iOS Ghost-Click Fix,
  Loading-Guard, Logout State Reset
- index.html: Dog-Switcher HTML, Favicon-Links, Sidebar "+ Neu erstellen",
  Navigation Tab Karte → Gesundheit
- health.js (neu): vollständiges Health-Frontend mit Tabs (Impfung, Entwurmung,
  Tierarzt, Medikament, Gewicht-Kurve, Allergie, Dokument), Ampel-System,
  KI-Zusammenfassung
- dog-profile.js: "+ Weiteren Hund anlegen" Button + _openCreateModal(),
  Event-Delegation statt direkter Listener (kein Doppelaufruf)
- diary.js: Dog-Picker im Formular, Avatar-Reihe auf Karten, Dog-Chips
  im Detail-Modal, dog_ids im API-Payload
- poison.js: Erledigt-Dialog mit Grundauswahl (beseitigt/fehlerhaft/anderes)
- api.js: health-Endpoints (list, create, update, delete, upload, ki)
- ui.js: confirm() Fix (resolve vor close)
- layout.css: Dog-Switcher Styles, scrollbare Sidebar-Nav, User-Item fix
- components.css: Health-Styles, Diary Dog-Picker, Ampel-Punkte, Gewicht-SVG
- icons/: Favicon-Set (ico, 16px, 32px, 180px, 192px, 512px)
This commit is contained in:
rene 2026-04-13 19:30:03 +02:00
parent 6f48ec581d
commit d8b9561fff
16 changed files with 1597 additions and 91 deletions

View file

@ -56,8 +56,9 @@ const App = (() => {
document.querySelectorAll(`[data-page="${pageId}"]`)
.forEach(el => el.classList.add('active'));
// Header-Titel setzen
document.getElementById('header-title').textContent = pages[pageId].title;
// Header-Titel setzen (nur wenn kein Dog-Switcher aktiv ist)
const titleEl = document.getElementById('header-title');
if (titleEl) titleEl.textContent = pages[pageId].title;
// History
if (pushHistory) {
@ -79,8 +80,12 @@ const App = (() => {
return;
}
// Guard: verhindert doppelten Load bei gleichzeitigen navigate()-Aufrufen
if (page._loading) return;
page._loading = true;
const container = document.querySelector(`#page-${pageId} .page-body`);
if (!container) return;
if (!container) { page._loading = false; return; }
// Skeleton während Laden
container.innerHTML = UI.skeleton(4);
@ -108,6 +113,8 @@ const App = (() => {
text: 'Diese Seite ist noch in Entwicklung.',
});
page.module = {};
} finally {
page._loading = false;
}
}
@ -134,8 +141,14 @@ const App = (() => {
return;
}
// + Button
if (e.target.closest('#nav-add')) {
// Sidebar-User (kein data-page, damit keine Aktiv-Markierung)
if (e.target.closest('#sidebar-user')) {
navigate('settings');
return;
}
// + Button (Mobile Bottom-Nav + Desktop Sidebar)
if (e.target.closest('#nav-add') || e.target.closest('#sidebar-add')) {
_showQuickAdd();
}
});
@ -146,11 +159,8 @@ const App = (() => {
navigate(page, false);
});
// Initial: URL-Hash auslesen
const hash = location.hash.replace('#', '');
if (hash && pages[hash]) {
navigate(hash, false);
}
// Hash-Navigation wird in init() nach _checkAuth() behandelt (nicht hier),
// damit kein doppelter _loadPage()-Aufruf entsteht.
}
// ----------------------------------------------------------
@ -184,13 +194,18 @@ const App = (() => {
document.querySelector('#modal-container').addEventListener('click', e => {
const btn = e.target.closest('[data-quick]');
if (!btn) return;
UI.modal.close();
const action = btn.dataset.quick;
if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); }
if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); }
if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); }
if (action === 'place') { navigate('places'); pages['places'].module?.openNew?.(); }
if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); }
UI.modal.close();
// Kurzes Delay wegen iOS Ghost-Click: nach modal.close() feuert iOS
// ~300ms später ein synthetisches Click-Event an derselben Position.
// Ohne Delay trifft es das neu geöffnete Modal und schließt es sofort.
setTimeout(() => {
if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); }
if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); }
if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); }
if (action === 'place') { navigate('places'); pages['places'].module?.openNew?.(); }
if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); }
}, 350);
}, { once: true });
}
@ -201,19 +216,22 @@ const App = (() => {
try {
const user = await API.auth.me();
state.user = user;
_onLoggedIn();
await _onLoggedIn();
} catch {
_onLoggedOut();
}
}
function _onLoggedIn() {
async function _onLoggedIn() {
document.getElementById('sidebar-username').textContent = state.user.name;
_loadDogs();
await _loadDogs();
}
function _onLoggedOut() {
state.user = null;
state.user = null;
state.dogs = [];
state.activeDog = null;
_renderDogSwitcher();
navigate('settings', false);
}
@ -221,18 +239,142 @@ const App = (() => {
try {
state.dogs = await API.dogs.list();
if (state.dogs.length > 0) {
state.activeDog = state.dogs[0];
// Zuletzt aktiven Hund aus localStorage wiederherstellen
const savedId = parseInt(localStorage.getItem('by_active_dog') || '0');
state.activeDog = state.dogs.find(d => d.id === savedId) || state.dogs[0];
}
// Seitenmodule über neuen Hund informieren
_renderDogSwitcher();
_notifyDogChange();
} catch { /* kein Hund vorhanden */ }
}
function _notifyDogChange() {
// Alle geladenen Seiten-Module über Hundwechsel informieren
Object.values(pages).forEach(p => p.module?.onDogChange?.(state.activeDog));
}
// ----------------------------------------------------------
// HUNDE-SWITCHER (Header Mobile + Sidebar-Logo Desktop)
// ----------------------------------------------------------
function _renderDogSwitcher() {
_renderSwitcherInto(document.getElementById('header-dog-switcher'), 'hdr');
_renderSwitcherInto(document.getElementById('sidebar-dog-switcher'), 'sb');
}
function _renderSwitcherInto(el, ctxId) {
if (!el) return;
const dog = state.activeDog;
const others = state.dogs.filter(d => d.id !== dog?.id);
// Fallback: kein User oder kein Hund → Standardlogo
if (!state.user || !dog) {
if (ctxId === 'sb') {
el.innerHTML = `
<img class="sidebar-logo-img" src="/icons/icon-180.png" alt="Ban Yaro">
<span class="sidebar-logo-text">Ban Yaro</span>`;
} else {
el.innerHTML = `<span class="header-title" id="header-title">Ban Yaro</span>`;
}
return;
}
const avHtml = d => d.foto_url
? `<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}">`
: `<span>🐕</span>`;
// Inaktive Hunde rechts
let othersHtml = '';
if (others.length === 1) {
othersHtml = `
<div class="dog-sw-others">
<div class="dog-sw-other" data-dog-id="${others[0].id}" title="${_esc(others[0].name)}">
${avHtml(others[0])}
</div>
</div>`;
} else if (others.length >= 2) {
const visible = others.slice(0, 3);
const extraCount = others.length - 3;
othersHtml = `
<div class="dog-sw-others">
<div class="dog-sw-stack" id="dog-sw-stack-${ctxId}">
${visible.map((d, i) => `
<div class="dog-sw-other dog-sw-other--${i}" data-dog-id="${d.id}" title="${_esc(d.name)}">
${avHtml(d)}
</div>`).join('')}
${extraCount > 0 ? `<div class="dog-sw-more">+${extraCount}</div>` : ''}
</div>
<div class="dog-quickpick hidden" id="dog-qp-${ctxId}">
${others.map(d => `
<div class="dog-qp-item" data-dog-id="${d.id}">
<div class="dog-qp-av">${avHtml(d)}</div>
<span>${_esc(d.name)}</span>
</div>`).join('')}
</div>
</div>`;
}
const titleClass = ctxId === 'sb' ? 'sidebar-logo-text' : 'header-title';
el.innerHTML = `
<div class="dog-sw-active" id="dog-sw-active-${ctxId}" title="${_esc(dog.name)} bearbeiten">
${avHtml(dog)}
</div>
<span class="${titleClass} dog-sw-title">Ban Yaro</span>
${othersHtml}`;
// Klick aktiver Avatar → Hund-Profil
el.querySelector(`#dog-sw-active-${ctxId}`)?.addEventListener('click', () => {
navigate('dog-profile');
});
// 1 anderer Hund → direkter Tausch
if (others.length === 1) {
el.querySelector('.dog-sw-other')?.addEventListener('click', () => {
setActiveDog(others[0].id);
});
}
// 2+ andere Hunde → Stack klick öffnet Quickpicker
if (others.length >= 2) {
const stack = el.querySelector(`#dog-sw-stack-${ctxId}`);
const qp = el.querySelector(`#dog-qp-${ctxId}`);
stack?.addEventListener('click', e => {
e.stopPropagation();
// Alle anderen Quickpicker schließen
document.querySelectorAll('.dog-quickpick').forEach(q => {
if (q !== qp) q.classList.add('hidden');
});
qp?.classList.toggle('hidden');
});
el.querySelectorAll('.dog-qp-item').forEach(item => {
item.addEventListener('click', e => {
e.stopPropagation();
setActiveDog(parseInt(item.dataset.dogId));
qp?.classList.add('hidden');
});
});
}
}
// Quickpicker schließen bei Klick außerhalb
document.addEventListener('click', () => {
document.querySelectorAll('.dog-quickpick').forEach(q => q.classList.add('hidden'));
});
function setActiveDog(dogId) {
const dog = state.dogs.find(d => d.id === dogId);
if (!dog || dog.id === state.activeDog?.id) return;
state.activeDog = dog;
localStorage.setItem('by_active_dog', String(dogId));
_renderDogSwitcher();
_notifyDogChange();
}
function _esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// INITIALISIERUNG
// ----------------------------------------------------------
@ -240,16 +382,19 @@ const App = (() => {
_bindNavigation();
await _checkAuth();
// Erste Seite laden (Hash oder Standard: diary)
const startPage = location.hash.replace('#', '') || 'diary';
navigate(pages[startPage] ? startPage : 'diary', false);
// Erste Seite laden: Hash aus URL oder Standard 'diary'.
// Bewusst NACH _checkAuth(), damit _loadPage() nur einmal aufgerufen wird
// (vorher war Hash-Navigation auch in _bindNavigation() → doppelter Aufruf).
const hash = location.hash.replace('#', '');
const startPage = (hash && pages[hash]) ? hash : 'diary';
navigate(startPage, false);
}
// ----------------------------------------------------------
// ÖFFENTLICHE API
// (andere Module können App.state, App.navigate etc. nutzen)
// ----------------------------------------------------------
return { init, navigate, state };
return { init, navigate, state, renderDogSwitcher: _renderDogSwitcher };
})();