Sprint 12: UI-Vereinheitlichung + Läufigkeits-Tracker

- by-tabs/by-tab: einheitliche Tab/Pill-Navigation in allen Seiten
- by-section-label, by-toolbar: einheitliche Section-Labels und Toolbars
- Design-Tokens: fehlende --c-amber, --c-primary-soft ergänzt, Fallback-Werte entfernt
- sitting.js: sitting-layout für konsistentes flush-Layout (wie walks)
- Läufigkeits-Tracker: neuer Health-Tab für Hündinnen mit Zyklusvorhersage,
  Timeline vergangener Läufigkeiten, Erinnerungen und auto-berechnetem Nächst-Datum
- emptyState-Bug: icon-Parameter muss SVG sein, nicht Icon-Name (dog/bell/warning gefixt)
- SW-Cache: by-v103, APP_VER: 79
This commit is contained in:
rene 2026-04-16 22:31:33 +02:00
parent 32d630d5a1
commit b58789373c
30 changed files with 4344 additions and 523 deletions

View file

@ -0,0 +1,588 @@
/* ============================================================
BAN YARO Admin-Bereich
Nur für Admins und Moderatoren.
============================================================ */
window.Page_admin = (() => {
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'chart-bar' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'forum', label: 'Forum & Meldungen',icon: 'chat-circle-dots' },
];
// ------------------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
const u = appState.user;
const isMod = u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator;
if (!isMod) {
container.innerHTML = _emptyState('shield', 'Kein Zugriff', 'Dieser Bereich ist nur für Admins und Moderatoren.');
return;
}
_render();
}
function refresh() { _renderTab(); }
function onDogChange() {}
// ------------------------------------------------------------------
// SHELL
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<div style="max-width:720px;margin:0 auto;padding:var(--space-4)">
<!-- Tabs -->
<div class="by-tabs" style="margin-bottom:var(--space-5)" id="adm-tabs">
${TABS.map(t => `
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
${UI.icon(t.icon)} ${t.label}
</button>
`).join('')}
</div>
<!-- Inhalt -->
<div id="adm-content"></div>
</div>
`;
_container.querySelectorAll('#adm-tabs .by-tab').forEach(btn => {
btn.addEventListener('click', () => {
_tab = btn.dataset.tab;
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
});
});
_renderTab();
}
async function _renderTab() {
const el = _container.querySelector('#adm-content');
if (!el) return;
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
try {
switch (_tab) {
case 'uebersicht': await _renderStats(el); break;
case 'nutzer': await _renderUsers(el); break;
case 'forum': await _renderForum(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
}
}
// ------------------------------------------------------------------
// TAB: ÜBERSICHT
// ------------------------------------------------------------------
async function _renderStats(el) {
const s = await API.get('/admin/stats');
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:var(--space-3);
margin-bottom:var(--space-5)">
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')}
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')}
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')}
${_statCard('warning', 'Offene Meldungen',s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')}
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')}
${_statCard('warning-octagon','Giftk. aktiv', s.poison_active,'var(--c-danger)')}
</div>
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)">
<use href="/icons/phosphor.svg#info"></use>
</svg>
Ersten Admin per SQL setzen:
<code style="background:var(--c-surface-2);padding:2px 6px;border-radius:4px;font-size:var(--text-xs)">
UPDATE users SET rolle='admin', is_moderator=1 WHERE email='deine@email.de';
</code>
</p>
</div>
`;
}
function _statCard(icon, label, value, color) {
return `
<div class="card" style="padding:var(--space-4);text-align:center">
<svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)"
aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div style="font-size:var(--text-2xl);font-weight:var(--weight-bold);color:var(--c-text)">
${value ?? '—'}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${label}</div>
</div>
`;
}
// ------------------------------------------------------------------
// TAB: NUTZER
// ------------------------------------------------------------------
async function _renderUsers(el) {
el.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
<input id="adm-user-q" type="search" placeholder="Name oder E-Mail…"
style="flex:1;padding:var(--space-2) var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)">
<select id="adm-user-rolle" style="padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;background:var(--c-surface);color:var(--c-text)">
<option value="">Alle Rollen</option>
<option value="user">user</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
</div>
<div id="adm-user-list">Lade</div>
`;
const load = async () => {
const q = el.querySelector('#adm-user-q').value;
const rolle = el.querySelector('#adm-user-rolle').value;
const data = await API.get(`/admin/users?q=${encodeURIComponent(q)}&rolle=${rolle}`);
_renderUserList(el.querySelector('#adm-user-list'), data.users, data.total);
};
let timer;
el.querySelector('#adm-user-q').addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(load, 350);
});
el.querySelector('#adm-user-rolle').addEventListener('change', load);
await load();
}
function _renderUserList(el, users, total) {
if (!users.length) {
el.innerHTML = _emptyState('users', 'Keine Nutzer gefunden', '');
return;
}
const isAdmin = _appState.user?.rolle === 'admin';
el.innerHTML = `
<div style="margin-bottom:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted)">
${total} Nutzer gefunden
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${users.map(u => `
<div class="card" style="padding:var(--space-3) var(--space-4);
${u.is_banned ? 'opacity:0.6;border-left:3px solid var(--c-danger)' : ''}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<!-- Avatar -->
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary)">
${_esc(u.name[0].toUpperCase())}
</div>
<!-- Info -->
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(u.name)}
${u.is_banned ? `<span style="font-size:10px;padding:1px 5px;border-radius:3px;
background:var(--c-danger);color:#fff;margin-left:4px">
GESPERRT</span>` : ''}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(u.email)} ·
<span style="color:${u.rolle === 'admin' ? 'var(--c-danger)' : u.rolle === 'moderator' ? '#f59e0b' : 'var(--c-text-muted)'}">
${_esc(u.rolle)}
</span>
· ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
· ${u.thread_count} Threads
</div>
</div>
<!-- Aktionen -->
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
${u.is_banned
? `<button class="btn btn-sm btn-ghost adm-unban" data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperre aufheben" style="color:var(--c-success)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#lock-open"></use></svg>
</button>`
: `<button class="btn btn-sm btn-ghost adm-ban" data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperren" style="color:var(--c-danger)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#lock"></use></svg>
</button>`
}
${isAdmin ? `
<button class="btn btn-sm btn-ghost adm-rolle" data-uid="${u.id}"
data-name="${_esc(u.name)}" data-rolle="${_esc(u.rolle)}"
title="Rolle ändern">
<svg class="ph-icon"><use href="/icons/phosphor.svg#shield"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-delete" data-uid="${u.id}"
data-name="${_esc(u.name)}" title="Löschen"
style="color:var(--c-danger)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
` : ''}
</div>
</div>
</div>
`).join('')}
</div>
`;
// Events
el.querySelectorAll('.adm-ban').forEach(btn => {
btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, true));
});
el.querySelectorAll('.adm-unban').forEach(btn => {
btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, false));
});
el.querySelectorAll('.adm-rolle').forEach(btn => {
btn.addEventListener('click', () => _changeRolle(btn.dataset.uid, btn.dataset.name, btn.dataset.rolle));
});
el.querySelectorAll('.adm-delete').forEach(btn => {
btn.addEventListener('click', () => _deleteUser(btn.dataset.uid, btn.dataset.name));
});
}
async function _banUser(uid, name, ban) {
if (ban) {
const reason = await _prompt(`${name} sperren — Grund (optional):`);
if (reason === null) return; // abgebrochen
try {
await API.patch(`/admin/users/${uid}`, { is_banned: 1, ban_reason: reason || 'Kein Grund angegeben.' });
UI.toast.success(`${name} gesperrt.`);
_renderTab();
} catch (e) { UI.toast.error(e.message); }
} else {
try {
await API.patch(`/admin/users/${uid}`, { is_banned: 0, ban_reason: null });
UI.toast.success(`Sperre für ${name} aufgehoben.`);
_renderTab();
} catch (e) { UI.toast.error(e.message); }
}
}
async function _changeRolle(uid, name, currentRolle) {
const rollen = ['user', 'moderator', 'admin'].filter(r => r !== currentRolle);
UI.modal.open({
title: `Rolle ändern: ${name}`,
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
Aktuelle Rolle: <strong>${currentRolle}</strong>
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${rollen.map(r => `
<button class="btn btn-secondary adm-rolle-choice" data-rolle="${r}" form="">
${r === 'admin' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg>` : ''}
${r === 'moderator' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>` : ''}
${r === 'user' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>` : ''}
Als <strong>${r}</strong> setzen
</button>
`).join('')}
</div>
`,
});
document.querySelectorAll('.adm-rolle-choice').forEach(btn => {
btn.addEventListener('click', async () => {
UI.modal.close();
try {
await API.patch(`/admin/users/${uid}`, { rolle: btn.dataset.rolle });
UI.toast.success(`${name} ist jetzt ${btn.dataset.rolle}.`);
_renderTab();
} catch (e) { UI.toast.error(e.message); }
});
});
}
async function _deleteUser(uid, name) {
const ok = await UI.modal.confirm({
title: `${name} löschen?`,
message: 'Alle Daten dieses Accounts werden unwiderruflich gelöscht — Hunde, Tagebuch, Beiträge.',
confirmText: 'Endgültig löschen',
});
if (!ok) return;
try {
await API.del(`/admin/users/${uid}`);
UI.toast.success(`${name} gelöscht.`);
_renderTab();
} catch (e) { UI.toast.error(e.message); }
}
// ------------------------------------------------------------------
// TAB: FORUM & MELDUNGEN
// ------------------------------------------------------------------
async function _renderForum(el) {
el.innerHTML = `
<!-- Unternavigation -->
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
<button class="btn btn-primary btn-sm adm-forum-nav" data-view="reports" id="adm-fn-reports">
Offene Meldungen
</button>
<button class="btn btn-ghost btn-sm adm-forum-nav" data-view="threads" id="adm-fn-threads">
Alle Threads
</button>
</div>
<div id="adm-forum-content">Lade</div>
`;
el.querySelectorAll('.adm-forum-nav').forEach(btn => {
btn.addEventListener('click', async () => {
el.querySelectorAll('.adm-forum-nav').forEach(b => {
b.className = b === btn ? 'btn btn-primary btn-sm adm-forum-nav' : 'btn btn-ghost btn-sm adm-forum-nav';
});
await _renderForumView(el.querySelector('#adm-forum-content'), btn.dataset.view);
});
});
await _renderForumView(el.querySelector('#adm-forum-content'), 'reports');
}
async function _renderForumView(el, view) {
el.innerHTML = '<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>';
if (view === 'reports') {
const reports = await API.get('/admin/reports');
if (!reports.length) {
el.innerHTML = _emptyState('check', 'Keine offenen Meldungen', 'Alles sauber.');
return;
}
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${reports.map(r => `
<div class="card" style="padding:var(--space-4);
${r.resolved ? 'opacity:0.5' : 'border-left:3px solid var(--c-danger)'}">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1)">
${r.resolved ? '✓ Erledigt · ' : ''}
${_esc(r.target_type)} #${r.target_id} ·
Gemeldet von <strong>${_esc(r.melder_name)}</strong>
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-1)">
Grund: ${_esc(r.grund)}
</div>
${r.content_preview ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm)">
${_esc(r.content_preview)}
</div>` : ''}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-sm ${r.resolved ? 'btn-ghost' : 'btn-primary'} adm-resolve-btn"
data-rid="${r.id}" data-resolved="${r.resolved}"
title="${r.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#${r.resolved ? 'arrow-square-out' : 'check'}"></use></svg>
</button>
${!r.resolved ? `
<button class="btn btn-sm btn-ghost adm-del-content"
data-type="${r.target_type}" data-id="${r.target_id}"
title="Inhalt löschen" style="color:var(--c-danger)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>` : ''}
</div>
</div>
</div>
`).join('')}
</div>
`;
el.querySelectorAll('.adm-resolve-btn').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await API.patch(`/admin/reports/${btn.dataset.rid}`, {});
_renderForumView(el, 'reports');
} catch (e) { UI.toast.error(e.message); }
});
});
el.querySelectorAll('.adm-del-content').forEach(btn => {
btn.addEventListener('click', () => _deleteContent(btn.dataset.type, btn.dataset.id, el, 'reports'));
});
} else {
// Threads
el.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3)">
<input id="adm-thread-q" type="search" placeholder="Threads durchsuchen…"
style="flex:1;padding:var(--space-2) var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)">
<label style="display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-secondary);white-space:nowrap">
<input type="checkbox" id="adm-show-deleted"> Gelöschte
</label>
</div>
<div id="adm-thread-list">Lade</div>
`;
const loadThreads = async () => {
const q = el.querySelector('#adm-thread-q').value;
const deleted = el.querySelector('#adm-show-deleted').checked ? 1 : 0;
const data = await API.get(`/admin/forum/threads?q=${encodeURIComponent(q)}&deleted=${deleted}`);
_renderThreadList(el.querySelector('#adm-thread-list'), data.threads, el);
};
let t2;
el.querySelector('#adm-thread-q').addEventListener('input', () => { clearTimeout(t2); t2 = setTimeout(loadThreads, 350); });
el.querySelector('#adm-show-deleted').addEventListener('change', loadThreads);
await loadThreads();
}
}
function _renderThreadList(el, threads, parentEl) {
if (!threads.length) {
el.innerHTML = _emptyState('chat-circle-dots', 'Keine Threads', '');
return;
}
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${threads.map(t => `
<div class="card" style="padding:var(--space-3) var(--space-4);
${t.is_deleted ? 'opacity:0.5;border-left:3px solid var(--c-danger)' : ''}
${t.is_locked ? 'border-left:3px solid #f59e0b' : ''}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${t.is_deleted ? '<s>' : ''}${_esc(t.titel)}${t.is_deleted ? '</s>' : ''}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
von ${_esc(t.autor_name)} ·
${t.antworten} Antworten ·
${t.is_pinned ? '📌 ' : ''}${t.is_locked ? '🔒 ' : ''}${t.is_deleted ? '🗑 gelöscht' : ''}
</div>
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
${!t.is_deleted ? `
<button class="btn btn-sm btn-ghost adm-pin" data-tid="${t.id}"
data-pinned="${t.is_pinned}" title="${t.is_pinned ? 'Entpinnen' : 'Anpinnen'}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#push-pin"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-lock" data-tid="${t.id}"
data-locked="${t.is_locked}" title="${t.is_locked ? 'Entsperren' : 'Sperren'}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#${t.is_locked ? 'lock-open' : 'lock'}"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-del-thread" data-tid="${t.id}"
title="Löschen" style="color:var(--c-danger)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
` : `
<button class="btn btn-sm btn-ghost adm-restore-thread" data-tid="${t.id}"
title="Wiederherstellen" style="color:var(--c-success)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg>
</button>
`}
</div>
</div>
</div>
`).join('')}
</div>
`;
el.querySelectorAll('.adm-pin').forEach(btn => {
btn.addEventListener('click', async () => {
await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_pinned: btn.dataset.pinned === '1' ? 0 : 1 });
parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input'));
});
});
el.querySelectorAll('.adm-lock').forEach(btn => {
btn.addEventListener('click', async () => {
await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_locked: btn.dataset.locked === '1' ? 0 : 1 });
parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input'));
});
});
el.querySelectorAll('.adm-del-thread').forEach(btn => {
btn.addEventListener('click', () => _deleteContent('thread', btn.dataset.tid, parentEl, 'threads'));
});
el.querySelectorAll('.adm-restore-thread').forEach(btn => {
btn.addEventListener('click', async () => {
await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_deleted: 0 });
parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input'));
});
});
}
async function _deleteContent(type, id, parentEl, view) {
const ok = await UI.modal.confirm({
title: `${type === 'thread' ? 'Thread' : 'Beitrag'} löschen?`,
message: 'Der Inhalt wird als gelöscht markiert.',
confirmText: 'Löschen',
});
if (!ok) return;
try {
await API.del(`/admin/forum/${type === 'thread' ? 'threads' : 'posts'}/${id}`);
UI.toast.success('Gelöscht.');
_renderForumView(parentEl, view);
} catch (e) { UI.toast.error(e.message); }
}
// ------------------------------------------------------------------
// HELPERS
// ------------------------------------------------------------------
function _prompt(msg) {
return new Promise(resolve => {
UI.modal.open({
title: 'Eingabe',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">${msg}</p>
<input id="adm-prompt-input" type="text"
style="width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)">
`,
footer: `
<button class="btn btn-primary" id="adm-prompt-ok" form="">OK</button>
<button class="btn btn-ghost" id="adm-prompt-cancel" form="">Abbrechen</button>
`,
});
document.getElementById('adm-prompt-ok')?.addEventListener('click', () => {
const val = document.getElementById('adm-prompt-input')?.value || '';
UI.modal.close();
resolve(val);
});
document.getElementById('adm-prompt-cancel')?.addEventListener('click', () => {
UI.modal.close();
resolve(null);
});
});
}
function _emptyState(icon, title, text) {
return `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<svg class="ph-icon" style="width:40px;height:40px;color:var(--c-border);
margin-bottom:var(--space-3)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<p style="font-weight:var(--weight-semibold);color:var(--c-text);margin:0 0 var(--space-1)">${title}</p>
${text ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">${text}</p>` : ''}
</div>
`;
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();