banyaro/backend/static/js/pages/admin.js

1171 lines
55 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 — 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: 'house-line' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
{ id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' },
{ id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
];
// ------------------------------------------------------------------
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 = `
<!-- Tabs -->
<div class="by-tabs adm-tabs" 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>
`;
_container.querySelector('#adm-tabs')
?.style.setProperty('--adm-tab-cols', Math.ceil(TABS.length / 2));
_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 'moderation': await _renderModeration(el); break;
case 'forum': await _renderForum(el); break;
case 'analytics': await _renderAnalytics(el); break;
case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break;
case 'audit': await _renderAudit(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
}
}
// ------------------------------------------------------------------
// TAB: ANALYTICS
async function _renderAnalytics(el) {
const d = await API.get('/admin/analytics');
const pv = d.pageviews?.pageviews ?? [];
const ses = d.pageviews?.sessions ?? [];
// Sparkline SVG (Seitenaufrufe 7 Tage)
function _sparkline(data, color) {
if (!data.length) return '<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Keine Daten</span>';
const vals = data.map(p => p.y ?? 0);
const max = Math.max(...vals, 1);
const W = 200, H = 48, pad = 4;
const pts = vals.map((v, i) => {
const x = pad + i * ((W - 2*pad) / Math.max(vals.length - 1, 1));
const y = H - pad - (v / max) * (H - 2*pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:48px">
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round"/>
</svg>`;
}
// Umami v2 liefert plain numbers, v1 liefert {value: X} — beide abfangen
const tv = v => (v != null && typeof v === 'object') ? (v.value ?? 0) : (v ?? 0);
const fmt = v => Number(v).toLocaleString('de');
// Bounce Rate & Verweildauer
const _bounces = tv(d.today?.bounces);
const _visits = tv(d.today?.visits);
const bounceToday = _visits > 0
? ((_bounces / _visits) * 100).toFixed(0) + ' %'
: '—';
const _totaltime = tv(d.week?.totaltime);
const _visitsW = tv(d.week?.visits);
const timeWeek = _totaltime > 0 && _visitsW > 0
? Math.round(_totaltime / _visitsW) + ' s'
: '—';
el.innerHTML = `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
${_statCard('user', 'Besucher heute', fmt(tv(d.today?.visitors)), 'var(--c-primary)')}
${_statCard('eye', 'Aufrufe heute', fmt(tv(d.today?.pageviews)), 'var(--c-primary)')}
${_statCard('users','Besucher 7 Tage', fmt(tv(d.week?.visitors)), 'var(--c-success)')}
${_statCard('eye', 'Aufrufe 7 Tage', fmt(tv(d.week?.pageviews)), 'var(--c-success)')}
${_statCard('arrow-u-up-left','Bounce heute', bounceToday, 'var(--c-text-secondary)')}
${_statCard('timer','Ø Verweildauer 7 Tage', timeWeek, 'var(--c-text-secondary)')}
</div>
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Seitenaufrufe — letzte 7 Tage</div>
${_sparkline(pv, 'var(--c-primary)')}
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
${pv.map(p => `<span>${new Date(p.x).toLocaleDateString('de',{weekday:'short'})}</span>`).join('')}
</div>
</div>
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Top Seiten — letzte 7 Tage</div>
${(d.top_pages ?? []).length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Daten</p>`
: `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${d.top_pages.map(p => {
const maxY = d.top_pages[0].y;
const pct = maxY > 0 ? (p.y / maxY * 100).toFixed(0) : 0;
return `
<div>
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);margin-bottom:3px">
<span style="color:var(--c-text);overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;max-width:75%">${UI.escape(p.x)}</span>
<span style="color:var(--c-text-secondary);flex-shrink:0">${fmt(p.y)}</span>
</div>
<div style="height:4px;border-radius:2px;background:var(--c-surface-3)">
<div style="height:100%;width:${pct}%;border-radius:2px;
background:var(--c-primary);transition:width .3s"></div>
</div>
</div>`;
}).join('')}
</div>`}
</div>
</div>`;
}
// ------------------------------------------------------------------
// TAB: ÜBERSICHT
// ------------------------------------------------------------------
async function _renderStats(el) {
const s = await API.get('/admin/stats');
el.innerHTML = `
<div class="adm-stats-grid">
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')}
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)')}
${_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)')}
${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)')}
${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')}
${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)')}
${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)')}
</div>
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">KI-Trainer Nutzung (Claude API)</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${[
['Anfragen heute', s.ki_today, 'var(--c-primary)'],
['Anfragen diesen Monat', s.ki_month, 'var(--c-text-secondary)'],
['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'],
].map(([label, val, color]) => `
<div style="display:flex;justify-content:space-between;font-size:var(--text-sm)">
<span style="color:var(--c-text-secondary)">${label}</span>
<span style="font-weight:600;color:${color}">${val ?? 0}</span>
</div>
`).join('')}
</div>
</div>
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">OSM-Cache nach Typ</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${Object.entries(s.osm_by_type).map(([type, count]) => `
<div style="display:flex;justify-content:space-between;font-size:var(--text-sm)">
<span style="color:var(--c-text-secondary)">${type}</span>
<span style="font-weight:600">${count.toLocaleString('de')}</span>
</div>
`).join('')}
</div>
</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);word-break:break-all;display:inline-block">
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 class="adm-filter-row">
<input id="adm-user-q" type="search" placeholder="Name oder E-Mail…"
class="adm-filter-input">
<select id="adm-user-rolle" class="adm-filter-select">
<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 style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">
🗺 ${u.route_count} Routen · ${u.total_km} km
· 📍 ${u.poi_count} POIs
${u.last_route ? '· zuletzt ' + new Date(u.last_route).toLocaleDateString('de-DE') : ''}
</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 class="adm-subnav">
<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 class="adm-filter-row">
<input id="adm-thread-q" type="search" placeholder="Threads durchsuchen…"
class="adm-filter-input">
<label class="adm-filter-label">
<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); }
}
// ------------------------------------------------------------------
// TAB: SYSTEM
// ------------------------------------------------------------------
async function _renderSystem(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-sys-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-sys-cards">Lade…</div>
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Wartung</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
<button class="btn btn-secondary btn-sm" id="adm-translate-temper">
${UI.icon('translate')} Temperament → Deutsch
</button>
<button class="btn btn-secondary btn-sm" id="adm-evaluate-breeds">
${UI.icon('chart-bar')} Qualitätsbewertung (20 Rassen)
</button>
</div>
<div id="adm-maint-result" style="margin-top:var(--space-2);font-size:var(--text-xs);
color:var(--c-text-secondary)"></div>
<div id="adm-eval-result" style="margin-top:var(--space-3);display:none"></div>
</div>
<div style="margin-top:var(--space-5)">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<span style="font-size:var(--text-sm);font-weight:600">Server-Logs</span>
<select id="adm-log-level" class="input" style="width:auto;padding:2px 8px;font-size:var(--text-xs)">
<option value="">Alle</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<button class="btn btn-ghost btn-sm" id="adm-log-refresh">${UI.icon('arrows-clockwise')}</button>
</div>
<div id="adm-log-box" style="
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);font-family:monospace;font-size:11px;
max-height:420px;overflow-y:auto;line-height:1.6">Lade…</div>
</div>
`;
const loadLogs = async () => {
const level = el.querySelector('#adm-log-level').value;
const box = el.querySelector('#adm-log-box');
box.textContent = 'Lade…';
const rows = await API.get(`/admin/logs?lines=200${level ? '&level=' + level : ''}`);
const COLORS = { ERROR: '#ef4444', WARNING: '#f59e0b', INFO: '#6b7280', DEBUG: '#94a3b8' };
box.innerHTML = rows.reverse().map(r => {
const color = COLORS[r.l] || '#6b7280';
return `<div style="border-bottom:1px solid var(--c-border);padding:2px 0">` +
`<span style="color:var(--c-text-muted)">${r.t}</span> ` +
`<span style="color:${color};font-weight:600">${r.l}</span> ` +
`<span style="color:var(--c-text-secondary)">${_esc(r.n)}</span> ` +
`<span>${_esc(r.m)}</span></div>`;
}).join('') || '<span style="color:var(--c-text-muted)">Keine Einträge</span>';
};
el.querySelector('#adm-sys-refresh').addEventListener('click', () => {
_loadSystemCards(el.querySelector('#adm-sys-cards'));
loadLogs();
});
el.querySelector('#adm-log-refresh').addEventListener('click', loadLogs);
el.querySelector('#adm-log-level').addEventListener('change', loadLogs);
el.querySelector('#adm-translate-temper').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = el.querySelector('#adm-maint-result');
btn.disabled = true;
res.textContent = 'Läuft…';
try {
const d = await API.post('/admin/wiki/translate-temperament', {});
res.textContent = `${d.updated} Rassen übersetzt`;
} catch (err) {
res.textContent = '✗ Fehler: ' + (err.message || err);
} finally {
btn.disabled = false;
}
});
el.querySelector('#adm-evaluate-breeds').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = el.querySelector('#adm-maint-result');
const box = el.querySelector('#adm-eval-result');
btn.disabled = true;
res.textContent = 'Bewertung läuft… (ca. 30s)';
box.style.display = 'none';
try {
const d = await API.get('/admin/wiki/evaluate?sample=20');
if (d.error) { res.textContent = '✗ ' + d.error; return; }
const avg = d.averages;
const scoreColor = v => v >= 4 ? 'var(--c-success)' : v >= 3 ? 'var(--c-warning)' : 'var(--c-danger)';
const scoreBar = v => `<span style="color:${scoreColor(v)};font-weight:600">${v.toFixed(1)}</span>`;
const rows = d.results.filter(r => !r.error).map(r =>
`<tr>
<td style="padding:2px 6px">${_esc(r.name)}</td>
<td style="text-align:center;padding:2px 6px">${scoreBar(r.vollstaendigkeit)}</td>
<td style="text-align:center;padding:2px 6px">${scoreBar(r.korrektheit)}</td>
<td style="text-align:center;padding:2px 6px">${scoreBar(r.sprachqualitaet)}</td>
<td style="text-align:center;padding:2px 6px">${scoreBar(r.konsistenz)}</td>
<td style="text-align:center;padding:2px 6px;font-weight:700">${scoreBar(r.gesamt)}</td>
<td style="padding:2px 6px;color:var(--c-text-muted);font-size:0.9em">${_esc(r.hinweis || '')}</td>
</tr>`
).join('');
box.style.display = 'block';
box.innerHTML = `
<div style="font-size:var(--text-xs);font-weight:600;margin-bottom:var(--space-2)">
Ø-Scores (${d.evaluated}/${d.sample_size} Rassen bewertet)
</div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-3)">
${['vollstaendigkeit','korrektheit','sprachqualitaet','konsistenz','gesamt'].map(k =>
`<div style="text-align:center">
<div style="font-size:1.4em;font-weight:700;color:${scoreColor(avg[k])}">${avg[k]?.toFixed(1) ?? ''}</div>
<div style="font-size:0.75em;color:var(--c-text-muted)">${{vollstaendigkeit:'Vollst.',korrektheit:'Korrekt.',sprachqualitaet:'Sprache',konsistenz:'Konsistenz',gesamt:'Gesamt'}[k]}</div>
</div>`
).join('')}
</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead><tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:2px 6px">Rasse</th>
<th style="padding:2px 6px">Vollst.</th><th style="padding:2px 6px">Korrekt.</th>
<th style="padding:2px 6px">Sprache</th><th style="padding:2px 6px">Konsis.</th>
<th style="padding:2px 6px">Ges.</th><th style="text-align:left;padding:2px 6px">Hinweis</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
res.textContent = `✓ Bewertung abgeschlossen`;
} catch (err) {
res.textContent = '✗ Fehler: ' + (err.message || err);
} finally {
btn.disabled = false;
}
});
await _loadSystemCards(el.querySelector('#adm-sys-cards'));
await loadLogs();
}
async function _loadSystemCards(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const s = await API.get('/admin/system');
const diskUsedGb = s.disk_total_gb - s.disk_free_gb;
const diskPct = s.disk_total_gb > 0 ? Math.round(diskUsedGb / s.disk_total_gb * 100) : 0;
const uptime = _formatUptime(s.uptime_seconds);
el.innerHTML = `
<div class="adm-stats-grid">
${_statCard('database', 'Datenbank', s.db_size_mb.toFixed(1) + ' MB', 'var(--c-primary)')}
${_statCard('image', 'Media-Ordner', s.media_size_mb.toFixed(1) + ' MB','var(--c-text-secondary)')}
${_statCard('timer', 'Uptime', uptime, 'var(--c-success)')}
${_statCard('hard-drive','Disk frei', s.disk_free_gb.toFixed(1) + ' GB','diskPct > 85 ? "var(--c-danger)" : "var(--c-text-secondary)"')}
</div>
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Disk-Auslastung</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="flex:1;height:8px;background:var(--c-surface-2);border-radius:4px;overflow:hidden">
<div style="height:100%;width:${diskPct}%;background:${diskPct > 85 ? 'var(--c-danger)' : diskPct > 65 ? '#f59e0b' : 'var(--c-success)'};border-radius:4px;transition:width .3s"></div>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);white-space:nowrap">
${diskPct}% · ${diskUsedGb.toFixed(1)} / ${s.disk_total_gb.toFixed(1)} GB
</div>
</div>
<div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
Python ${_esc(s.python_version)}
</div>
</div>
`;
}
function _formatUptime(secs) {
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}min`;
return `${m}min`;
}
// ------------------------------------------------------------------
// TAB: JOBS
// ------------------------------------------------------------------
// TAB: MODERATION
// ------------------------------------------------------------------
async function _renderModeration(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-mod-refresh">${UI.icon('arrows-clockwise')} Aktualisieren</button>
</div>
<div id="adm-mod-content">Lade…</div>
`;
el.querySelector('#adm-mod-refresh').addEventListener('click', () => _loadModeration(el.querySelector('#adm-mod-content')));
await _loadModeration(el.querySelector('#adm-mod-content'));
}
async function _loadModeration(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const [zuchter, fotos] = await Promise.all([
API.get('/wiki/zuchter/pending').catch(() => []),
API.get('/wiki/foto-submissions').catch(() => []),
]);
let html = '';
// --- Züchter-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin-bottom:var(--space-3)">
Züchter-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchter.length}</span>
</h3>`;
if (!zuchter.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-6)">Keine ausstehenden Einreichungen.</p>`;
} else {
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-6)"><div class="adm-table-scroll"><table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Rasse</th><th class="adm-th">Name / Zwingername</th>
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Website</th><th class="adm-th"></th>
</tr></thead><tbody>
${zuchter.map((z, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(z.rasse_slug)}</td>
<td class="adm-td">${_esc(z.name)}${z.zwingername ? `<br><span style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(z.zwingername)}</span>` : ''}</td>
<td class="adm-td">${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))}</td>
<td class="adm-td">${z.vdh_mitglied ? '<span style="color:var(--c-success)">✓ VDH</span>' : '—'}</td>
<td class="adm-td">${z.website ? `<a href="${_esc(z.website)}" target="_blank" style="color:var(--c-primary);font-size:var(--text-xs)">Link</a>` : '—'}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-zuchter-approve" data-id="${z.id}" style="margin-right:4px">✓ Freigeben</button>
<button class="btn btn-sm btn-ghost adm-zuchter-delete" data-id="${z.id}" style="color:var(--c-danger)">✗</button>
</td>
</tr>`).join('')}
</tbody></table></div></div>`;
}
// --- Wiki-Foto-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin-bottom:var(--space-3)">
Wiki-Foto-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotos.length}</span>
</h3>`;
if (!fotos.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden Foto-Einreichungen.</p>`;
} else {
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4)">
${fotos.map(f => `
<div class="card" style="padding:var(--space-4)">
<img src="${_esc(f.foto_url)}" alt=""
style="width:100%;height:140px;object-fit:cover;border-radius:var(--radius-md);margin-bottom:var(--space-3)">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">${_esc(f.rasse_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-3)">von ${_esc(f.user_name)}</div>
${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
opacity:.5;margin-bottom:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-3)">↑ aktuelles Foto</div>` : ''}
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-sm btn-primary adm-foto-approve" data-id="${f.id}" style="flex:1">✓</button>
<button class="btn btn-sm btn-ghost adm-foto-reject" data-id="${f.id}" style="color:var(--c-danger)">✗</button>
</div>
</div>`).join('')}
</div>`;
}
el.innerHTML = html;
// Züchter freigeben
el.querySelectorAll('.adm-zuchter-approve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
await API.patch(`/wiki/zuchter/${btn.dataset.id}/verify`, {});
await _loadModeration(el);
});
});
// Züchter löschen
el.querySelectorAll('.adm-zuchter-delete').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Eintrag löschen?')) return;
btn.disabled = true;
await API.delete(`/admin/wiki/zuchter/${btn.dataset.id}`);
await _loadModeration(el);
});
});
// Foto freigeben
el.querySelectorAll('.adm-foto-approve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
await API.patch(`/wiki/foto-submissions/${btn.dataset.id}`, {action: 'approve'});
await _loadModeration(el);
});
});
// Foto ablehnen
el.querySelectorAll('.adm-foto-reject').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
await API.patch(`/wiki/foto-submissions/${btn.dataset.id}`, {action: 'reject', reject_reason: 'Nicht geeignet.'});
await _loadModeration(el);
});
});
}
// ------------------------------------------------------------------
async function _renderJobs(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-jobs-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-jobs-list">Lade…</div>
`;
el.querySelector('#adm-jobs-refresh').addEventListener('click', () => _loadJobs(el.querySelector('#adm-jobs-list')));
await _loadJobs(el.querySelector('#adm-jobs-list'));
}
async function _loadJobs(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const jobs = await API.get('/admin/scheduler/jobs');
if (!jobs.length) {
el.innerHTML = _emptyState('timer', 'Keine Jobs', 'Der Scheduler hat keine registrierten Jobs.');
return;
}
el.innerHTML = `
<div class="card adm-table-card">
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Job</th>
<th class="adm-th">Nächster Lauf</th>
<th class="adm-th">Trigger</th>
<th class="adm-th"></th>
</tr>
</thead>
<tbody>
${jobs.map((j, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td" style="font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(j.name)}
<div class="adm-job-id">${_esc(j.id)}</div>
</td>
<td class="adm-td" style="color:var(--c-text-secondary);white-space:nowrap">
${j.next_run_time ? _formatDateTime(j.next_run_time) : '<span style="color:var(--c-text-muted)">—</span>'}
</td>
<td class="adm-td adm-td-trigger">
${_esc(j.trigger)}
</td>
<td class="adm-td" style="text-align:right">
<button class="btn btn-sm btn-ghost adm-job-trigger adm-icon-btn" data-id="${_esc(j.id)}" data-name="${_esc(j.name)}"
title="Jetzt ausführen" style="color:var(--c-primary)">
${UI.icon('play')}
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
el.querySelectorAll('.adm-job-trigger').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.post(`/admin/scheduler/trigger/${encodeURIComponent(btn.dataset.id)}`, {});
UI.toast.success(`Job "${btn.dataset.name}" wird ausgeführt.`);
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Auslösen des Jobs.');
} finally {
btn.disabled = false;
}
});
});
}
function _formatDateTime(iso) {
try {
const d = new Date(iso);
return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
} catch { return iso; }
}
// ------------------------------------------------------------------
// TAB: AUDIT-LOG
// ------------------------------------------------------------------
async function _renderAudit(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-audit-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-audit-list">Lade…</div>
`;
el.querySelector('#adm-audit-refresh').addEventListener('click', () => _loadAudit(el.querySelector('#adm-audit-list')));
await _loadAudit(el.querySelector('#adm-audit-list'));
}
async function _loadAudit(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const rows = await API.get('/admin/audit?limit=50');
if (!rows.length) {
el.innerHTML = _emptyState('list-bullets', 'Keine Einträge', 'Noch keine Admin-Aktionen protokolliert.');
return;
}
el.innerHTML = `
<div class="card adm-table-card">
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Wann</th>
<th class="adm-th">Admin</th>
<th class="adm-th">Aktion</th>
<th class="adm-th">Ziel</th>
</tr>
</thead>
<tbody>
${rows.map((r, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td" style="color:var(--c-text-muted);white-space:nowrap;font-size:var(--text-xs)">
${_formatDateTime(r.created_at)}
</td>
<td class="adm-td" style="color:var(--c-text);white-space:nowrap">
${_esc(r.admin_name || '—')}
</td>
<td class="adm-td">
<span class="adm-badge-mono">${_esc(r.action)}</span>
${r.detail ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(r.detail)}</div>` : ''}
</td>
<td class="adm-td" style="color:var(--c-text-secondary);font-size:var(--text-xs);white-space:nowrap">
${_esc(r.target || '—')}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
}
// ------------------------------------------------------------------
// 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 };
})();