/* ============================================================ 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.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 '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 => ` ${_esc(m.name)} ${m.published} ${m.with_link} ${m.published > 0 ? ` (${Math.round(m.with_link/m.published*100)}%)` : ''} ${m.scheduled} ${m.ideas} ${m.total} `).join(''); // Plattform-Balken const maxPlat = Math.max(...d.by_platform.map(p => p.n), 1); const platBars = d.by_platform.map(p => `
${_PL[p.platform]||p.platform}
${p.n}
`).join(''); // Monats-Timeline const maxMonth = Math.max(...d.by_month.map(m => m.n), 1); const monthBars = [...d.by_month].reverse().map(m => `
${m.monat}
${m.n}
`).join(''); // Veröffentlichte Posts (mit/ohne Link) const postRows = d.recent_published.map(p => ` ${_fmt(p.published_at)} ${_esc(p.topic)} ${_PL[p.platform]||p.platform||'–'} ${_esc(p.category||'–')} ${p.ai_score ? '⭐'.repeat(p.ai_score) : '–'} ${_esc(p.manager||'–')} ${p.post_url ? `🔗 Link` : ``} `).join(''); el.innerHTML = `
Veröffentlicht nach Plattform
${platBars || '
Noch keine Posts
'}
Posts pro Monat
${monthBars || '
Noch keine Posts
'}
${d.managers.length ? `
Manager-Übersicht
${managerRows}
Manager Veröffentlicht Mit Link Geplant Ideen Gesamt
` : ''}
Veröffentlichte Posts (letzte 50)
${postRows || ``}
DatumThemaPlattform KategorieScoreManagerLink
Noch keine Posts
`; } // ------------------------------------------------------------------ // 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 (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]) => `
${label} ${val ?? 0}
`).join('')}

User-Limit: ${s.ki_cloud_weekly_limit ?? 20} Cloud-Anfragen / Woche

${(s.ki_top_users || []).length ? `

Top Cloud-User (7 Tage)

${s.ki_top_users.map((u, i) => `
${i+1}. ${_esc(u.name)} ${u.cloud_calls}
`).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]) => `
${label}
${val ?? 0}
`).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 `
${value ?? '—'}
${label}
`; } // ------------------------------------------------------------------ // 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…
Medien
Wiki-Daten
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-generate-previews').addEventListener('click', async (e) => { const btn = e.currentTarget; const res = el.querySelector('#adm-maint-result'); btn.disabled = true; res.textContent = 'Generiere Previews… (kann 1–2 Minuten dauern)'; try { const d = await API.post('/admin/media/generate-previews', {}); res.textContent = `✓ ${d.generated} neu generiert · ${d.skipped} bereits vorhanden · ${d.errors} Fehler`; } catch (err) { res.textContent = '✗ Fehler: ' + (err.message || err); } finally { btn.disabled = false; } }); 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('')}
${rows}
Rasse Vollst.Korrekt. SpracheKonsis. Ges.Hinweis
`; 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)} APP v${typeof APP_VER !== 'undefined' ? APP_VER : '—'} · ${_esc(s.sw_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 += `
${zuchter.map((z, i) => ` `).join('')}
RasseName / Zwingername OrtVDHWebsite
${_esc(z.rasse_slug)} ${_esc(z.name)}${z.zwingername ? `
${_esc(z.zwingername)}` : ''}
${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))} ${z.vdh_mitglied ? ` VDH` : '—'} ${z.website ? `Link` : '—'}
`; } // --- 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 ? `Aktuell
↑ 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 = `
${jobs.map((j, i) => ` `).join('')}
Job Nächster Lauf Trigger
${_esc(j.name)}
${_esc(j.id)}
${j.next_run_time ? _formatDateTime(j.next_run_time) : ''} ${_esc(j.trigger)}
`; 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 = `
${rows.map((r, i) => ` `).join('')}
Wann Admin Aktion Ziel
${_formatDateTime(r.created_at)} ${_esc(r.admin_name || '—')} ${_esc(r.action)} ${r.detail ? `
${_esc(r.detail)}
` : ''}
${_esc(r.target || '—')}
`; } // ------------------------------------------------------------------ // 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 }; })();