/* ============================================================
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.map(t => `
`).join('')}
`;
_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 = `Lade…
`;
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 'Keine Daten';
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 ``;
}
// 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 = `
${_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)')}
Seitenaufrufe — letzte 7 Tage
${_sparkline(pv, 'var(--c-primary)')}
${pv.map(p => `${new Date(p.x).toLocaleDateString('de',{weekday:'short'})}`).join('')}
Top Seiten — letzte 7 Tage
${(d.top_pages ?? []).length === 0
? `
Noch keine Daten
`
: `
${d.top_pages.map(p => {
const maxY = d.top_pages[0].y;
const pct = maxY > 0 ? (p.y / maxY * 100).toFixed(0) : 0;
return `
${UI.escape(p.x)}
${fmt(p.y)}
`;
}).join('')}
`}
`;
}
// ------------------------------------------------------------------
// TAB: ÜBERSICHT
// ------------------------------------------------------------------
async function _renderStats(el) {
const s = await API.get('/admin/stats');
el.innerHTML = `
${_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('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : '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)')}
KI-Nutzung
${[
['☁️ Claude heute', s.ki_cloud_today, 'var(--c-primary)'],
['🖥️ LM Studio heute', s.ki_local_today, 'var(--c-success)'],
['🌙 Luna heute', s.ki_luna_today, 'var(--c-warning)'],
['Gesamt heute', s.ki_today, 'var(--c-text-secondary)'],
['☁️ Claude Monat', s.ki_cloud_month, 'var(--c-primary)'],
['🖥️ LM Studio Monat', s.ki_local_month, 'var(--c-success)'],
['🌙 Luna Monat', s.ki_luna_month, 'var(--c-warning)'],
['Gesamt Monat', s.ki_month, 'var(--c-text-secondary)'],
['Aktive User heute', s.ki_users_today, 'var(--c-text-secondary)'],
].map(([label, val, color]) => `
${label}
${val ?? 0}
`).join('')}
OSM-Cache nach Typ
${Object.entries(s.osm_by_type).map(([type, count]) => `
${type}
${count.toLocaleString('de')}
`).join('')}
📱 Social Media Tracking
${[
['Gesamt generiert', s.social_total, 'var(--c-text-secondary)'],
['Veröffentlicht', s.social_published, 'var(--c-success)'],
['Geplant', s.social_scheduled, 'var(--c-primary)'],
['Ideen offen', s.social_ideas, 'var(--c-warning)'],
['Diese Woche live', s.social_this_week, 'var(--c-success)'],
].map(([label, val, color]) => `
`).join('')}
${Object.keys(s.social_by_cat||{}).length ? `
Kategorien
${Object.entries(s.social_by_cat).map(([cat, n]) => `
${cat} ${n}`).join('')}
` : ''}
${s.social_recent?.length ? `
Letzte 10 Posts
${s.social_recent.map(p => `
${p.status}
${p.topic}
${(p.published_at||p.created_at||'').slice(0,10)}
`).join('')}
` : ''}
Ersten Admin per SQL setzen:
UPDATE users SET rolle='admin', is_moderator=1 WHERE email='deine@email.de';
`;
}
function _statCard(icon, label, value, color) {
return `
`;
}
// ------------------------------------------------------------------
// TAB: NUTZER
// ------------------------------------------------------------------
async function _renderUsers(el) {
el.innerHTML = `
Lade…
`;
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 = `
${total} Nutzer gefunden
${users.map(u => `
${_esc(u.name[0].toUpperCase())}
${_esc(u.name)}
${u.is_banned ? `
GESPERRT` : ''}
${_esc(u.email)} ·
${_esc(u.rolle)}
· ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
· ${u.thread_count} Threads
🗺 ${u.route_count} Routen · ${u.total_km} km
· 📍 ${u.poi_count} POIs
${u.last_route ? '· zuletzt ' + new Date(u.last_route).toLocaleDateString('de-DE') : ''}
${u.is_banned
? ``
: ``
}
${isAdmin ? `
` : ''}
`).join('')}
`;
// 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: `
Aktuelle Rolle: ${currentRolle}
${rollen.map(r => `
`).join('')}
`,
});
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 = `
Lade…
`;
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 = 'Lade…
';
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 = `
${reports.map(r => `
${r.resolved ? '✓ Erledigt · ' : ''}
${_esc(r.target_type)} #${r.target_id} ·
Gemeldet von ${_esc(r.melder_name)}
Grund: ${_esc(r.grund)}
${r.content_preview ? `
${_esc(r.content_preview)}
` : ''}
${!r.resolved ? `
` : ''}
`).join('')}
`;
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 = `
Lade…
`;
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 = `
${threads.map(t => `
${t.is_deleted ? '' : ''}${_esc(t.titel)}${t.is_deleted ? '' : ''}
von ${_esc(t.autor_name)} ·
${t.antworten} Antworten ·
${t.is_pinned ? '📌 ' : ''}${t.is_locked ? '🔒 ' : ''}${t.is_deleted ? '🗑 gelöscht' : ''}
${!t.is_deleted ? `
` : `
`}
`).join('')}
`;
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 = `
Lade…
Wartung
Server-Logs
Lade…
`;
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 `` +
`${r.t} ` +
`${r.l} ` +
`${_esc(r.n)} ` +
`${_esc(r.m)}
`;
}).join('') || 'Keine Einträge';
};
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-enrichment-status').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = el.querySelector('#adm-maint-result');
btn.disabled = true;
res.textContent = 'Lade…';
try {
const d = await API.get('/admin/wiki/enrichment-status');
const modelList = Object.entries(d.by_model)
.map(([m, n]) => `${m}: ${n}`).join(', ');
res.textContent = `Gesamt: ${d.total} | Angereichert: ${d.enriched} | Kein Wiki: ${d.no_wiki} | Ausstehend: ${d.pending} | Mit Foto: ${d.with_photo} | Modelle: ${modelList || '–'}`;
} catch (err) {
res.textContent = '✗ Fehler: ' + (err.message || err);
} finally {
btn.disabled = false;
}
});
el.querySelector('#adm-fetch-photos').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = el.querySelector('#adm-maint-result');
btn.disabled = true;
res.textContent = 'Fotos werden geladen… (kann 30–60s dauern)';
try {
const d = await API.post('/admin/wiki/fetch-photos?limit=50', {});
res.textContent = `✓ ${d.found} Foto(s) gespeichert`;
} 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 => `${v.toFixed(1)}`;
const rows = d.results.filter(r => !r.error).map(r =>
`
| ${_esc(r.name)} |
${scoreBar(r.vollstaendigkeit)} |
${scoreBar(r.korrektheit)} |
${scoreBar(r.sprachqualitaet)} |
${scoreBar(r.konsistenz)} |
${scoreBar(r.gesamt)} |
${_esc(r.hinweis || '')} |
`
).join('');
box.style.display = 'block';
box.innerHTML = `
Ø-Scores (${d.evaluated}/${d.sample_size} Rassen bewertet)
${['vollstaendigkeit','korrektheit','sprachqualitaet','konsistenz','gesamt'].map(k =>
`
${avg[k]?.toFixed(1) ?? '–'}
${{vollstaendigkeit:'Vollst.',korrektheit:'Korrekt.',sprachqualitaet:'Sprache',konsistenz:'Konsistenz',gesamt:'Gesamt'}[k]}
`
).join('')}
| Rasse |
Vollst. | Korrekt. |
Sprache | Konsis. |
Ges. | Hinweis |
${rows}
`;
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 = `Lade…
`;
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 = `
${_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)"')}
Disk-Auslastung
${diskPct}% · ${diskUsedGb.toFixed(1)} / ${s.disk_total_gb.toFixed(1)} GB
Python ${_esc(s.python_version)}
`;
}
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 = `
Lade…
`;
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 = `Lade…
`;
const [zuchter, fotos] = await Promise.all([
API.get('/wiki/zuchter/pending').catch(() => []),
API.get('/wiki/foto-submissions').catch(() => []),
]);
let html = '';
// --- Züchter-Einreichungen ---
html += `
Züchter-Einreichungen
${zuchter.length}
`;
if (!zuchter.length) {
html += `Keine ausstehenden Einreichungen.
`;
} else {
html += ``;
}
// --- Wiki-Foto-Einreichungen ---
html += `
Wiki-Foto-Einreichungen
${fotos.length}
`;
if (!fotos.length) {
html += `Keine ausstehenden Foto-Einreichungen.
`;
} else {
html += `
${fotos.map(f => `
${_esc(f.rasse_name)}
von ${_esc(f.user_name)}
${f.aktuell_foto ? `
↑ aktuelles Foto
` : ''}
`).join('')}
`;
}
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 = `
Lade…
`;
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 = `Lade…
`;
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 = `
`;
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 = `
Lade…
`;
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 = `Lade…
`;
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 = `
`;
}
// ------------------------------------------------------------------
// HELPERS
// ------------------------------------------------------------------
function _prompt(msg) {
return new Promise(resolve => {
UI.modal.open({
title: 'Eingabe',
body: `
${msg}
`,
footer: `
`,
});
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 `
${title}
${text ? `
${text}
` : ''}
`;
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();