- ki.complete() zählt sich selbst (user_id-Parameter, _track_usage) - CLOUD_WEEKLY_LIMIT=20, geprüft vor jedem Cloud-Call - user_id durchgereicht in health, diary, knigge, notes, ki-route - Admin-Panel: 7-Tage-Ansicht, Limit-Info, Top-Cloud-User-Tabelle - Statusmail täglich 06:00 CEST statt alle 2h
1384 lines
67 KiB
JavaScript
1384 lines
67 KiB
JavaScript
/* ============================================================
|
||
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: 'social', label: 'Social Media', icon: 'camera' },
|
||
{ 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 'social': await _renderSocial(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: SOCIAL MEDIA
|
||
async function _renderSocial(el) {
|
||
const d = await API.get('/admin/social');
|
||
|
||
const _PL = { instagram: '📸 Instagram', tiktok: '🎵 TikTok', both: '📱 Beide' };
|
||
const _fmt = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '–';
|
||
|
||
// Manager-Tabelle
|
||
const managerRows = d.managers.map(m => `
|
||
<tr>
|
||
<td style="font-weight:600">${_esc(m.name)}</td>
|
||
<td style="text-align:right">${m.published}</td>
|
||
<td style="text-align:right">${m.with_link}
|
||
${m.published > 0 ? `<span style="font-size:10px;color:var(--c-text-muted)">
|
||
(${Math.round(m.with_link/m.published*100)}%)</span>` : ''}</td>
|
||
<td style="text-align:right">${m.scheduled}</td>
|
||
<td style="text-align:right">${m.ideas}</td>
|
||
<td style="text-align:right;color:var(--c-text-muted)">${m.total}</td>
|
||
</tr>`).join('');
|
||
|
||
// Plattform-Balken
|
||
const maxPlat = Math.max(...d.by_platform.map(p => p.n), 1);
|
||
const platBars = d.by_platform.map(p => `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-2)">
|
||
<div style="width:120px;flex-shrink:0;font-size:var(--text-sm)">${_PL[p.platform]||p.platform}</div>
|
||
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-full);height:8px;overflow:hidden">
|
||
<div style="width:${Math.round(p.n/maxPlat*100)}%;height:100%;
|
||
background:var(--c-primary);border-radius:var(--radius-full)"></div>
|
||
</div>
|
||
<div style="width:28px;text-align:right;font-weight:600;font-size:var(--text-sm)">${p.n}</div>
|
||
</div>`).join('');
|
||
|
||
// Monats-Timeline
|
||
const maxMonth = Math.max(...d.by_month.map(m => m.n), 1);
|
||
const monthBars = [...d.by_month].reverse().map(m => `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-2)">
|
||
<div style="width:55px;flex-shrink:0;font-size:11px;color:var(--c-text-muted)">${m.monat}</div>
|
||
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-full);height:8px;overflow:hidden">
|
||
<div style="width:${Math.round(m.n/maxMonth*100)}%;height:100%;
|
||
background:var(--c-success);border-radius:var(--radius-full)"></div>
|
||
</div>
|
||
<div style="width:28px;text-align:right;font-weight:600;font-size:var(--text-sm)">${m.n}</div>
|
||
</div>`).join('');
|
||
|
||
// Veröffentlichte Posts (mit/ohne Link)
|
||
const postRows = d.recent_published.map(p => `
|
||
<tr>
|
||
<td style="color:var(--c-text-muted);white-space:nowrap">${_fmt(p.published_at)}</td>
|
||
<td style="font-weight:500;max-width:200px;overflow:hidden;text-overflow:ellipsis;
|
||
white-space:nowrap">${_esc(p.topic)}</td>
|
||
<td>${_PL[p.platform]||p.platform||'–'}</td>
|
||
<td style="font-size:10px;color:var(--c-text-muted)">${_esc(p.category||'–')}</td>
|
||
<td>${p.ai_score ? '⭐'.repeat(p.ai_score) : '–'}</td>
|
||
<td style="font-weight:500">${_esc(p.manager||'–')}</td>
|
||
<td>${p.post_url
|
||
? `<a href="${_esc(p.post_url)}" target="_blank" rel="noopener"
|
||
style="font-size:11px;color:var(--c-primary)">🔗 Link</a>`
|
||
: `<span style="font-size:11px;color:var(--c-text-muted)">–</span>`}</td>
|
||
</tr>`).join('');
|
||
|
||
el.innerHTML = `
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);margin-bottom:var(--space-4)">
|
||
<!-- Plattform -->
|
||
<div class="card" style="padding:var(--space-4)">
|
||
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
|
||
Veröffentlicht nach Plattform
|
||
</div>
|
||
${platBars || '<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Posts</div>'}
|
||
</div>
|
||
<!-- Timeline -->
|
||
<div class="card" style="padding:var(--space-4)">
|
||
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
|
||
Posts pro Monat
|
||
</div>
|
||
${monthBars || '<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Posts</div>'}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Manager -->
|
||
${d.managers.length ? `
|
||
<div class="card adm-table-card" style="margin-bottom:var(--space-4)">
|
||
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
|
||
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
|
||
border-bottom:1px solid var(--c-border)">Manager-Übersicht</div>
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead><tr>
|
||
<th>Manager</th>
|
||
<th style="text-align:right">Veröffentlicht</th>
|
||
<th style="text-align:right">Mit Link</th>
|
||
<th style="text-align:right">Geplant</th>
|
||
<th style="text-align:right">Ideen</th>
|
||
<th style="text-align:right">Gesamt</th>
|
||
</tr></thead>
|
||
<tbody>${managerRows}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
<!-- Alle veröffentlichten Posts -->
|
||
<div class="card adm-table-card">
|
||
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
|
||
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
|
||
border-bottom:1px solid var(--c-border)">
|
||
Veröffentlichte Posts (letzte 50)
|
||
</div>
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead><tr>
|
||
<th>Datum</th><th>Thema</th><th>Plattform</th>
|
||
<th>Kategorie</th><th>Score</th><th>Manager</th><th>Link</th>
|
||
</tr></thead>
|
||
<tbody>${postRows || `<tr><td colspan="7" style="text-align:center;
|
||
color:var(--c-text-muted);padding:var(--space-6)">Noch keine Posts</td></tr>`}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// 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('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)')}
|
||
</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-Nutzung</p>
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||
${[
|
||
['☁️ Claude (7 Tage)', s.ki_cloud_week, 'var(--c-primary)'],
|
||
['🖥️ LM Studio (7 Tage)', s.ki_local_week, 'var(--c-success)'],
|
||
['🌙 Luna (7 Tage)', s.ki_luna_week, 'var(--c-warning)'],
|
||
['Gesamt heute', s.ki_today, 'var(--c-text-secondary)'],
|
||
['Gesamt 7 Tage', s.ki_week, 'var(--c-text-secondary)'],
|
||
['Gesamt 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 style="margin-top:var(--space-3);padding-top:var(--space-3);border-top:1px solid var(--c-border)">
|
||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-2)">
|
||
User-Limit: <strong>${s.ki_cloud_weekly_limit ?? 20} Cloud-Anfragen / Woche</strong>
|
||
</p>
|
||
${(s.ki_top_users || []).length ? `
|
||
<p style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);margin:var(--space-2) 0 var(--space-1)">Top Cloud-User (7 Tage)</p>
|
||
${s.ki_top_users.map((u, i) => `
|
||
<div style="display:flex;justify-content:space-between;font-size:var(--text-xs)">
|
||
<span style="color:var(--c-text-secondary)">${i+1}. ${_esc(u.name)}</span>
|
||
<span style="font-weight:600;color:${u.cloud_calls >= (s.ki_cloud_weekly_limit ?? 20) ? 'var(--c-danger)' : 'var(--c-primary)'}">${u.cloud_calls}</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>
|
||
|
||
<!-- Social Media Tracking -->
|
||
<div class="card" style="padding:var(--space-4)">
|
||
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">
|
||
📱 Social Media Tracking</p>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);
|
||
margin-bottom:var(--space-3)">
|
||
${[
|
||
['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]) => `
|
||
<div style="background:var(--c-surface-2);border-radius:8px;padding:10px">
|
||
<div style="font-size:10px;color:var(--c-text-muted);margin-bottom:2px">${label}</div>
|
||
<div style="font-size:1.4em;font-weight:700;color:${color}">${val ?? 0}</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
${Object.keys(s.social_by_cat||{}).length ? `
|
||
<div style="margin-bottom:var(--space-3)">
|
||
<div style="font-size:11px;color:var(--c-text-muted);margin-bottom:6px;
|
||
font-weight:600;text-transform:uppercase">Kategorien</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||
${Object.entries(s.social_by_cat).map(([cat, n]) => `
|
||
<span style="background:var(--c-surface-2);border-radius:20px;
|
||
padding:2px 10px;font-size:11px">
|
||
${cat} <strong>${n}</strong></span>`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
${s.social_recent?.length ? `
|
||
<div>
|
||
<div style="font-size:11px;color:var(--c-text-muted);margin-bottom:6px;
|
||
font-weight:600;text-transform:uppercase">Letzte 10 Posts</div>
|
||
<div style="display:flex;flex-direction:column;gap:6px">
|
||
${s.social_recent.map(p => `
|
||
<div style="display:flex;align-items:center;gap:8px;
|
||
font-size:var(--text-xs);padding:6px 0;
|
||
border-bottom:1px solid var(--c-border)">
|
||
<span style="padding:2px 7px;border-radius:4px;font-size:10px;
|
||
background:var(--c-surface-2);flex-shrink:0;
|
||
color:${{idea:'var(--c-text-muted)',draft:'var(--c-warning)',
|
||
scheduled:'var(--c-primary)',published:'var(--c-success)',
|
||
archived:'var(--c-text-muted)'}[p.status]||'inherit'}">
|
||
${p.status}</span>
|
||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;
|
||
white-space:nowrap">${p.topic}</span>
|
||
<span style="color:var(--c-text-muted);flex-shrink:0">
|
||
${(p.published_at||p.created_at||'').slice(0,10)}</span>
|
||
</div>`).join('')}
|
||
</div>
|
||
</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)">Wiki-Daten</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
|
||
<button class="btn btn-secondary btn-sm" id="adm-enrichment-status">
|
||
${UI.icon('arrows-clockwise')} Enrichment-Status
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" id="adm-evaluate-breeds">
|
||
${UI.icon('chart-bar')} Qualitätsbewertung (20 Rassen)
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" id="adm-fetch-photos">
|
||
${UI.icon('image')} Fotos nachladen
|
||
</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-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 => `<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);display:flex;align-items:center;gap:2px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> 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"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
|
||
<button class="btn btn-sm btn-ghost adm-zuchter-delete" data-id="${z.id}" style="color:var(--c-danger)"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg></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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
return { init, refresh, onDogChange };
|
||
|
||
})();
|