banyaro/backend/static/js/pages/dog-profile.js
rene c03884cb81 Perf: 9 Performance-Fixes — SW by-v1072
Backend:
- DB: 3 neue Indizes (forum_posts thread+user, routes user) — Forum/Routen-Queries
- Caching: cache.py (TTL-Cache ohne neue Dependency) für 5 statische Listen
  (training_exercises, pflege_tipps, wiki_stats, wiki_gruppen, help_articles)
- diary.py + breeder_photos.py: Bildverarbeitung (ffmpeg/PIL/EXIF) per
  run_in_executor → blockiert Event-Loop nicht mehr
- scheduler.py: 11 kollidierende Jobs auf 5-Min-Intervalle gestaggert, coalesce=True
- social.py: ORDER BY RANDOM() ohne LIMIT in 2 Stellen gefixt
- alerts.py: Haversine-Loop bekommt SQL-Bounding-Box-Vorfilter

Frontend:
- sw.js: Tile-Cache mit LRU-Eviction (max 500 Einträge)
- admin.js: Event-Listener-Leak — Tab-Klicks per Delegation statt N Listener
- api.js: compressImage() Helper — Client-seitiges Resize auf max 2000px
  (HEIC/Videos/<500KB unverändert), integriert in 8 Upload-Stellen
  (diary, dog-profile×2, walks, poison, lost, health×2)

Bump APP_VER 1071 → 1072 (sw.js, app.js, main.py, index.html)
2026-05-26 06:30:36 +02:00

