banyaro/backend/static/js/pages/dog-profile.js
rene ea2a83b29e Feature: Filme-Suche, HdM ins Forum + Gewinner-Badge im Profil, SW by-v594
- Filme-Seite: Suchfeld (filtert live nach Titel, Rasse, Genre, Beschreibung)
- Filme-Seite: Tab "Hund des Monats" entfernt
- Forum: kompakte HdM-Kachel über der Suche (Sieger + Stimmen), Klick öffnet Abstimmungs-Modal
- Hundeprofil: goldene Badges für jeden gewonnenen Monat (🏆 Mai 2026 …)
- DB: Tabelle hund_des_monats_wins (dauerhaft, dog_id + monat + stimmen)
- Scheduler: Job am 1. des Monats 00:05 — schreibt Vormonats-Sieger, Push an Besitzer
- Dogs-API: liefert hdm_wins[] pro Hund mit
2026-05-02 08:12:29 +02:00

1215 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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>`}
${(dog.hdm_wins?.length) ? `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);justify-content:center;margin-bottom:var(--space-5)">
${dog.hdm_wins.map(m => {
const [y, mo] = m.split('-');
const label = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+y, +mo - 1, 1));
return `<span class="dp-hdm-badge" title="Hund des Monats ${label}">🏆 ${label}</span>`;
}).join('')}
</div>
` : `<div style="margin-bottom:var(--space-5)"></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 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#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 style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px">${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 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#scales"></use></svg> Gewicht</div>
<div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</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>` : ''}
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-secondary" style="flex:1" id="dp-ausweis-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
Ausweis
</button>
${!dog.is_guest ? `<button class="btn btn-secondary" style="flex:1" id="dp-share-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
Teilen
</button>` : ''}
</div>
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
+ Weiteren Hund anlegen
</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
</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);
// 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-ausweis-btn')?.addEventListener('click', () => {
_showAusweisModal(dog.id);
});
document.getElementById('dp-share-btn')?.addEventListener('click', () => {
_showShareModal(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 style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<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 style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
${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 {
const fd = new FormData();
fd.append('file', file);
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);
UI.modal.close();
App.renderDogSwitcher();
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) {
UI.modal.open({
title: 'Heimtierausweis',
body: `<iframe src="/ausweis/${dogId}" class="ausweis-frame" title="Heimtierausweis"></iframe>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<a href="/ausweis/${dogId}" target="_blank" class="btn btn-ghost">${UI.icon('printer')} Drucken</a>`,
size: 'fullscreen',
});
}
// ----------------------------------------------------------
// TEILEN
// ----------------------------------------------------------
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 style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<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 style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<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" 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">
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 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)">
<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>
</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);
});
}
document.getElementById('dp-form-cancel')
?.addEventListener('click', UI.modal.close);
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,
chip_nr: fd.chip_nr || null,
bio: fd.bio || null,
is_public: 'is_public' in fd,
};
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
const fotoFile = document.getElementById('dp-form-foto')?.files[0];
if (fotoFile) {
try {
const fd = new FormData();
fd.append('file', fotoFile);
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);
} catch {
UI.toast.warning('Profil gespeichert, Foto konnte nicht hochgeladen werden.');
}
}
// Dog Switcher in Header + Sidebar aktualisieren
App.renderDogSwitcher?.();
await _render();
});
});
}
// ----------------------------------------------------------
// 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;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, onDogChange, addNew: _openCreateModal };
})();