2646 lines
119 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — Hunde-Profil
Seiten-Modul: Profil anlegen / anzeigen / bearbeiten.
============================================================ */
window.Page_dog_profile = (() => {
let _container = null;
let _appState = null;
// ----------------------------------------------------------
// INIT / REFRESH / LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
// Event-Delegation auf dem persistenten Container — überlebt innerHTML-Ersatz
_container.addEventListener('click', e => {
if (e.target.closest('#dp-add-dog-btn')) {
_openCreateModal();
return;
}
if (e.target.closest('#dp-edit-btn')) {
if (_appState.activeDog) _openEditModal(_appState.activeDog);
return;
}
if (e.target.closest('#profile-goto-login')) {
App.navigate('settings');
}
if (e.target.closest('[data-action="goto-weight"]')) {
App.navigate('health', true, { tab: 'gewicht', openForm: true });
return;
}
});
await _render();
}
async function refresh() {
await _render();
}
async function onDogChange(dog) {
await _render();
}
// ----------------------------------------------------------
// HAUPTRENDER
// ----------------------------------------------------------
async function _render() {
if (!_appState.user) {
_container.innerHTML = UI.emptyState({
icon : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
title : 'Anmelden erforderlich',
text : 'Melde dich an, um ein Hundeprofil anzulegen.',
action: `<button class="btn btn-primary" id="profile-goto-login">Anmelden</button>`,
});
_container.querySelector('#profile-goto-login')
?.addEventListener('click', () => App.navigate('settings'));
return;
}
if (!_appState.activeDog) {
_renderCreateForm();
} else {
_renderProfile(_appState.activeDog);
}
}
// ----------------------------------------------------------
// PROFIL-ANSICHT
// ----------------------------------------------------------
function _renderProfile(dog) {
const geburtstag = dog.geburtstag
? new Date(dog.geburtstag + 'T00:00:00')
.toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
: null;
_container.innerHTML = `
<div style="text-align:center;padding:var(--space-6) var(--space-2) var(--space-4)">
<!-- Profilfoto mit Upload-Button -->
<div style="position:relative;display:inline-block;margin-bottom:var(--space-4);padding:4px">
${dog.foto_url
? `<div class="dp-avatar-ring">
<img src="${dog.foto_url}" alt="${_esc(dog.name)}" class="dp-avatar-img"
style="transform:scale(${dog.foto_zoom||1}) translate(${dog.foto_offset_x||0}%,${dog.foto_offset_y||0}%)">
</div>`
: `<div class="dp-avatar-ring dp-avatar-empty">${UI.icon('dog')}</div>`}
<button class="dp-avatar-edit-btn" id="dp-photo-edit-btn">
${UI.icon('pencil-simple')}
</button>
</div>
<!-- Name + Rasse -->
<h2 style="font-size:var(--text-2xl);font-weight:700;
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
${dog.rasse
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-2)"></p>`}
<!-- Rassen-Community-Chip (wird async geladen) -->
<div id="dp-same-breed-chip" style="margin-bottom:var(--space-4)"></div>
<!-- Info-Grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
margin-bottom:var(--space-5);text-align:left">
${geburtstag ? `
<div class="card" style="padding:var(--space-3)">
<div class="dp-info-label"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Geburtstag</div>
<div style="font-weight:500;font-size:var(--text-sm)">${geburtstag}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${_calcAlter(dog.geburtstag)}
</div>
</div>
` : ''}
${dog.geschlecht ? `
<div class="card" style="padding:var(--space-3)">
<div class="dp-info-label">${dog.geschlecht === 'm' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-male"></use></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>'} Geschlecht</div>
<div style="font-weight:500;font-size:var(--text-sm)">
${dog.geschlecht === 'm' ? 'Rüde' : 'Hündin'}
</div>
</div>
` : ''}
${dog.gewicht_kg ? `
<div class="card" style="padding:var(--space-3);cursor:pointer" data-action="goto-weight">
<div class="dp-info-label"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg> Gewicht</div>
<div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</div>
</div>
` : ''}
${dog.widerrist_cm ? `
<div class="card" style="padding:var(--space-3)">
<div class="dp-info-label"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#ruler"></use></svg> Widerrist</div>
<div style="font-weight:500;font-size:var(--text-sm)">${dog.widerrist_cm} cm</div>
</div>
` : ''}
<div class="card" style="padding:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg> Transponder
</div>
${dog.chip_nr
? `<div style="font-size:var(--text-xs);font-weight:500;word-break:break-all">${_esc(dog.chip_nr)}</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">nicht eingetragen
<button class="btn btn-link btn-sm" id="dp-chip-edit-btn"
style="padding:0 0 0 var(--space-1);font-size:var(--text-xs)">Eintragen</button>
</div>`
}
</div>
</div>
${dog.bio ? `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-5);text-align:left">
<p style="margin:0;color:var(--c-text-secondary);font-style:italic;line-height:1.6">
"${_esc(dog.bio)}"
</p>
</div>
` : ''}
<div id="dp-skills" style="margin-bottom:var(--space-5);text-align:left"></div>
<div id="dp-pflege" style="margin-bottom:var(--space-5);text-align:left"></div>
${dog.is_public ? `
<div style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
border-radius:var(--radius-md);padding:var(--space-4);
margin-bottom:var(--space-5);text-align:left">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:var(--space-2);font-weight:var(--weight-medium)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> NFC-Link
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);
flex-wrap:wrap">
<code id="dp-nfc-link"
style="flex:1;font-size:var(--text-sm);background:var(--c-surface);
border:1px solid var(--c-border);border-radius:var(--radius-sm);
padding:var(--space-2) var(--space-3);color:var(--c-text);
word-break:break-all">banyaro.app/hund/${dog.id}</code>
<button class="btn btn-secondary btn-sm" id="dp-copy-link-btn"
style="flex-shrink:0;padding:var(--space-2) var(--space-3);
font-size:var(--text-sm);min-height:36px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Kopieren
</button>
</div>
<p style="margin:var(--space-2) 0 0;font-size:var(--text-xs);
color:var(--c-text-muted)">
Dieser Link kann auf ein NFC-Tag gebrannt werden
</p>
</div>
` : ''}
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
${!dog.is_guest ? `<button class="btn btn-primary w-full" id="dp-edit-btn">
Profil bearbeiten
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-share-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
Teilen
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-passport-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#notebook"></use></svg>
Hundepass
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-vcard-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
Visitenkarte teilen
</button>` : ''}
${!dog.is_guest && App.hasPro(_appState.user) ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
+ Weiteren Hund anlegen
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-wrapped-btn"
style="background:linear-gradient(135deg,#1a1a2e,#16213e);color:#e8c96e;
border-color:transparent;font-weight:700">
✨ Jahresrückblick ${new Date().getFullYear()}
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-buch-btn"
style="background:linear-gradient(135deg,#5c3a10,#7a4f1a);color:#f5e4c0;
border-color:transparent;font-weight:700">
📖 Hunde-Buch erstellen
</button>` : ''}
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-timeline-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#timeline"></use></svg>
Lebens-Timeline 🐾
</button>` : ''}
</div>
</div>
${dog.user_id === _appState.user?.id ? `
<div class="card" style="margin-bottom:var(--space-5)">
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<div style="font-weight:600">Sitter-Zugang</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Gib einem Freund temporären Schreibzugang für diesen Hund.
Deine bestehenden Daten und Medien bleiben unsichtbar und privat — der Sitter kann nur neue Einträge anlegen.
</div>
</div>
<div id="dp-sitting-access" style="padding:var(--space-4)">Lade…</div>
</div>
` : ''}
`;
// Foto-Editor öffnen
document.getElementById('dp-photo-edit-btn')?.addEventListener('click', () => {
_showPhotoEditor(dog);
});
// Skills laden
_loadSkills(dog);
// Pflegetipps laden
_loadPflegeTipps(dog);
// Rassen-Community-Chip laden (falls Rasse bekannt)
if (dog.rasse) _loadSameBreedChip();
// Sitter-Zugang laden (nur für Besitzer)
if (dog.user_id === _appState.user?.id) {
_loadSittingAccess(dog.id);
}
// NFC-Link kopieren
document.getElementById('dp-copy-link-btn')?.addEventListener('click', async () => {
const url = `https://banyaro.app/hund/${dog.id}`;
try {
await navigator.clipboard.writeText(url);
UI.toast.success('Link kopiert!');
} catch {
// Fallback für ältere Browser
const el = document.getElementById('dp-nfc-link');
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
document.execCommand('copy');
sel.removeAllRanges();
UI.toast.success('Link kopiert!');
}
});
// Transponder "Eintragen"-Button
document.getElementById('dp-chip-edit-btn')?.addEventListener('click', () => {
_showChipEdit(dog);
});
document.getElementById('dp-share-btn')?.addEventListener('click', () => {
_showShareModal(dog);
});
document.getElementById('dp-passport-btn')?.addEventListener('click', () => {
_showPassportModal(dog);
});
document.getElementById('dp-vcard-btn')?.addEventListener('click', () => {
_showVcardModal(dog);
});
document.getElementById('dp-wrapped-btn')?.addEventListener('click', () => {
_showWrappedModal(dog);
});
document.getElementById('dp-buch-btn')?.addEventListener('click', () => {
_showBuchModal(dog);
});
document.getElementById('dp-timeline-btn')?.addEventListener('click', () => {
_showTimelineModal(dog);
});
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
}
// ----------------------------------------------------------
// FÄHIGKEITEN & KOMMANDOS
// ----------------------------------------------------------
async function _loadSkills(dog) {
const el = document.getElementById('dp-skills');
if (!el) return;
const skills = await API.dogs.getSkills(dog.id).catch(() => null);
if (!skills || !skills.length) { el.innerHTML = ''; return; }
const sitzt = skills.filter(s => s.status === 'sitzt');
const meistens = skills.filter(s => s.status === 'meistens');
const badge = (skill, type) => {
const isGreen = type === 'sitzt';
return `<span style="
display:inline-flex;align-items:center;gap:4px;
padding:3px 10px;border-radius:var(--radius-full,999px);
font-size:var(--text-xs);font-weight:var(--weight-semibold);
background:${isGreen ? '#f0fdf4' : '#fff7ed'};
color:${isGreen ? '#15803d' : '#c2410c'};
border:1px solid ${isGreen ? '#86efac' : '#fdba74'}">
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${isGreen ? 'check' : 'fire'}"></use>
</svg>
${_esc(skill.exercise_name)}
</span>`;
};
const sitztBlock = sitzt.length ? `
<div style="margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2);
text-transform:uppercase;letter-spacing:.04em">Sitzt</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${sitzt.map(s => badge(s, 'sitzt')).join('')}
</div>
</div>` : '';
const meistensBlock = meistens.length ? `
<div>
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2);
text-transform:uppercase;letter-spacing:.04em">Übt noch</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${meistens.map(s => badge(s, 'meistens')).join('')}
</div>
</div>` : '';
el.innerHTML = `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#list-checks"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
Kommandos & Fähigkeiten
</span>
</div>
${sitztBlock}
${meistensBlock}
</div>`;
}
// ----------------------------------------------------------
// PFLEGETIPPS
// ----------------------------------------------------------
async function _loadPflegeTipps(dog) {
const el = document.getElementById('dp-pflege');
if (!el) return;
let data;
try {
data = await API.get(`/dogs/${dog.id}/pflege`);
} catch { return; }
if (!data?.tipps?.length) return;
const t = data.tipp_des_tages;
const _ph = n => `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${n}"></use></svg>`;
const kat_icons = {
'Fell': _ph('scissors'), 'Krallen': _ph('scissors'),
'Zähne': _ph('tooth'), 'Ohren': _ph('ear'),
'Augen': _ph('eye'), 'Pfoten': _ph('paw-print'),
'Parasiten': _ph('bug'), 'Saisonal': _ph('flower'),
'Gesundheitsvorsorge':_ph('heart'), 'Welpen-Pflege': _ph('dog'),
};
const pflegeArtBadge = data.fell_pflege_art === 'schneiden'
? `<span title="Dieses Fell wächst kontinuierlich und wird mit der Schere geschnitten"
style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;
background:#dbeafe;color:#1d4ed8;margin-left:6px">✂️ Schneiden</span>`
: data.fell_pflege_art === 'trimmen'
? `<span title="Dieses Fell hat natürliche Wachstumsbegrenzung und wird durch Hand-Stripping gepflegt"
style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;
background:#fef9c3;color:#92400e;margin-left:6px">✋ Trimmen</span>`
: '';
el.innerHTML = `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span style="font-size:1.1em">🛁</span>
<span style="font-size:var(--text-sm);font-weight:600">
Pflegetipps${data.rasse_name ? ` für ${_esc(data.rasse_name)}` : ''}
</span>
</div>
${t ? `
<!-- Tipp des Tages -->
<div style="background:var(--c-surface-2);border-radius:10px;padding:12px;
margin-bottom:var(--space-3);border-left:3px solid #a78bfa">
<div style="font-size:10px;font-weight:700;color:#a78bfa;text-transform:uppercase;
letter-spacing:.5px;margin-bottom:4px">
${t.saisonal_aktuell ? '🌸 Aktuell & Saisonal' : '💡 Tipp des Tages'}
</div>
<div style="font-weight:600;font-size:var(--text-sm);margin-bottom:4px">
${kat_icons[t.kategorie]||_ph('paw-print')} ${_esc(t.titel)}
</div>
<div style="font-size:12px;color:var(--c-text-secondary);margin-bottom:8px;
line-height:1.5">${_esc(t.beschreibung||'')}</div>
${t.haeufigkeit ? `<div style="font-size:11px;color:var(--c-text-muted)">
🔄 ${_esc(t.haeufigkeit)}</div>` : ''}
${t.materialien ? `<div style="font-size:11px;color:var(--c-text-muted)">
🛒 ${_esc(t.materialien)}</div>` : ''}
${t.schritte?.length ? `
<details style="margin-top:8px">
<summary style="font-size:12px;cursor:pointer;color:var(--c-primary);
font-weight:600">Anleitung anzeigen</summary>
<ol style="margin:8px 0 0 16px;padding:0;font-size:12px;
color:var(--c-text);line-height:1.6">
${t.schritte.map(s=>`<li style="margin-bottom:3px">${_esc(s)}</li>`).join('')}
</ol>
${t.tipp ? `<div style="margin-top:8px;font-size:11px;color:#a78bfa;
font-style:italic">💜 ${_esc(t.tipp)}</div>` : ''}
</details>` : ''}
</div>` : ''}
<!-- Alle Tipps Button -->
<button id="dp-pflege-alle" class="btn btn-secondary btn-sm"
style="width:100%;font-size:12px">
Alle ${data.tipps.length} Pflegetipps anzeigen
</button>
<div id="dp-pflege-liste" style="display:none;margin-top:var(--space-3)">
${data.kategorien.map(kat => {
const katTipps = data.tipps.filter(t=>t.kategorie===kat);
const katBadge = kat === 'Fell' ? pflegeArtBadge : '';
return `
<div style="margin-bottom:var(--space-3)">
<div style="font-size:11px;font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;margin-bottom:8px;display:flex;align-items:center">
${kat_icons[kat]||_ph('paw-print')} ${_esc(kat)}${katBadge}</div>
${katTipps.map(tip => `
<details style="background:var(--c-surface-2);border-radius:8px;
padding:10px;margin-bottom:6px">
<summary style="font-size:var(--text-sm);font-weight:600;cursor:pointer;
list-style:none;display:flex;justify-content:space-between;
align-items:center">
${_esc(tip.titel)}
${tip.saisonal_aktuell ? '<span style="font-size:10px;color:#10b981">● Aktuell</span>' : ''}
</summary>
<div style="margin-top:8px;font-size:12px;color:var(--c-text-secondary);
line-height:1.5">${_esc(tip.beschreibung||'')}</div>
${tip.haeufigkeit ? `<div style="font-size:11px;color:var(--c-text-muted);
margin-top:4px">🔄 ${_esc(tip.haeufigkeit)}</div>` : ''}
${tip.schritte?.length ? `
<ol style="margin:8px 0 0 16px;padding:0;font-size:12px;line-height:1.6">
${tip.schritte.map(s=>`<li style="margin-bottom:3px">${_esc(s)}</li>`).join('')}
</ol>` : ''}
${tip.tipp ? `<div style="margin-top:6px;font-size:11px;color:#a78bfa;
font-style:italic">💜 ${_esc(tip.tipp)}</div>` : ''}
</details>`).join('')}
</div>`;
}).join('')}
</div>
</div>`;
el.querySelector('#dp-pflege-alle')?.addEventListener('click', e => {
const liste = el.querySelector('#dp-pflege-liste');
const btn = e.currentTarget;
if (liste.style.display === 'none') {
liste.style.display = '';
btn.textContent = 'Pflegetipps einklappen ▲';
} else {
liste.style.display = 'none';
btn.textContent = `Alle ${data.tipps.length} Pflegetipps anzeigen`;
}
});
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ----------------------------------------------------------
// SITTER-ZUGANG
// ----------------------------------------------------------
async function _loadSittingAccess(dogId) {
const wrap = document.getElementById('dp-sitting-access');
if (!wrap) return;
try {
const [accessData, friendsData] = await Promise.all([
API.sittingAccess.my(),
API.friends.list(),
]);
const active = (accessData.as_owner || []).filter(s => s.dog_id === dogId);
const friends = (friendsData?.friends || []);
let activeHtml = '';
if (active.length) {
activeHtml = active.map(s => `
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);background:var(--c-surface-2);border-radius:var(--radius-md);margin-bottom:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<div style="flex:1;font-size:var(--text-sm)">
<strong>${_esc(s.sitter_name)}</strong>
<span style="color:var(--c-text-muted)"> · bis ${_esc(s.valid_until)}</span>
</div>
<button class="btn btn-link btn-sm sa-revoke-btn" data-sub-id="${s.id}"
style="color:var(--c-danger);padding:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>`).join('');
}
const friendOptions = friends.length
? friends.map(f => `<option value="${f.friend_id}">${_esc(f.friend_name)}</option>`).join('')
: '<option value="" disabled>Keine Freunde vorhanden</option>';
const today = new Date().toISOString().slice(0, 10);
const defaultUntil = new Date(Date.now() + 14 * 86400000).toISOString().slice(0, 10);
wrap.innerHTML = `
${activeHtml}
${friends.length ? `
<div style="margin-top:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-2);font-weight:600">Zugang gewähren</div>
<div style="display:grid;grid-template-columns:1fr auto;gap:var(--space-2);
align-items:end">
<div class="form-group" style="margin:0">
<label class="form-label" style="font-size:var(--text-xs)">Freund</label>
<select class="form-control form-control-sm" id="sa-friend-select">
<option value="">Freund wählen…</option>
${friendOptions}
</select>
</div>
<div class="form-group" style="margin:0">
<label class="form-label" style="font-size:var(--text-xs)">Gültig bis</label>
<input class="form-control form-control-sm" type="date" id="sa-until-input"
value="${defaultUntil}" min="${today}">
</div>
</div>
<button class="btn btn-primary btn-sm w-full" id="sa-grant-btn"
style="margin-top:var(--space-2)">
Zugang gewähren
</button>
</div>
` : `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">
Füge zuerst Freunde hinzu, um ihnen Zugang zu gewähren.
</p>`}
`;
// Event-Listener
wrap.querySelectorAll('.sa-revoke-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const subId = parseInt(btn.dataset.subId);
try {
await API.sittingAccess.revoke(subId);
_loadSittingAccess(dogId);
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Widerrufen.');
}
});
});
document.getElementById('sa-grant-btn')?.addEventListener('click', async () => {
const sitterId = parseInt(document.getElementById('sa-friend-select').value);
const validUntil = document.getElementById('sa-until-input').value;
if (!sitterId) { UI.toast.warning('Bitte einen Freund auswählen.'); return; }
if (!validUntil) { UI.toast.warning('Bitte ein Datum angeben.'); return; }
const btn = document.getElementById('sa-grant-btn');
UI.setLoading(btn, true);
try {
await API.sittingAccess.grant({ dog_id: dogId, sitter_id: sitterId, valid_until: validUntil });
UI.toast.success('Sitter-Zugang gewährt.');
_loadSittingAccess(dogId);
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler beim Gewähren.');
}
});
} catch (e) {
if (wrap) wrap.innerHTML = '<p style="color:var(--c-danger);font-size:var(--text-sm)">Fehler beim Laden.</p>';
}
}
function _showChipEdit(dog) {
UI.modal.open({
title: 'Transpondernummer',
body: `
<div class="mb-3">
<label class="form-label">Chip-Nummer (15-stellig)</label>
<input id="chip-edit-input" class="form-control" type="text"
value="${_esc(dog.chip_nr || '')}" placeholder="z.B. 276009200123456" maxlength="20">
</div>`,
footer: `
<div class="w3-btn-stack">
<button class="btn btn-primary" id="chip-edit-save-btn" style="width:100%">Speichern</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>`,
});
document.getElementById('chip-edit-save-btn').addEventListener('click', async () => {
const nr = document.getElementById('chip-edit-input').value.trim() || null;
const btn = document.getElementById('chip-edit-save-btn');
UI.setLoading(btn, true);
try {
await API.dogs.update(dog.id, { chip_nr: nr });
dog.chip_nr = nr;
_appState.activeDog = { ..._appState.activeDog, chip_nr: nr };
_appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d);
UI.modal.close();
UI.toast.success('Transpondernummer gespeichert.');
_renderProfile(_appState.activeDog);
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error('Fehler beim Speichern.');
}
});
}
// ----------------------------------------------------------
// FOTO-EDITOR
// ----------------------------------------------------------
function _showPhotoEditor(dog) {
const hasPhoto = !!dog.foto_url;
const zoom = dog.foto_zoom || 1.0;
const ox = dog.foto_offset_x || 0.0;
const oy = dog.foto_offset_y || 0.0;
const body = `
<div class="photo-editor">
<div class="photo-editor-preview" id="pe-preview">
${hasPhoto
? `<img src="${UI.escape(dog.foto_url)}" id="pe-img" draggable="false"
oncontextmenu="return false"
style="transform:scale(${zoom}) translate(${ox}%,${oy}%)">`
: `<div class="photo-editor-empty">${UI.icon('dog')}</div>`}
</div>
${hasPhoto ? `
<div class="photo-editor-controls">
<label class="form-label">Zoom</label>
<input type="range" id="pe-zoom" min="1" max="3" step="0.05" value="${zoom}"
style="width:100%">
</div>
` : ''}
<label class="btn btn-secondary" style="cursor:pointer">
${UI.icon('upload-simple')} Neues Foto wählen
<input type="file" id="pe-file-input" accept="image/*" style="display:none">
</label>
</div>
`;
const footer = `
<div class="w3-btn-stack">
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" style="width:100%">Speichern</button>` : ''}
<div style="display:flex;gap:var(--space-2)">
${hasPhoto ? `<button class="btn btn-danger" id="pe-delete-btn">${UI.icon('trash')} Löschen</button>` : ''}
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div>
</div>
`;
UI.modal.open({ title: 'Foto bearbeiten', body, footer });
// State für Drag
let _zoom = zoom, _ox = ox, _oy = oy;
const img = document.getElementById('pe-img');
const zoomSlider = document.getElementById('pe-zoom');
// Offsets in % des Preview-Containers (200px) — konsistent mit Profil-Anzeige
const PREVIEW_PX = 200;
function _applyTransform() {
if (img) img.style.transform = `scale(${_zoom}) translate(${_ox}%,${_oy}%)`;
}
// Zoom-Slider
zoomSlider?.addEventListener('input', e => {
_zoom = parseFloat(e.target.value);
_applyTransform();
});
// Drag-to-pan
if (img) {
let _dragging = false, _startX = 0, _startY = 0, _baseX = _ox, _baseY = _oy;
img.addEventListener('pointerdown', e => {
_dragging = true; _startX = e.clientX; _startY = e.clientY;
_baseX = _ox; _baseY = _oy;
img.setPointerCapture(e.pointerId);
e.preventDefault();
});
img.addEventListener('pointermove', e => {
if (!_dragging) return;
// Pixel-Delta → Prozent des Preview-Containers, korrigiert um Zoom
_ox = _baseX + (e.clientX - _startX) / (PREVIEW_PX / 100) / _zoom;
_oy = _baseY + (e.clientY - _startY) / (PREVIEW_PX / 100) / _zoom;
_applyTransform();
});
img.addEventListener('pointerup', () => { _dragging = false; });
}
// Speichern
document.getElementById('pe-save-btn')?.addEventListener('click', async () => {
try {
await API.dogs.updatePhotoPosition(dog.id, _zoom, _ox, _oy);
_appState.activeDog = { ..._appState.activeDog, foto_zoom: _zoom, foto_offset_x: _ox, foto_offset_y: _oy };
_appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d);
UI.modal.close();
UI.toast.success('Position gespeichert.');
_renderProfile(_appState.activeDog);
App.renderDogSwitcher();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
}
});
// Löschen
document.getElementById('pe-delete-btn')?.addEventListener('click', async () => {
if (!confirm('Foto wirklich löschen?')) return;
try {
await API.dogs.deletePhoto(dog.id);
_appState.activeDog = { ..._appState.activeDog, foto_url: null, foto_zoom: 1, foto_offset_x: 0, foto_offset_y: 0 };
_appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d);
UI.modal.close();
UI.toast.success('Foto gelöscht.');
_renderProfile(_appState.activeDog);
App.renderDogSwitcher();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
});
// Neues Foto hochladen
document.getElementById('pe-file-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
try {
// Client-Side-Kompression vor Upload (HEIC bleibt unverändert)
const toUpload = await API.compressImage(file);
const fd = new FormData();
fd.append('file', toUpload);
const result = await API.dogs.uploadPhoto(dog.id, fd);
// Position zurücksetzen
await API.dogs.updatePhotoPosition(dog.id, 1.0, 0.0, 0.0);
_appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url, foto_zoom: 1, foto_offset_x: 0, foto_offset_y: 0 };
_appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d);
// localStorage + SW-Cache invalidieren
const userId2 = _appState.user?.id || 'anon';
localStorage.removeItem(`w3_bg3_${userId2}_` + new Date().toISOString().slice(0, 10));
localStorage.removeItem('w3_dogs');
API.swCacheDelete('/api/dogs');
API.swCacheDelete(`/api/dogs/${dog.id}`);
API.swCacheDelete(`/api/dogs/${dog.id}/welcome-dashboard`);
UI.modal.close();
App.renderDogSwitcher?.();
window.Worlds?.refresh(_appState);
UI.toast.success('Foto hochgeladen.');
_renderProfile(_appState.activeDog);
// Editor neu öffnen damit User positionieren kann
_showPhotoEditor(_appState.activeDog);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
});
}
// ----------------------------------------------------------
// AUSWEIS
// ----------------------------------------------------------
function _showAusweisModal(dogId) {
window.open(`/ausweis/${dogId}`, '_blank', 'noopener');
}
// ----------------------------------------------------------
// TEILEN
// ----------------------------------------------------------
// ----------------------------------------------------------
// HUNDE-VISITENKARTE MIT QR-CODE
// ----------------------------------------------------------
function _showVcardModal(dog) {
const passportUrl = `https://banyaro.app/hund/${dog.id}`;
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&color=ffffff&bgcolor=1a2035&data=${encodeURIComponent(passportUrl)}`;
const user = _appState?.user;
const ownerName = user?.name || '';
const wohnort = user?.wohnort || '';
// Alter errechnen
let alterStr = '';
if (dog.geburtstag) {
const birth = new Date(dog.geburtstag + 'T00:00:00');
const now = new Date();
const years = now.getFullYear() - birth.getFullYear()
- (now < new Date(now.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0);
alterStr = years < 1
? `${Math.max(1, Math.round((now - birth) / (30.5 * 86400000)))} Monate`
: years === 1 ? '1 Jahr' : `${years} Jahre`;
}
const metaLine = [dog.rasse, alterStr].filter(Boolean).join(' · ');
const cardHtml = `
<div id="dp-vcard-canvas" style="
background:linear-gradient(135deg,#0f1a2b 0%,#1a2a45 60%,#0d2137 100%);
border-radius:20px;padding:24px 20px;color:white;
font-family:system-ui,-apple-system,sans-serif;
position:relative;overflow:hidden;max-width:340px;margin:0 auto;
box-shadow:0 8px 32px rgba(0,0,0,0.4)">
<!-- Deko-Kreis -->
<div style="position:absolute;top:-30px;right:-30px;width:120px;height:120px;
border-radius:50%;background:rgba(196,132,58,0.12);pointer-events:none"></div>
<div style="position:absolute;bottom:-40px;left:-20px;width:100px;height:100px;
border-radius:50%;background:rgba(196,132,58,0.07);pointer-events:none"></div>
<!-- Header -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px">
${dog.foto_url
? `<img src="${_esc(dog.foto_url)}" style="width:52px;height:52px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,0.6);flex-shrink:0">`
: `<div style="width:52px;height:52px;border-radius:50%;background:rgba(196,132,58,0.2);
display:flex;align-items:center;justify-content:center;font-size:1.6rem;
flex-shrink:0;border:2px solid rgba(196,132,58,0.4)">🐾</div>`}
<div>
<div style="font-size:1.25rem;font-weight:800;color:#fff;line-height:1.2">${_esc(dog.name)}</div>
${metaLine ? `<div style="font-size:0.8rem;color:rgba(255,255,255,0.6);margin-top:2px">${_esc(metaLine)}</div>` : ''}
${wohnort ? `<div style="font-size:0.75rem;color:rgba(196,132,58,0.9);margin-top:3px">📍 ${_esc(wohnort)}</div>` : ''}
</div>
</div>
<!-- Divider -->
<div style="height:1px;background:rgba(255,255,255,0.1);margin-bottom:16px"></div>
<!-- Owner + QR -->
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px">
<div style="flex:1;min-width:0">
${ownerName ? `<div style="font-size:0.7rem;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Besitzer</div>
<div style="font-size:0.9rem;font-weight:600;color:rgba(255,255,255,0.85)">${_esc(ownerName)}</div>` : ''}
<div style="font-size:0.65rem;color:rgba(255,255,255,0.35);margin-top:8px">banyaro.app</div>
</div>
<div style="flex-shrink:0;text-align:center">
<img id="dp-vcard-qr" src="${_esc(qrUrl)}"
style="width:80px;height:80px;border-radius:10px;display:block"
alt="QR-Code">
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35);margin-top:4px">Profil öffnen</div>
</div>
</div>
</div>
`;
UI.modal.open({
title: 'Visitenkarte',
body: `
<div style="margin-bottom:var(--space-4)">${cardHtml}</div>
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);text-align:center;margin-bottom:0">
QR-Code auf NFC-Tag oder Anhänger kleben — jeder kann das Profil von ${_esc(dog.name)} sofort öffnen.
</p>
`,
footer: `
<button class="btn btn-secondary" id="dp-vcard-copy-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
Link kopieren
</button>
<button class="btn btn-primary" id="dp-vcard-share-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
Teilen
</button>
`,
});
// Link kopieren
document.getElementById('dp-vcard-copy-btn')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(passportUrl);
UI.toast.success('Link kopiert!');
} catch {
const inp = document.createElement('input');
inp.value = passportUrl;
document.body.appendChild(inp);
inp.select();
document.execCommand('copy');
inp.remove();
UI.toast.success('Link kopiert!');
}
});
// Native Share API
document.getElementById('dp-vcard-share-btn')?.addEventListener('click', async () => {
if (navigator.share) {
try {
await navigator.share({
title: `${dog.name} auf Ban Yaro`,
text: `Schau dir das Profil von ${dog.name} an!`,
url: passportUrl,
});
} catch {}
} else {
// Fallback: kopieren
try {
await navigator.clipboard.writeText(passportUrl);
UI.toast.success('Link kopiert!');
} catch {
UI.toast.error('Teilen nicht verfügbar.');
}
}
});
}
async function _showShareModal(dog) {
UI.modal.open({
title: `${_esc(dog.name)} teilen`,
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Erstelle einen Einladungslink, den du per WhatsApp, Signal oder E-Mail teilen kannst.
Die eingeladene Person sieht Tagebuch und Gesundheitsakte nach dem Annehmen.
</p>
<div class="form-group">
<label class="form-label">Berechtigung</label>
<select class="form-control" id="share-role-select">
<option value="editor">Mitschreiben (Tagebuch & Gesundheit bearbeiten)</option>
<option value="viewer">Nur lesen</option>
</select>
</div>
<div id="share-link-result" style="display:none;margin-top:var(--space-4)">
<label class="form-label">Einladungslink</label>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input class="form-control" id="share-link-input" type="text" readonly
style="font-size:var(--text-xs)">
<button class="btn btn-secondary btn-sm" id="share-link-copy">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
Dieser Link kann einmalig angenommen werden.
</p>
</div>
<div id="share-list-wrap" style="margin-top:var(--space-4)"></div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="share-create-btn">Link erstellen</button>`,
});
_loadShareList(dog.id);
document.getElementById('share-create-btn').addEventListener('click', async () => {
const role = document.getElementById('share-role-select').value;
const btn = document.getElementById('share-create-btn');
UI.setLoading(btn, true);
try {
const res = await API.sharing.create(dog.id, role);
const link = `${location.origin}${res.invite_path}`;
const inp = document.getElementById('share-link-input');
inp.value = link;
document.getElementById('share-link-result').style.display = 'block';
document.getElementById('share-link-copy').onclick = async () => {
await navigator.clipboard.writeText(link).catch(() => {});
UI.toast.success('Link kopiert!');
};
UI.setLoading(btn, false);
_loadShareList(dog.id);
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
async function _loadShareList(dogId) {
const wrap = document.getElementById('share-list-wrap');
if (!wrap) return;
try {
const shares = await API.sharing.list(dogId);
if (!shares.length) { wrap.innerHTML = ''; return; }
wrap.innerHTML = `
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
Aktive Einladungen
</div>` +
shares.map(s => `
<div style="display:flex;align-items:center;gap:var(--space-2);
padding:var(--space-2) var(--space-3);background:var(--c-surface);
border-radius:var(--radius-md);margin-bottom:var(--space-1)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<div style="flex:1;font-size:var(--text-sm)">
${s.shared_with_name
? `<strong>${_esc(s.shared_with_name)}</strong> · ${s.role}`
: `<em style="color:var(--c-text-muted)">Ausstehend</em> · ${s.role}`}
</div>
<button class="btn btn-link btn-sm share-revoke-btn" data-share-id="${s.id}"
style="color:var(--c-danger);padding:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>`).join('');
wrap.querySelectorAll('.share-revoke-btn').forEach(btn => {
btn.addEventListener('click', async () => {
await API.sharing.revoke(dogId, parseInt(btn.dataset.shareId));
_loadShareList(dogId);
});
});
} catch (e) { /* ignore */ }
}
// ----------------------------------------------------------
// NEU ANLEGEN (direkt auf der Seite, kein Modal)
// ----------------------------------------------------------
function _renderCreateForm() {
_container.innerHTML = `
<div style="padding:var(--space-4) 0 var(--space-2)">
<div style="text-align:center;margin-bottom:var(--space-5)">
<div style="font-size:3rem;margin-bottom:var(--space-2)">${UI.icon('dog')}</div>
<h2 style="font-size:var(--text-xl);font-weight:700;margin:0 0 var(--space-2)">
Hund anlegen
</h2>
<p style="color:var(--c-text-secondary);margin:0">
Erstelle das Profil für deinen Hund.
</p>
</div>
${_formHTML(null)}
</div>
`;
_bindForm(null, false);
}
// ----------------------------------------------------------
// NEUEN HUND ANLEGEN (Modal) — auch aufrufbar via addNew()
// ----------------------------------------------------------
function _openCreateModal() {
UI.modal.open({
title: 'Weiteren Hund anlegen',
body: _formHTML(null, true),
footer: `
<div class="w3-btn-stack">
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">${UI.icon('dog')} Hund anlegen</button>
<button type="button" class="btn btn-secondary" id="dp-form-cancel">Abbrechen</button>
</div>
`,
});
_bindForm(null, true);
}
// ----------------------------------------------------------
// BEARBEITEN (Modal)
// ----------------------------------------------------------
function _openEditModal(dog) {
UI.modal.open({
title: `${dog.name} bearbeiten`,
body: _formHTML(dog, true),
footer: `
<div class="w3-btn-stack">
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button>
<div style="display:flex;gap:var(--space-2)">
<button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button>
<button type="button" id="dp-gedenken-btn"
style="flex:1;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:none;background:#1a1a1a;color:#C4843A;
font-size:var(--text-sm);font-weight:600;cursor:pointer">
Verstorben
</button>
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
</div>
</div>
`,
});
_bindForm(dog, true);
}
// ----------------------------------------------------------
// FORMULAR HTML
// ----------------------------------------------------------
function _formHTML(dog, inModal = false) {
const today = new Date().toISOString().slice(0, 10);
return `
<form id="dp-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Name *</label>
<input class="form-control" type="text" name="name"
value="${_esc(dog?.name || '')}"
placeholder="z. B. Ban Yaro" required>
</div>
<div class="form-group">
<label class="form-label">
Rasse
<span style="color:var(--c-text-secondary)">(optional)</span>
${UI.help('Verknüpfe deine Rasse mit unserem Wiki für personalisierte Pflegetipps.')}
</label>
<input class="form-control" type="text" name="rasse"
id="dp-rasse-input"
value="${_esc(dog?.rasse || '')}"
list="dp-rasse-list"
autocomplete="off"
placeholder="z. B. Mischling, Golden Retriever…">
<datalist id="dp-rasse-list"></datalist>
<input type="hidden" name="rasse_id" id="dp-rasse-id"
value="${dog?.rasse_id || ''}">
<div id="dp-rasse-match" style="display:none;margin-top:4px;font-size:11px;
color:var(--c-success);font-weight:600">
✓ Mit Wiki verknüpft — Pflegetipps werden auf diese Rasse angepasst
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Geburtstag</label>
<input class="form-control" type="date" name="geburtstag"
value="${dog?.geburtstag || ''}" max="${today}">
</div>
<div class="form-group">
<label class="form-label">Geschlecht</label>
<select class="form-control" name="geschlecht">
<option value="" ${!dog?.geschlecht ? 'selected' : ''}></option>
<option value="m" ${dog?.geschlecht === 'm' ? 'selected' : ''}>Rüde</option>
<option value="w" ${dog?.geschlecht === 'w' ? 'selected' : ''}>Hündin</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Gewicht (kg)</label>
<input class="form-control" type="number" name="gewicht_kg"
value="${dog?.gewicht_kg || ''}"
min="0.1" max="120" step="0.1" placeholder="z. B. 28.5">
</div>
<div class="form-group">
<label class="form-label">
Widerristhöhe (cm)
${UI.help('Der Widerrist ist der höchste Punkt zwischen den Schulterblättern. Hund gerade hinstellen, senkrecht von diesem Punkt zum Boden messen. Ab 40 cm gilt der Hund in NRW als „großer Hund" (Anleinpflicht + Versicherungspflicht). In anderen Bundesländern gelten teils andere Regeln — im Zweifel bei der Gemeinde nachfragen.')}
</label>
<input class="form-control" type="number" name="widerrist_cm"
value="${dog?.widerrist_cm || ''}"
min="10" max="120" step="1" placeholder="z. B. 58">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">
Chip-Nummer
${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')}
</label>
<input class="form-control" type="text" name="chip_nr"
value="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig">
</div>
<div></div>
</div>
<div class="form-group">
<label class="form-label">
Felltyp
<span style="color:var(--c-text-secondary)">(optional)</span>
${UI.help('Der Felltyp wird für personalisierte Wetter-Hinweise genutzt.')}
</label>
<select class="form-control" name="fell_typ">
<option value="" ${!dog?.fell_typ ? 'selected' : ''}> nicht angegeben </option>
<option value="kurz" ${dog?.fell_typ === 'kurz' ? 'selected' : ''}>Kurzhaar (Labrador, Boxer)</option>
<option value="mittel" ${dog?.fell_typ === 'mittel' ? 'selected' : ''}>Mittellang (Spaniel, Husky)</option>
<option value="lang" ${dog?.fell_typ === 'lang' ? 'selected' : ''}>Langhaar (Collie, Berner Senne)</option>
<option value="drahtaar" ${dog?.fell_typ === 'drahtaar' ? 'selected' : ''}>Drahthaar (Terrier, Schnauzer)</option>
<option value="doppel" ${dog?.fell_typ === 'doppel' ? 'selected' : ''}>Doppeltes Unterfell (Husky, Malamute, Samojede)</option>
<option value="nackt" ${dog?.fell_typ === 'nackt' ? 'selected' : ''}>Nackthund (Chinese Crested, Xolo)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">
Bio / Steckbrief
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<textarea class="form-control" name="bio" rows="2"
placeholder="Kurze Beschreibung…">${_esc(dog?.bio || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label"
style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="is_public" ${dog?.is_public ? 'checked' : ''}>
Öffentliches Profil (für NFC-Tag)
</label>
</div>
<div class="form-group">
<label class="form-label">Foto</label>
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
<img id="dp-form-preview"
src="${dog?.foto_url || ''}"
style="width:64px;height:64px;border-radius:50%;object-fit:cover;
background:var(--c-surface-2);border:2px solid var(--c-border);
display:${dog?.foto_url ? 'block' : 'none'}">
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Foto auswählen
<input type="file" name="foto" accept="image/*" style="display:none"
id="dp-form-foto">
</label>
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
style="margin:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
Rasse erkennen
</button>
<input type="file" accept="image/jpeg,image/png,image/webp"
id="dp-rasse-foto-input" style="display:none">
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Foto hochladen um die Rasse per KI zu erkennen
</div>
</div>
${!inModal ? `
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-4)">
${dog ? `<button type="button" class="btn btn-secondary flex-1"
id="dp-form-cancel">Abbrechen</button>` : ''}
<button type="submit" class="btn btn-primary flex-1">
${dog ? 'Speichern' : `${UI.icon('dog')} Hund anlegen`}
</button>
</div>` : ''}
</form>
`;
}
// ----------------------------------------------------------
// FORMULAR EVENTS
// ----------------------------------------------------------
function _bindForm(dog, inModal) {
const form = document.getElementById('dp-form');
if (!form) return;
// Rassen-Autocomplete aus Wiki laden
let _wikiBreeds = [];
API.get('/wiki/rassen?limit=1000&offset=0').then(data => {
_wikiBreeds = data.rassen || [];
const list = document.getElementById('dp-rasse-list');
if (list) {
list.innerHTML = _wikiBreeds.map(r =>
`<option value="${r.name.replace(/"/g,'&quot;')}" data-id="${r.id}">`
).join('');
}
// Vorhandene Rasse: Match prüfen und Badge zeigen
const rasseInput = document.getElementById('dp-rasse-input');
const rasseIdInput = document.getElementById('dp-rasse-id');
const matchBadge = document.getElementById('dp-rasse-match');
if (rasseInput?.value) {
const match = _wikiBreeds.find(r =>
r.name.toLowerCase() === rasseInput.value.toLowerCase());
if (match && matchBadge) {
if (!rasseIdInput.value) rasseIdInput.value = match.id;
matchBadge.style.display = '';
}
}
}).catch(() => {});
// Rassen-Input: bei Änderung ID nachschlagen
document.getElementById('dp-rasse-input')?.addEventListener('input', e => {
const val = e.target.value.trim().toLowerCase();
const rasseIdInput = document.getElementById('dp-rasse-id');
const matchBadge = document.getElementById('dp-rasse-match');
const match = _wikiBreeds.find(r => r.name.toLowerCase() === val);
if (match) {
rasseIdInput.value = match.id;
if (matchBadge) matchBadge.style.display = '';
} else {
rasseIdInput.value = '';
if (matchBadge) matchBadge.style.display = 'none';
}
});
// Foto-Vorschau
const fotoInput = document.getElementById('dp-form-foto');
const fotoPreview = document.getElementById('dp-form-preview');
if (fotoInput && fotoPreview) {
fotoInput.addEventListener('change', () => {
const file = fotoInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
fotoPreview.src = e.target.result;
fotoPreview.style.display = 'block';
};
reader.readAsDataURL(file);
});
}
// Rassen-Erkennung per KI
_bindRasseErkennung();
document.getElementById('dp-form-cancel')
?.addEventListener('click', UI.modal.close);
document.getElementById('dp-gedenken-btn')?.addEventListener('click', async () => {
UI.modal.close();
_openGedenkenFlow(dog);
});
document.getElementById('dp-delete-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title : `${dog.name} löschen?`,
message: 'Tagebuch-Einträge und Gesundheitsdaten werden ebenfalls gelöscht. Nicht rückgängig.',
confirmText: 'Löschen',
danger : true,
});
if (!ok) return;
try {
await API.dogs.delete(dog.id);
_appState.dogs = _appState.dogs.filter(d => d.id !== dog.id);
_appState.activeDog = _appState.dogs[0] || null;
if (inModal) UI.modal.close();
UI.toast.success(`${dog.name} wurde gelöscht.`);
await _render();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
});
form.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="dp-form"][type="submit"]') || form.querySelector('[type="submit"]');
const fd = UI.formData(form);
if (!fd.name?.trim()) {
UI.toast.warning('Bitte einen Namen eingeben.');
return;
}
await UI.asyncButton(btn, async () => {
const payload = {
name: fd.name.trim(),
rasse: fd.rasse || null,
rasse_id: fd.rasse_id ? parseInt(fd.rasse_id) : null,
geburtstag: fd.geburtstag || null,
geschlecht: fd.geschlecht || null,
gewicht_kg: fd.gewicht_kg ? parseFloat(fd.gewicht_kg) : null,
widerrist_cm: fd.widerrist_cm ? parseFloat(fd.widerrist_cm) : null,
chip_nr: fd.chip_nr || null,
bio: fd.bio || null,
is_public: 'is_public' in fd,
fell_typ: fd.fell_typ || null,
};
// Datei-Referenz VOR Modal-Close sichern — DOM-Element wird beim Schließen entfernt
const fotoFile = document.getElementById('dp-form-foto')?.files[0];
let saved;
if (dog) {
saved = await API.dogs.update(dog.id, payload);
_appState.dogs = _appState.dogs.map(d => d.id === dog.id ? saved : d);
_appState.activeDog = saved;
if (inModal) UI.modal.close();
UI.toast.success('Profil gespeichert.');
} else {
saved = await API.dogs.create(payload);
_appState.dogs.push(saved);
_appState.activeDog = saved;
localStorage.setItem('by_active_dog', String(saved.id));
if (inModal) UI.modal.close();
UI.toast.success(`${saved.name} wurde angelegt! 🎉`);
}
// Foto hochladen wenn gewählt
if (fotoFile) {
try {
// Client-Side-Kompression vor Upload
const toUpload = await API.compressImage(fotoFile);
const fd = new FormData();
fd.append('file', toUpload);
const result = await API.dogs.uploadPhoto(saved.id, fd);
saved.foto_url = result.foto_url;
_appState.activeDog = { ...saved };
_appState.dogs = _appState.dogs.map(d => d.id === saved.id ? _appState.activeDog : d);
// localStorage + SW-Cache invalidieren damit Welten das neue Foto zeigen
const userId = _appState.user?.id || 'anon';
localStorage.removeItem(`w3_bg3_${userId}_` + new Date().toISOString().slice(0, 10));
localStorage.removeItem('w3_dogs');
API.swCacheDelete('/api/dogs');
API.swCacheDelete(`/api/dogs/${saved.id}`);
API.swCacheDelete(`/api/dogs/${saved.id}/welcome-dashboard`);
} catch {
UI.toast.warning('Profil gespeichert, Foto konnte nicht hochgeladen werden.');
}
}
// Dog Switcher in Header + Sidebar aktualisieren
App.renderDogSwitcher?.();
// Welten neu laden damit HUND-Welt den neuen Hund zeigt
window.Worlds?.refresh(_appState);
await _render();
});
});
}
// ----------------------------------------------------------
// RASSEN-ERKENNUNG PER KI (Formular)
// ----------------------------------------------------------
function _bindRasseErkennung() {
const btn = document.getElementById('dp-rasse-erkennen-btn');
const fileInput = document.getElementById('dp-rasse-foto-input');
if (!btn || !fileInput) return;
btn.addEventListener('click', () => {
fileInput.value = '';
fileInput.click();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
UI.toast.error('Bild zu groß (max. 5 MB).');
return;
}
const origLabel = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert…`;
try {
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('by_token');
const resp = await fetch('/api/ki/rasse-erkennung', {
method: 'POST',
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: fd,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
btn.disabled = false;
btn.innerHTML = origLabel;
_showRasseErgebnis(data);
} catch (e) {
btn.disabled = false;
btn.innerHTML = origLabel;
UI.toast.error(e.message || 'Fehler bei der Rassen-Erkennung.');
}
});
}
function _showRasseErgebnis(data) {
if (!data.ist_hund) {
UI.modal.open({
title: 'Kein Hund erkannt',
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
<p style="color:var(--c-text-secondary)">
Auf diesem Foto konnte kein Hund erkannt werden.<br>
Bitte lade ein deutlicheres Foto hoch.
</p>
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
return;
}
const rassen = data.rassen || [];
const cardsHtml = rassen.map((r, i) => {
const isTop = i === 0;
return `
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
<div style="display:flex;align-items:center;justify-content:space-between">
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
</div>
<div class="rasse-result-bar-wrap">
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
style="width:${r.sicherheit}%"></div>
</div>
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);flex-wrap:wrap">
${isTop ? `<button class="btn btn-primary btn-sm" data-action="uebernehmen"
data-rasse="${_esc(r.name)}" style="flex:1">
Rasse übernehmen
</button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen"
data-rasse="${_esc(r.name)}" style="flex:1">
Diese wählen
</button>`}
${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki"
data-slug="${_esc(r.wiki_slug)}">
Im Wiki
</button>` : ''}
</div>
</div>
`;
}).join('');
UI.modal.open({
title: 'Erkannte Rasse',
body: `
<div style="padding-bottom:var(--space-2)">
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary)"> ${_esc(data.hinweis)}</div>` : ''}
${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center">
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
</p>
</div>
`,
footer: `<button class="btn btn-secondary" id="dp-rasse-modal-schliessen">Schließen</button>`,
});
document.getElementById('dp-rasse-modal-schliessen')
?.addEventListener('click', UI.modal.close);
document.querySelectorAll('[data-action="uebernehmen"]').forEach(btn => {
btn.addEventListener('click', () => {
const rasse = btn.dataset.rasse;
const rasseInput = document.getElementById('dp-rasse-input');
const rasseIdInput = document.getElementById('dp-rasse-id');
const matchBadge = document.getElementById('dp-rasse-match');
if (rasseInput) {
rasseInput.value = rasse;
rasseInput.dispatchEvent(new Event('input'));
}
UI.modal.close();
UI.toast.success(`Rasse "${rasse}" übernommen.`);
});
});
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
btn.addEventListener('click', () => {
UI.modal.close();
App.navigate('wiki');
setTimeout(() => {
if (window.Page_wiki && typeof Page_wiki._openBreedDetail === 'function') {
Page_wiki._openBreedDetail(btn.dataset.slug);
}
}, 400);
});
});
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _calcAlter(geburtstag) {
const born = new Date(geburtstag + 'T00:00:00');
const tage = Math.floor((Date.now() - born) / 86400000);
if (tage < 0) return '';
if (tage < 30) return `${tage} Tag${tage !== 1 ? 'e' : ''} alt`;
if (tage < 365) {
const m = Math.floor(tage / 30);
return `${m} Monat${m !== 1 ? 'e' : ''} alt`;
}
const j = Math.floor(tage / 365);
const m = Math.floor((tage % 365) / 30);
return m > 0
? `${j} Jahr${j !== 1 ? 'e' : ''}, ${m} Monat${m !== 1 ? 'e' : ''} alt`
: `${j} Jahr${j !== 1 ? 'e' : ''} alt`;
}
function _esc(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// HUNDEPASS
// ----------------------------------------------------------
async function _showPassportModal(dog) {
UI.modal.open({
title: `Hundepass — ${_esc(dog.name)}`,
body: `<div id="pp-body" style="min-height:200px">
<div style="text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<a class="btn btn-secondary" href="/ausweis/${dog.id}" target="_blank" rel="noopener">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
Ausweis öffnen
</a>
<button class="btn btn-secondary" id="pp-share-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
Link teilen
</button>
<a class="btn btn-primary" id="pp-pdf-btn"
href="/api/passport/${dog.id}/pdf" target="_blank" download>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-pdf"></use></svg>
PDF
</a>
</div>`,
size: 'large',
});
document.getElementById('pp-share-btn')?.addEventListener('click', () => {
_createPassportShare(dog);
});
await _loadPassportBody(dog);
}
async function _loadPassportBody(dog) {
const wrap = document.getElementById('pp-body');
if (!wrap) return;
let data;
try {
data = await API.get(`/passport/${dog.id}`);
} catch (e) {
wrap.innerHTML = `<p style="color:var(--c-danger)">Fehler beim Laden: ${_esc(e.message)}</p>`;
return;
}
const _fmt = d => {
if (!d) return '';
try {
const p = d.substring(0, 10).split('-');
return `${p[2]}.${p[1]}.${p[0]}`;
} catch { return d; }
};
const meta = data.meta || {};
const vaccs = data.vaccinations || [];
const meds = data.medications || [];
wrap.innerHTML = `
<!-- Meta: Blutgruppe, Allergien, Besonderheiten -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
Gesundheits-Info
</span>
<button class="btn btn-secondary btn-sm" id="pp-meta-edit-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
Bearbeiten
</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Blutgruppe</div>
<div id="pp-meta-blutgruppe" style="font-size:var(--text-sm);font-weight:500">
${_esc(meta.blutgruppe) || '<span style="color:var(--c-text-muted)">nicht eingetragen</span>'}
</div>
</div>
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Allergien</div>
<div id="pp-meta-allergien" style="font-size:var(--text-sm)">
${_esc(meta.allergien) || '<span style="color:var(--c-text-muted)">keine</span>'}
</div>
</div>
</div>
${meta.besonderheiten ? `
<div style="margin-top:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Besonderheiten</div>
<div id="pp-meta-besonderheiten" style="font-size:var(--text-sm)">
${_esc(meta.besonderheiten)}
</div>
</div>` : ''}
</div>
<!-- Impfungen -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
Impfungen
</span>
<button class="btn btn-primary btn-sm" id="pp-vacc-add-btn">+ Eintragen</button>
</div>
<div id="pp-vacc-list">
${vaccs.length === 0
? `<div style="text-align:center;padding:var(--space-4) var(--space-2);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:32px;height:32px;margin-bottom:var(--space-2);opacity:.4" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
<p style="font-size:var(--text-sm);margin:0">Noch keine Impfungen eingetragen.<br>Klicke auf „+ Eintragen" um loszulegen.</p>
</div>`
: vaccs.map(v => `
<div class="pp-vacc-row" data-id="${v.id}"
class="pp-data-row">
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.krankheit)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Gegeben: ${_fmt(v.datum)}
${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''}
${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''}
${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
style="color:var(--c-danger);flex-shrink:0;padding:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`).join('')
}
</div>
</div>
<!-- Medikamente -->
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
Medikamente
</span>
<button class="btn btn-primary btn-sm" id="pp-med-add-btn">+ Eintragen</button>
</div>
<div id="pp-med-list">
${meds.length === 0
? `<div style="text-align:center;padding:var(--space-4) var(--space-2);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:32px;height:32px;margin-bottom:var(--space-2);opacity:.4" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
<p style="font-size:var(--text-sm);margin:0">Noch keine Medikamente eingetragen.<br>Klicke auf „+ Eintragen" um loszulegen.</p>
</div>`
: meds.map(m => `
<div class="pp-med-row" data-id="${m.id}"
class="pp-data-row">
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(m.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${m.dosierung ? `${_esc(m.dosierung)} · ` : ''}
${m.von ? `Von ${_fmt(m.von)}` : ''}
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
${m.notiz ? ` · ${_esc(m.notiz)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}"
style="color:var(--c-danger);flex-shrink:0;padding:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`).join('')
}
</div>
</div>
`;
// Meta bearbeiten
document.getElementById('pp-meta-edit-btn')?.addEventListener('click', () => {
_editPassportMeta(dog, meta, () => _loadPassportBody(dog));
});
// Impfung hinzufügen
document.getElementById('pp-vacc-add-btn')?.addEventListener('click', () => {
_addVaccination(dog, () => _loadPassportBody(dog));
});
// Impfung löschen
wrap.querySelectorAll('.pp-vacc-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Impfung wirklich löschen?')) return;
try {
await API.del(`/passport/${dog.id}/vaccinations/${btn.dataset.id}`);
_loadPassportBody(dog);
} catch (e) {
UI.toast.error(e.message || 'Fehler');
}
});
});
// Medikament hinzufügen
document.getElementById('pp-med-add-btn')?.addEventListener('click', () => {
_addMedication(dog, () => _loadPassportBody(dog));
});
// Medikament löschen
wrap.querySelectorAll('.pp-med-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Medikament wirklich löschen?')) return;
try {
await API.del(`/passport/${dog.id}/medications/${btn.dataset.id}`);
_loadPassportBody(dog);
} catch (e) {
UI.toast.error(e.message || 'Fehler');
}
});
});
}
function _editPassportMeta(dog, current, onSave) {
UI.modal.open({
title: 'Gesundheits-Info bearbeiten',
body: `
<div class="form-group">
<label class="form-label">Blutgruppe</label>
<input id="pp-meta-bg" class="form-control" type="text"
value="${_esc(current.blutgruppe || '')}" placeholder="z. B. DEA 1.1 positiv">
</div>
<div class="form-group">
<label class="form-label">Allergien</label>
<textarea id="pp-meta-al" class="form-control" rows="2"
placeholder="z. B. Hühnchen, Flohspeichel">${_esc(current.allergien || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Besonderheiten</label>
<textarea id="pp-meta-be" class="form-control" rows="2"
placeholder="z. B. Herzprobleme, Angstpatient">${_esc(current.besonderheiten || '')}</textarea>
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-meta-save">Speichern</button>
</div>`,
});
document.getElementById('pp-meta-save').addEventListener('click', async () => {
const btn = document.getElementById('pp-meta-save');
UI.setLoading(btn, true);
try {
await API.put(`/passport/${dog.id}/meta`, {
blutgruppe: document.getElementById('pp-meta-bg').value.trim() || null,
allergien: document.getElementById('pp-meta-al').value.trim() || null,
besonderheiten: document.getElementById('pp-meta-be').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Gesundheits-Info gespeichert.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
function _addVaccination(dog, onSave) {
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: 'Impfung eintragen',
body: `
<div class="form-group">
<label class="form-label">Krankheit *</label>
<input id="pp-vacc-krankheit" class="form-control" type="text"
placeholder="z. B. Staupe, Parvovirose, Tollwut, DHPP" list="pp-vacc-list">
<datalist id="pp-vacc-list">
<option value="Staupe">
<option value="Parvovirose">
<option value="Hepatitis (HCC)">
<option value="Leptospirose">
<option value="Tollwut">
<option value="Kennel-Husten (Bordetella)">
<option value="Borreliose">
<option value="DHPP (Kombi)">
</datalist>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum *</label>
<input id="pp-vacc-datum" class="form-control" type="date" value="${today}">
</div>
<div class="form-group">
<label class="form-label">Nächste fällig</label>
<input id="pp-vacc-naechste" class="form-control" type="date">
</div>
</div>
<div class="form-group">
<label class="form-label">Tierarzt</label>
<input id="pp-vacc-tierarzt" class="form-control" type="text" placeholder="Name der Praxis">
</div>
<div class="form-group">
<label class="form-label">Charge-Nr.</label>
<input id="pp-vacc-charge" class="form-control" type="text" placeholder="Chargennummer des Impfstoffs">
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-vacc-save">Speichern</button>
</div>`,
});
document.getElementById('pp-vacc-save').addEventListener('click', async () => {
const krankheit = document.getElementById('pp-vacc-krankheit').value.trim();
const datum = document.getElementById('pp-vacc-datum').value;
if (!krankheit || !datum) {
UI.toast.warning('Bitte Krankheit und Datum angeben.');
return;
}
const btn = document.getElementById('pp-vacc-save');
UI.setLoading(btn, true);
try {
await API.post(`/passport/${dog.id}/vaccinations`, {
krankheit,
datum,
naechste: document.getElementById('pp-vacc-naechste').value || null,
tierarzt: document.getElementById('pp-vacc-tierarzt').value.trim() || null,
charge_nr: document.getElementById('pp-vacc-charge').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Impfung eingetragen.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
function _addMedication(dog, onSave) {
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: 'Medikament eintragen',
body: `
<div class="form-group">
<label class="form-label">Medikament *</label>
<input id="pp-med-name" class="form-control" type="text"
placeholder="z. B. Frontline, Milbemax, Onsior">
</div>
<div class="form-group">
<label class="form-label">Dosierung</label>
<input id="pp-med-dosierung" class="form-control" type="text"
placeholder="z. B. 1× täglich, 5 mg">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Von</label>
<input id="pp-med-von" class="form-control" type="date" value="${today}">
</div>
<div class="form-group">
<label class="form-label">Bis <span style="color:var(--c-text-muted)">(leer = dauerhaft)</span></label>
<input id="pp-med-bis" class="form-control" type="date">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<input id="pp-med-notiz" class="form-control" type="text"
placeholder="z. B. nach dem Fressen geben">
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-med-save">Speichern</button>
</div>`,
});
document.getElementById('pp-med-save').addEventListener('click', async () => {
const name = document.getElementById('pp-med-name').value.trim();
if (!name) {
UI.toast.warning('Bitte einen Namen angeben.');
return;
}
const btn = document.getElementById('pp-med-save');
UI.setLoading(btn, true);
try {
await API.post(`/passport/${dog.id}/medications`, {
name,
dosierung: document.getElementById('pp-med-dosierung').value.trim() || null,
von: document.getElementById('pp-med-von').value || null,
bis: document.getElementById('pp-med-bis').value || null,
notiz: document.getElementById('pp-med-notiz').value.trim() || null,
});
UI.modal.close();
UI.toast.success('Medikament eingetragen.');
onSave();
} catch (e) {
UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler');
}
});
}
async function _createPassportShare(dog) {
const btn = document.getElementById('pp-share-btn');
if (btn) UI.setLoading(btn, true);
try {
const res = await API.post(`/passport/${dog.id}/share`, {});
const url = `${location.origin}${res.url}`;
if (btn) UI.setLoading(btn, false);
// Zeige Share-Link im Modal (window.confirm wäre zu kurz)
const shareWrap = document.createElement('div');
shareWrap.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
Dieser Link ist 30 Tage gültig. Tierärzte und Sitter können den Pass ohne Login öffnen.
</p>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input id="pp-sharelink-input" class="form-control" type="text" readonly
value="${_esc(url)}" style="font-size:var(--text-xs)">
<button class="btn btn-secondary btn-sm" id="pp-sharelink-copy" style="flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
Gültig bis: ${res.valid_until.split('-').reverse().join('.')}
</p>`;
UI.modal.open({
title: 'Hundepass-Link teilen',
body: shareWrap.innerHTML,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
document.getElementById('pp-sharelink-copy')?.addEventListener('click', async () => {
await navigator.clipboard.writeText(url).catch(() => {});
UI.toast.success('Link kopiert!');
});
} catch (e) {
if (btn) UI.setLoading(btn, false);
UI.toast.error(e.message || 'Fehler beim Erstellen des Links.');
}
}
// ----------------------------------------------------------
// JAHRESRÜCKBLICK — WRAPPED
// ----------------------------------------------------------
async function _showWrappedModal(dog) {
const year = new Date().getFullYear();
let data = null;
try {
data = await API.get(`/dogs/${dog.id}/wrapped?year=${year}`);
} catch (e) {
UI.toast.error('Rückblick konnte nicht geladen werden.');
return;
}
const name = _esc(data.dog_name);
const km = data.gesamt_km || 0;
const konfetti = km > 100;
const _TYPEN = {
eintrag: 'Tagebuch', gassi: 'Gassi', training: 'Training',
tierarzt: 'Tierarzt', freizeit: 'Freizeit', milestone: 'Meilenstein',
};
const aktivitaet = data.lieblings_aktivitaet
? (_TYPEN[data.lieblings_aktivitaet] || data.lieblings_aktivitaet)
: null;
const stadtpark = km > 0 ? Math.round(km / 1.5) : 0;
const schneeheld = data.wetter_kalt >= 10;
const pfotalalarm = data.wetter_warm >= 10;
const _card = (content) =>
`<div style="min-height:320px;display:flex;flex-direction:column;
align-items:center;justify-content:center;text-align:center;
padding:32px 24px;gap:16px">${content}</div>`;
const cards = [
_card(`
<div style="font-size:3rem">🐾</div>
<div style="font-size:1.6rem;font-weight:800;color:#e8c96e;line-height:1.2">
Dein Jahr mit ${name}
</div>
<div style="font-size:1rem;color:#b8b0a0;font-weight:500">${year} in Zahlen</div>
`),
_card(`
<div style="font-size:2.5rem">👟</div>
<div style="font-size:3rem;font-weight:900;color:#e8c96e">${km} km</div>
<div style="font-size:1rem;color:#d0c8b8;font-weight:600">zusammen gelaufen</div>
${stadtpark > 0 ? `<div style="font-size:0.85rem;color:#888;margin-top:4px">= ${stadtpark}× um den Stadtpark</div>` : ''}
${konfetti ? `<div style="font-size:1.5rem;margin-top:8px">🎉 Über 100 km!</div>` : ''}
`),
_card(`
<div style="font-size:2.5rem">📔</div>
<div style="font-size:3rem;font-weight:900;color:#e8c96e">${data.eintraege_gesamt}</div>
<div style="font-size:1rem;color:#d0c8b8;font-weight:600">Tagebucheinträge</div>
${data.fotos_gesamt > 0 ? `<div style="font-size:1.1rem;color:#a0c890;font-weight:700;margin-top:4px">📷 ${data.fotos_gesamt} Fotos</div>` : ''}
${data.gassi_tage > 0 ? `<div style="font-size:0.9rem;color:#888;margin-top:4px">🐾 ${data.gassi_tage} aktive Tage</div>` : ''}
${data.lieblings_monat ? `<div style="font-size:0.85rem;color:#b89a6a;margin-top:4px">Meiste Einträge: ${_esc(data.lieblings_monat)}</div>` : ''}
${aktivitaet ? `<div style="font-size:0.85rem;color:#888">Lieblingsaktivität: ${_esc(aktivitaet)}</div>` : ''}
`),
_card(`
<div style="font-size:2rem">🌡️</div>
<div style="font-size:1.2rem;font-weight:700;color:#d0c8b8;margin-bottom:8px">Wetter-Tapferkeit</div>
<div style="display:flex;gap:32px;justify-content:center;flex-wrap:wrap">
<div><div style="font-size:2rem">❄️</div>
<div style="font-size:2rem;font-weight:900;color:#8ecfef">${data.wetter_kalt}</div>
<div style="font-size:0.8rem;color:#888">kalte Tage</div></div>
<div><div style="font-size:2rem">☀️</div>
<div style="font-size:2rem;font-weight:900;color:#f0b040">${data.wetter_warm}</div>
<div style="font-size:0.8rem;color:#888">heiße Tage</div></div>
</div>
${schneeheld ? `<div style="margin-top:12px;background:#1a3a5c;border-radius:8px;padding:6px 16px;font-size:0.9rem;font-weight:700;color:#8ecfef">❄️ Schneeheld!</div>` : ''}
${pfotalalarm ? `<div style="margin-top:12px;background:#3a2000;border-radius:8px;padding:6px 16px;font-size:0.9rem;font-weight:700;color:#f0b040">🔥 Pfoten-Alarm!</div>` : ''}
${data.training_sessions > 0 ? `<div style="margin-top:12px;font-size:0.85rem;color:#a0c890">🏋️ ${data.training_sessions} Training-Sessions</div>` : ''}
`),
_card(`
<div style="font-size:2.5rem">🐾</div>
<div style="font-size:1.3rem;font-weight:800;color:#e8c96e">Was für ein Jahr!</div>
<div style="font-size:0.95rem;color:#b8b0a0;line-height:1.5;max-width:280px">
${name} und du — ein unschlagbares Team.<br>${year} war unvergesslich.
</div>
<button id="dp-wrapped-copy-btn" style="
margin-top:12px;background:#e8c96e;color:#1a1a2e;font-weight:800;
border:none;border-radius:8px;padding:10px 20px;cursor:pointer;font-size:1rem">
📋 Text kopieren
</button>
`),
];
let currentCard = 0;
const totalCards = cards.length;
const renderDots = () => Array.from({ length: totalCards }, (_, i) =>
`<div style="width:8px;height:8px;border-radius:50%;background:${i === currentCard ? '#e8c96e' : '#444'};transition:background .3s"></div>`
).join('');
const modalEl = document.createElement('div');
modalEl.style.cssText = 'position:fixed;inset:0;z-index:9999;background:#0d0d1a;display:flex;flex-direction:column;overflow:hidden;';
modalEl.innerHTML = `
<div style="display:flex;justify-content:flex-end;padding:max(16px,env(safe-area-inset-top)) 20px 0">
<button id="dp-wrapped-close" style="background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:44px;height:44px;font-size:1.4rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center">×</button>
</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">
<div id="dp-wrapped-card-container" style="width:100%;max-width:400px;color:#fff;">${cards[0]}</div>
<button id="dp-wrapped-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:none;align-items:center;justify-content:center"></button>
<button id="dp-wrapped-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>
</div>
<div id="dp-wrapped-dots" style="display:flex;gap:8px;justify-content:center;padding:16px 0 32px">${renderDots()}</div>
`;
document.body.appendChild(modalEl);
const cardContainer = modalEl.querySelector('#dp-wrapped-card-container');
const dotsEl = modalEl.querySelector('#dp-wrapped-dots');
const prevBtn = modalEl.querySelector('#dp-wrapped-prev');
const nextBtn = modalEl.querySelector('#dp-wrapped-next');
const updateCard = () => {
cardContainer.innerHTML = cards[currentCard];
dotsEl.innerHTML = renderDots();
prevBtn.style.display = currentCard > 0 ? 'flex' : 'none';
nextBtn.style.display = currentCard < totalCards - 1 ? 'flex' : 'none';
if (currentCard === totalCards - 1) {
cardContainer.querySelector('#dp-wrapped-copy-btn')?.addEventListener('click', async () => {
const shareText = `🐾 ${name} & ich — Jahresrückblick ${year}\n`
+ (km > 0 ? `👟 ${km} km gelaufen\n` : '')
+ (data.eintraege_gesamt > 0 ? `📔 ${data.eintraege_gesamt} Tagebucheinträge\n` : '')
+ (data.fotos_gesamt > 0 ? `📷 ${data.fotos_gesamt} Fotos\n` : '')
+ (data.training_sessions > 0 ? `🏋️ ${data.training_sessions} Training-Sessions\n` : '')
+ `\nbanyaro.app`;
try {
await navigator.clipboard.writeText(shareText);
UI.toast.success('Text kopiert!');
} catch {
UI.toast.error('Kopieren fehlgeschlagen.');
}
});
}
};
prevBtn.addEventListener('click', () => { if (currentCard > 0) { currentCard--; updateCard(); } });
nextBtn.addEventListener('click', () => { if (currentCard < totalCards - 1) { currentCard++; updateCard(); } });
modalEl.querySelector('#dp-wrapped-close').addEventListener('click', () => modalEl.remove());
let touchStartX = 0;
modalEl.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; }, { passive: true });
modalEl.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > 50) {
if (dx < 0 && currentCard < totalCards - 1) { currentCard++; updateCard(); }
if (dx > 0 && currentCard > 0) { currentCard--; updateCard(); }
}
});
const onKey = e => { if (e.key === 'Escape') { modalEl.remove(); document.removeEventListener('keydown', onKey); } };
document.addEventListener('keydown', onKey);
}
// ----------------------------------------------------------
// HUNDE-BUCH
// ----------------------------------------------------------
function _showBuchModal(dog) {
const currentYear = new Date().getFullYear();
let selectedJahr = String(currentYear);
let nurFotos = false;
let nurMeilensteine = false;
const modalEl = document.createElement('div');
modalEl.style.cssText = `
position:fixed;inset:0;z-index:9999;
background:rgba(0,0,0,0.55);
display:flex;align-items:center;justify-content:center;padding:16px;
`;
const renderModal = () => {
const years = [String(currentYear - 1), String(currentYear), 'alle'];
const yearBtns = years.map(y => {
const active = selectedJahr === y
? 'background:#7a4f1a;color:#f5e4c0;border-color:#7a4f1a;'
: 'background:#f5f0e8;color:#444;border-color:#e0d4b8;';
const label = y === 'alle' ? 'Alle' : y;
return `<button onclick="window._buchSetJahr('${y}')" style="
border:1px solid;border-radius:8px;padding:8px 16px;
font-size:0.9rem;cursor:pointer;font-family:inherit;
${active}
">${label}</button>`;
}).join('');
const togStyle = (active) =>
active
? 'background:#7a4f1a;color:#f5e4c0;border-color:#7a4f1a;'
: 'background:#f5f0e8;color:#444;border-color:#e0d4b8;';
modalEl.innerHTML = `
<div style="
background:#fff;border-radius:16px;padding:32px 24px;
max-width:420px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.2);
font-family:system-ui,sans-serif;
">
<div style="font-size:1.4rem;font-weight:700;margin-bottom:4px">📖 Hunde-Buch erstellen</div>
<div style="font-size:0.9rem;color:#888;margin-bottom:24px">
Eine druckbare Ansicht der schönsten Einträge.<br>Im Browser als PDF speichern.
</div>
<div style="margin-bottom:20px">
<div style="font-weight:600;margin-bottom:10px;color:#555;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em">Jahrgang</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">${yearBtns}</div>
</div>
<div style="margin-bottom:20px;display:flex;flex-direction:column;gap:10px">
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
<button onclick="window._buchToggleFotos()" style="
width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;
${togStyle(nurFotos)}
">${nurFotos ? '✓' : ''}</button>
<span style="font-size:0.95rem">Nur Einträge mit Fotos</span>
</label>
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
<button onclick="window._buchToggleMeilensteine()" style="
width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;
${togStyle(nurMeilensteine)}
">${nurMeilensteine ? '✓' : ''}</button>
<span style="font-size:0.95rem">Nur Meilensteine</span>
</label>
</div>
<div style="display:flex;gap:10px">
<button onclick="window._buchOpen()" style="
flex:1;background:#7a4f1a;color:#f5e4c0;border:none;border-radius:10px;
padding:14px;font-size:1rem;font-weight:700;cursor:pointer;font-family:inherit;
">📖 Buch öffnen</button>
<button onclick="window._buchClose()" style="
background:#f0f0f0;color:#555;border:none;border-radius:10px;
padding:14px 18px;font-size:1rem;cursor:pointer;font-family:inherit;
">✕</button>
</div>
</div>
`;
};
window._buchSetJahr = (y) => { selectedJahr = y; renderModal(); };
window._buchToggleFotos = () => { nurFotos = !nurFotos; renderModal(); };
window._buchToggleMeilensteine = () => { nurMeilensteine = !nurMeilensteine; renderModal(); };
window._buchClose = () => {
modalEl.remove();
delete window._buchSetJahr;
delete window._buchToggleFotos;
delete window._buchToggleMeilensteine;
delete window._buchOpen;
delete window._buchClose;
};
window._buchOpen = () => {
const params = new URLSearchParams();
if (selectedJahr !== 'alle') params.set('jahr', selectedJahr);
if (nurFotos) params.set('nur_fotos', 'true');
if (nurMeilensteine) params.set('nur_meilensteine', 'true');
const url = `/api/dogs/${dog.id}/buch?${params.toString()}`;
window.open(url, '_blank');
};
renderModal();
document.body.appendChild(modalEl);
modalEl.addEventListener('click', e => { if (e.target === modalEl) window._buchClose(); });
const onKey = e => {
if (e.key === 'Escape') { window._buchClose(); document.removeEventListener('keydown', onKey); }
};
document.addEventListener('keydown', onKey);
}
// ----------------------------------------------------------
// LEBENS-TIMELINE
// ----------------------------------------------------------
async function _showTimelineModal(dog) {
UI.modal.open({
title: `Lebens-Timeline — ${_esc(dog.name)}`,
body: `<div id="dp-timeline-body" style="min-height:200px;text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
size: 'large',
});
let data;
try {
data = await API.get(`/dogs/${dog.id}/timeline`);
} catch (e) {
const b = document.getElementById('dp-timeline-body');
if (b) b.innerHTML = `<p style="color:var(--c-danger)">Fehler: ${_esc(e.message)}</p>`;
return;
}
const wrap = document.getElementById('dp-timeline-body');
if (!wrap) return;
const events = data.events || [];
if (!events.length) {
wrap.innerHTML = `<p style="color:var(--c-text-secondary);padding:var(--space-6)">
Noch keine Einträge vorhanden. Beginne dein Tagebuch oder trage Gesundheitsdaten ein.
</p>`;
return;
}
const _KAT = {
meilenstein: { color: '#8b5cf6', icon: 'star', label: 'Meilenstein' },
tagebuch: { color: 'var(--c-primary)', icon: 'book-open', label: 'Tagebuch' },
gesundheit: { color: '#ef4444', icon: 'heartbeat', label: 'Gesundheit' },
training: { color: '#22c55e', icon: 'target', label: 'Training' },
route: { color: '#3b82f6', icon: 'path', label: 'Route' },
};
const _fmtDate = d => {
if (!d) return '';
try {
const p = d.substring(0, 10).split('-');
return `${p[2]}.${p[1]}.${p[0]}`;
} catch { return d; }
};
let lastYear = null;
let html = '<div class="tl-wrap">';
for (const ev of events) {
const year = ev.datum ? ev.datum.substring(0, 4) : null;
if (year && year !== lastYear) {
html += `<div class="tl-year">${_esc(year)}</div>`;
lastYear = year;
}
const kat = _KAT[ev.kategorie] || _KAT.tagebuch;
const big = ev.is_milestone;
let label = _esc(ev.titel);
if (ev.is_first && ev.kategorie === 'tagebuch') label = `🎉 Erster Tagebucheintrag — ${label}`;
if (ev.is_first && ev.kategorie === 'route') label = `🎉 Erste Route — ${label}`;
if (ev.is_first && ev.kategorie === 'training') label = `🎉 Erstes Training — ${label}`;
if (ev.typ === 'geburtstag') label = `🎂 ${label}`;
const dotSize = big ? '18px' : '12px';
const dotBorder = big ? `3px solid ${kat.color}` : `2px solid ${kat.color}`;
const dotML = big ? '6px' : '9px';
html += `
<div class="tl-item${big ? ' tl-item--big' : ''}">
<div class="tl-dot" style="width:${dotSize};height:${dotSize};
background:${kat.color};border:${dotBorder};
margin-left:${dotML};
box-shadow:${big ? `0 0 0 4px ${kat.color}22` : 'none'}"></div>
<div class="tl-card">
${big && ev.foto_url ? `
<div class="tl-foto" style="background-image:url(${_esc(ev.foto_url)})"></div>` : ''}
<div class="tl-meta">
<span class="tl-badge" style="background:${kat.color}22;color:${kat.color}">
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${kat.icon}"></use>
</svg>
${_esc(kat.label)}
</span>
<span class="tl-date">${_fmtDate(ev.datum)}</span>
</div>
<div class="tl-title${big ? ' tl-title--big' : ''}">${label}</div>
${ev.distanz_km ? `<div class="tl-sub">${ev.distanz_km} km</div>` : ''}
</div>
</div>`;
}
html += '</div>';
html += `
<style>
.tl-wrap { padding:var(--space-2) 0;position:relative; }
.tl-wrap::before {
content:'';position:absolute;left:15px;top:0;bottom:0;width:2px;
background:var(--c-border);border-radius:1px;
}
.tl-year {
padding:var(--space-2) 0 var(--space-2) 48px;
font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.08em;
}
.tl-item {
display:flex;align-items:flex-start;gap:var(--space-3);
margin-bottom:var(--space-3);position:relative;
}
.tl-dot {
flex-shrink:0;border-radius:50%;margin-top:4px;z-index:1;position:relative;
}
.tl-card {
flex:1;min-width:0;background:var(--c-surface-2);
border-radius:var(--radius-md);padding:var(--space-3);overflow:hidden;
}
.tl-item--big .tl-card { border-left:3px solid var(--c-primary); }
.tl-foto {
width:100%;height:120px;background-size:cover;background-position:center;
border-radius:var(--radius-sm);margin-bottom:var(--space-2);
}
.tl-meta {
display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap;
}
.tl-badge {
display:inline-flex;align-items:center;gap:3px;
padding:2px 8px;border-radius:var(--radius-full);
font-size:var(--text-xs);font-weight:var(--weight-semibold);
}
.tl-date { font-size:var(--text-xs);color:var(--c-text-secondary);margin-left:auto; }
.tl-title { font-size:var(--text-sm);color:var(--c-text);font-weight:var(--weight-medium); }
.tl-title--big { font-weight:var(--weight-semibold);font-size:var(--text-base); }
.tl-sub { font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px; }
</style>`;
wrap.innerHTML = html;
}
// ----------------------------------------------------------
// RASSEN-COMMUNITY-CHIP
// ----------------------------------------------------------
async function _loadSameBreedChip() {
const el = document.getElementById('dp-same-breed-chip');
if (!el) return;
try {
const data = await API.get('friends/same-breed');
if (!data || data.count === 0) return;
const hauptRasse = data.rassen[0]?.rasse || '';
const label = data.count === 1
? `1 anderer ${_esc(hauptRasse)}-Halter in der App`
: `${data.count} andere ${_esc(hauptRasse)}-Halter in der App`;
el.innerHTML = `
<button class="breed-community-chip" id="dp-breed-chip-btn">
🐕 ${label} &mdash; Forum ansehen
</button>
`;
document.getElementById('dp-breed-chip-btn')?.addEventListener('click', () => {
App.navigate('forum', false, { search: hauptRasse });
});
} catch {}
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
// ----------------------------------------------------------
// GEDENKEN-FLOW
// ----------------------------------------------------------
async function _openGedenkenFlow(dog) {
// Schritt 1: Würdevoller Übergangsdialog mit Datum-Eingabe
UI.modal.open({
title: `Abschied von ${dog.name}`,
body: `
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)">
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary);opacity:0.7" aria-hidden="true">
<use href="/icons/phosphor.svg#heart"></use>
</svg>
<p style="color:var(--c-text-secondary);margin:var(--space-3) 0 var(--space-4);line-height:1.6">
${dog.name} hinterlässt eine riesige Lücke.<br>
Die gemeinsamen Erinnerungen bleiben für immer.
</p>
</div>
<form id="gedenken-form">
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Datum des Abschieds
</label>
<input type="date" id="gedenken-datum" name="datum"
value="${new Date().toISOString().slice(0,10)}"
max="${new Date().toISOString().slice(0,10)}"
style="width:100%;padding:10px 12px;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-bg-card);color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</form>`,
footer: `
<div class="w3-btn-stack">
<button type="submit" form="gedenken-form" id="gedenken-save-btn" class="btn btn-primary" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
Gedenkseite erstellen
</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>`,
});
document.getElementById('gedenken-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('gedenken-save-btn');
const datum = document.getElementById('gedenken-datum').value;
await UI.asyncButton(btn, async () => {
await API.post(`/dogs/${dog.id}/gedenken`, { verstorben_am: datum });
// Aus aktiver Hundeliste entfernen
_appState.dogs = _appState.dogs.filter(d => d.id !== dog.id);
_appState.activeDog = _appState.dogs[0] || null;
UI.modal.close();
// Gedenkseite öffnen
await _openGedenkseite(dog.id, dog.name);
await _render();
});
});
}
async function _openGedenkseite(dogId, dogName) {
UI.modal.open({ title: `Erinnerungen an ${dogName}`, body: `
<div style="text-align:center;padding:var(--space-4)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary);animation:spin 1s linear infinite">
<use href="/icons/phosphor.svg#spinner"></use>
</svg>
</div>` });
let data;
try { data = await API.get(`/dogs/${dogId}/gedenkseite`); }
catch { UI.modal.close(); return; }
const d = data;
const av = d.dog.foto_url
? `<img src="${UI.escape(d.dog.foto_url)}" style="width:100px;height:100px;border-radius:50%;object-fit:cover;border:3px solid var(--c-primary)">`
: `<div style="width:100px;height:100px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;border:3px solid var(--c-primary)"><svg class="ph-icon" style="width:48px;height:48px;color:var(--c-primary)"><use href="/icons/phosphor.svg#dog"></use></svg></div>`;
const photoGrid = d.photos.length ? `
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin:var(--space-4) 0">
${d.photos.map(url => `<img src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px">`).join('')}
</div>` : '';
const statsHtml = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin:var(--space-4) 0">
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.km_total}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">km zusammen</div>
</div>` : ''}
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Tagebucheinträge</div>
</div>` : ''}
${d.media_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.media_count}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Fotos</div>
</div>` : ''}
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-heart"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">gemeinsame Tage</div>
</div>` : ''}
</div>`;
// Trauer-Support-Texte
const supportHtml = `
<div style="background:var(--c-primary-subtle);border-left:3px solid var(--c-primary);
border-radius:0 var(--radius-md) var(--radius-md) 0;padding:var(--space-4);margin:var(--space-4) 0">
<div style="font-weight:700;margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#heartbeat"></use></svg>
Für dich in dieser Zeit
</div>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
Der Schmerz über den Verlust eines Hundes ist real und tief. Du musst nicht stark sein.
Lass dich trauern — so lange du brauchst. Die Erinnerungen bleiben immer bei dir.
</p>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.8">
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
Sprich mit Freunden oder der Familie über ${d.dog.name} — Geschichten lebendig halten hilft.
</div>
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
Das Tagebuch bleibt erhalten — es ist ein kostbares Stück gemeinsamer Geschichte.
</div>
<div style="display:flex;align-items:flex-start;gap:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:3px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
Professionelle Hilfe bei Tiertrauer: <strong>Tiertrauer-Hotline 0800 111 0 111</strong> (kostenlos)
</div>
</div>
<div id="gedenk-ki-wrap" style="margin-top:var(--space-4)">
<button id="gedenk-ki-btn" class="btn btn-secondary" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg>
Persönlichen Abschiedstext erstellen
</button>
</div>`;
const modal = UI.modal.open({
title: `🌈 Erinnerungen an ${UI.escape(d.dog.name)}`,
body: `
<div style="text-align:center;margin-bottom:var(--space-4)">
${av}
<div style="font-size:var(--text-xl);font-weight:800;margin-top:var(--space-3)">${UI.escape(d.dog.name)}</div>
${d.dog.rasse ? `<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${UI.escape(d.dog.rasse)}</div>` : ''}
${d.dog.verstorben_am ? `<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:4px">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#rainbow"></use></svg>
Über die Regenbogenbrücke am ${new Date(d.dog.verstorben_am).toLocaleDateString('de-DE')}
</div>` : ''}
</div>
${photoGrid}
${statsHtml}
${supportHtml}`,
});
document.getElementById('gedenk-ki-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('gedenk-ki-btn');
await UI.asyncButton(btn, async () => {
const result = await API.post('/ki/abschied', {
dog_id: dogId,
name: d.dog.name,
rasse: d.dog.rasse,
km_total: d.km_total,
diary_count: d.diary_count,
gemeinsam_tage: d.gemeinsam_tage,
});
const wrap = document.getElementById('gedenk-ki-wrap');
if (wrap) wrap.innerHTML = `
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-4);
font-size:var(--text-sm);line-height:1.7;color:var(--c-text);font-style:italic">
"${UI.escape(result.text)}"
</div>`;
});
});
}
return { init, refresh, onDogChange, addNew: _openCreateModal };
})();