banyaro/backend/static/js/pages/admin.js
rene de1677154f Security + E-Mail-HTML + Quartalsbericht + Registrierungspflicht
Registrierung & Login:
- E-Mail-Verifikation jetzt Pflicht vor erstem Login
- Register gibt keinen Token mehr zurück → "Postfach prüfen"-Screen
- Login blockt mit EMAIL_NOT_VERIFIED (403) wenn unverifiziert
- Resend-Verification ohne Auth (email-basiert)
- Frontend: _renderVerifyPending() nach Register und Login-Fehler
- Account-Lockout: 5 Fehlversuche → 15 Min gesperrt (ratelimit.py)
- Login Rate-Limit zusätzlich per E-Mail-Adresse (5/5 Min)
- Fehler-Tracking wird bei erfolgreichem Login zurückgesetzt

E-Mail-Templates (alle Mails jetzt HTML):
- email_html() Shared-Template in mailer.py (Gradient-Header, Warm-Beige)
- Verifikations-Mail, Passwort-Reset → HTML mit CTA-Button
- Admin-Outreach: plain text auto-wrapped in HTML
- Züchter-Mails (Antrag/Genehmigung/Ablehnung) → Template
- Tierschutz-Alert (litters.py) → Template
- send_support_mail → HTML
- outreach._build_message() + _send_smtp() unterstützen jetzt html= Parameter

Forum-Schutz:
- Post-Cooldown: 30 Sek zwischen beliebigen Posts (DB-Check)
- Stunden-Limit: 5 Threads / 20 Antworten pro User/Stunde
- Duplikat-Erkennung: gleicher Text in 5 Min blockiert (in-memory)
- content_filter.py: Spam-Keywords, URL-Sperre für Accounts < 7 Tage,
  Sonderzeichen-Ratio-Check

Security-Headers:
- HSTS: max-age=31536000; includeSubDomains
- Content-Security-Policy: frame-ancestors none, base-uri self, …
- X-Frame-Options entfernt (CSP frame-ancestors ist moderner)

Honeypot-Fallen (13 Scanner-Pfade → 24h IP-Sperre):
- /api/admin/users, /api/v1/users, /api/.env, /api/config,
  /api/setup, /api/install, /api/phpinfo, /api/debug,
  /api/actuator, /api/swagger, /api/graphql u.a.

Quartalsbericht-System:
- backend/scripts/generate_reports.py: 6 Sections
  (Sicherheit, Funktionsumfang, Dateien, Nutzer, Partner, Server)
- make reports: generiert alle Berichte aus dem Container, committed
- Scheduler: quarterly_report Job (1. Feb/Mai/Aug/Nov 07:00)
  → vollständige HTML-Mail an ADMIN_EMAIL
- quarterly_report erscheint im täglichen Status-Report

Admin-Panel:
- "Forum & Meldungen" → "Forum"
2026-05-01 08:20:53 +02:00

2381 lines
119 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — Admin-Bereich
Nur für Admins und Moderatoren.
============================================================ */
window.Page_admin = (() => {
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
{ id: 'forum', label: 'Forum', 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: 'partner', label: 'Partner', icon: 'handshake' },
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
{ 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 'zuchter': await _renderZuechter(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 'partner': await _renderPartner(el); break;
case 'outreach': await _renderOutreach(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) {
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">Lade Analytics…</div>`;
let d;
try { d = await API.get('/admin/analytics'); }
catch (err) {
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-danger);font-size:var(--text-sm)">
${UI.icon('warning')} Fehler: ${_esc(err.message || String(err))}</div>`;
return;
}
const tv = v => (v != null && typeof v === 'object') ? (v.value ?? 0) : (v ?? 0);
const fmt = v => Number(v).toLocaleString('de');
const pv = d.pageviews?.pageviews ?? [];
const ses = d.pageviews?.sessions ?? [];
// Bounce + Verweildauer
const _bounces = tv(d.today?.bounces), _vis = tv(d.today?.visits);
const bounceToday = _vis > 0 ? ((_bounces / _vis) * 100).toFixed(0) + ' %' : '—';
const _tt = tv(d.week?.totaltime), _vw = tv(d.week?.visits);
const timeWeek = _tt > 0 && _vw > 0 ? Math.round(_tt / _vw) + ' s' : '—';
// Dual-Series Area+Line Chart (SVG)
function _dualChart(pvData, sesData) {
if (!pvData.length) return `<p style="color:var(--c-text-muted);font-size:var(--text-xs);margin:0">Keine Daten</p>`;
const W = 800, H = 120, padX = 0, padY = 8;
const pvVals = pvData.map(p => p.y ?? 0);
const sesVals = sesData.map(p => p.y ?? 0);
const maxVal = Math.max(...pvVals, ...sesVals, 1);
const n = pvData.length;
function toXY(vals) {
return vals.map((v, i) => {
const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1));
const y = H - padY - (v / maxVal) * (H - 2*padY);
return [x.toFixed(1), y.toFixed(1)];
});
}
const pvPts = toXY(pvVals);
const sesPts = toXY(sesVals);
const pvLine = pvPts.map(p => p.join(',')).join(' ');
const sesLine = sesPts.map(p => p.join(',')).join(' ');
// Filled area unter pageviews
const areaPath = `M ${pvPts[0][0]},${H} ` +
pvPts.map(p => `L ${p[0]},${p[1]}`).join(' ') +
` L ${pvPts[pvPts.length-1][0]},${H} Z`;
// X-Achsen-Labels: Anfang, Mitte, Ende
const labelIdx = [0, Math.floor(n/2), n-1].filter((v,i,a) => a.indexOf(v)===i);
const labels = labelIdx.map(i => {
const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1));
const date = pvData[i]?.x ? new Date(pvData[i].x).toLocaleDateString('de',{day:'2-digit',month:'2-digit'}) : '';
return `<text x="${x.toFixed(0)}" y="${H+14}" text-anchor="middle"
font-size="10" fill="currentColor" style="color:var(--c-text-muted)">${date}</text>`;
}).join('');
return `<svg viewBox="0 0 ${W} ${H+18}" style="width:100%;height:100px;display:block;overflow:visible"
preserveAspectRatio="none">
<defs>
<linearGradient id="pvGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity="0.25"/>
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0.02"/>
</linearGradient>
</defs>
<path d="${areaPath}" fill="url(#pvGrad)"/>
<polyline points="${pvLine}" fill="none" stroke="var(--c-primary)"
stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<polyline points="${sesLine}" fill="none" stroke="var(--c-success)"
stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="4,2"/>
${labels}
</svg>`;
}
// Balken-Chart für Top-Pages / Referrers
function _barChart(items, labelKey = 'x', valKey = 'y') {
if (!items?.length) return `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Daten</p>`;
const maxV = Math.max(...items.map(p => p[valKey] ?? 0), 1);
return `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${items.map(p => {
const pct = ((p[valKey] ?? 0) / maxV * 100).toFixed(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:78%">${_esc(p[labelKey] || '—')}</span>
<span style="color:var(--c-text-secondary);flex-shrink:0;margin-left:var(--space-2)">${fmt(p[valKey] ?? 0)}</span>
</div>
<div style="height:4px;border-radius:2px;background:var(--c-surface-2)">
<div style="height:100%;width:${pct}%;border-radius:2px;background:var(--c-primary);transition:width .4s"></div>
</div>
</div>`;
}).join('')}
</div>`;
}
el.innerHTML = `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Stat-Karten -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,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('users', 'Besucher 30 Tage', fmt(tv(d.month?.visitors)), 'var(--c-text-secondary)')}
${_statCard('eye', 'Aufrufe 30 Tage', fmt(tv(d.month?.pageviews)), 'var(--c-text-secondary)')}
${_statCard('arrow-u-up-left', 'Bounce heute', bounceToday, 'var(--c-text-secondary)')}
${_statCard('timer', 'Ø Verweildauer 7d', timeWeek, 'var(--c-text-secondary)')}
</div>
<!-- Verlaufschart 30 Tage -->
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Verlauf — letzte 30 Tage</span>
<div style="display:flex;gap:var(--space-3);font-size:10px;color:var(--c-text-muted)">
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:2px;background:var(--c-primary);display:inline-block;border-radius:1px"></span> Aufrufe
</span>
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:2px;background:var(--c-success);display:inline-block;border-radius:1px;
border-bottom:2px dashed var(--c-success);background:none"></span> Besucher
</span>
</div>
</div>
${_dualChart(pv, ses)}
</div>
<!-- Jahres-Übersicht -->
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Jahresübersicht — letzte 12 Monate</span>
<div style="display:flex;gap:var(--space-3);font-size:10px;color:var(--c-text-muted)">
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:10px;background:var(--c-primary);opacity:0.7;display:inline-block;border-radius:2px"></span> Seitenaufrufe
</span>
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:10px;background:var(--c-success);opacity:0.7;display:inline-block;border-radius:2px"></span> Neuanmeldungen
</span>
</div>
</div>
${(() => {
const pvYear = d.pageviews_year?.pageviews ?? [];
const regs = d.monthly_registrations ?? [];
// Alle Monate der letzten 12 sammeln
const months = [];
const now = new Date();
for (let i = 11; i >= 0; i--) {
const d2 = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(d2.getFullYear() + '-' + String(d2.getMonth()+1).padStart(2,'0'));
}
// Umami liefert x als ISO-Datum-String
const pvByMonth = {};
pvYear.forEach(p => {
const key = (p.x || '').slice(0, 7);
pvByMonth[key] = (pvByMonth[key] || 0) + (p.y ?? 0);
});
const regByMonth = {};
regs.forEach(r => { regByMonth[r.month] = r.count; });
const pvVals = months.map(m => pvByMonth[m] || 0);
const regVals = months.map(m => regByMonth[m] || 0);
const maxPv = Math.max(...pvVals, 1);
const maxReg = Math.max(...regVals, 1);
const W = 800, H = 120, n = months.length;
const barW = Math.floor((W - (n-1)*4) / n);
const bars = months.map((m, i) => {
const x = i * (barW + 4);
const pvH = Math.round((pvVals[i] / maxPv) * (H - 20));
const regH = Math.round((regVals[i] / maxReg) * (H - 20));
const label = m.slice(5); // MM
return `
<rect x="${x}" y="${H - 20 - pvH}" width="${barW}" height="${pvH}"
fill="var(--c-primary)" opacity="0.65" rx="2"/>
<rect x="${x + Math.floor(barW*0.55)}" y="${H - 20 - regH}" width="${Math.floor(barW*0.4)}" height="${regH}"
fill="var(--c-success)" opacity="0.8" rx="2"/>
<text x="${x + barW/2}" y="${H - 4}" text-anchor="middle"
font-size="9" fill="currentColor" style="color:var(--c-text-muted)">${label}</text>
`;
}).join('');
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:120px;display:block;overflow:visible"
preserveAspectRatio="none">${bars}</svg>`;
})()}
</div>
<!-- Top Seiten + Referrers nebeneinander -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4)">
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-3)">Top Seiten 30 Tage</div>
${_barChart(d.top_pages)}
</div>
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-3)">Referrers 30 Tage</div>
${_barChart(d.referrers)}
</div>
</div>
</div>`;
}
// ------------------------------------------------------------------
// TAB: ÜBERSICHT
// ------------------------------------------------------------------
async function _renderStats(el) {
const [s, ki, kiH] = await Promise.all([
API.get('/admin/stats'),
API.get('/admin/ki/status').catch(() => null),
API.get('/admin/ki/history').catch(() => null),
]);
const _kiStatusBadge = () => {
if (!ki) return '';
if (ki.mode === 'off') {
return `<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md)">
<span style="width:8px;height:8px;border-radius:50%;background:var(--c-text-muted);flex-shrink:0"></span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">KI-Modus: <strong>off</strong></span>
</div>`;
}
const dot = ki.local_reachable ? 'var(--c-success)' : 'var(--c-danger)';
const label = ki.local_reachable ? 'Lokal erreichbar' : 'Nicht erreichbar';
const model = ki.local_model_loaded || ki.local_model_config || '?';
return `<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md);flex-wrap:wrap">
<span style="width:8px;height:8px;border-radius:50%;background:${dot};flex-shrink:0;
box-shadow:0 0 4px ${dot}"></span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);font-weight:600">${label}</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">·</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-family:monospace">${_esc(model)}</span>
<span style="margin-left:auto;font-size:10px;padding:1px 6px;border-radius:10px;
background:var(--c-surface);color:var(--c-text-muted);border:1px solid var(--c-border)">
Modus: ${ki.local_reachable ? 'local' : 'cloud'}
</span>
</div>`;
};
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)">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<p style="font-size:var(--text-sm);font-weight:600;margin:0;display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>
KI-Nutzung
</p>
<span style="font-size:var(--text-xs);font-weight:700;padding:2px 10px;border-radius:100px;
background:var(--c-primary-subtle);color:var(--c-primary)">
${s.ki_today ?? 0} heute · ${s.ki_week ?? 0} (7 Tage)
</span>
</div>
<!-- KI-Status Badge -->
${_kiStatusBadge()}
<!-- Quellen-Zeile -->
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
${[
['☁️ Claude', s.ki_cloud_week, 'var(--c-primary)'],
['🖥️ Lokal', s.ki_local_week, 'var(--c-success)'],
['🌙 Luna', s.ki_luna_week, 'var(--c-warning)'],
['📅 Monat', s.ki_month, 'var(--c-text-secondary)'],
['👥 User heute',s.ki_users_today, 'var(--c-text-secondary)'],
].map(([label, val, color]) => `
<span style="font-size:var(--text-xs);padding:2px 8px;border-radius:100px;
background:var(--c-surface-2);border:1px solid var(--c-border);white-space:nowrap">
<span style="color:var(--c-text-muted)">${label}</span>
<strong style="color:${color};margin-left:3px">${val ?? 0}</strong>
</span>`).join('')}
</div>
<!-- Sparkline (30 Tage) -->
${(() => {
const hist = kiH?.daily_history || [];
if (!hist.length) return '<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-3)">Noch keine Verlaufsdaten</p>';
const max = Math.max(...hist.map(d => d.total), 1);
const W = 400, H = 60, n = hist.length;
const pts = hist.map((d, i) => {
const x = n === 1 ? W/2 : (i / (n - 1)) * W;
const y = H - 5 - (d.total / max) * (H - 10);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
const first = hist[0]?.date?.slice(5) || '';
const last = hist[hist.length-1]?.date?.slice(5) || '';
return `<div style="margin-bottom:var(--space-3)">
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:60px;display:block" preserveAspectRatio="none">
<polyline points="${pts}" fill="none" stroke="var(--c-primary)" stroke-width="1.5" stroke-linejoin="round"/>
</svg>
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--c-text-muted);margin-top:2px">
<span>${first}</span><span>30 Tage</span><span>${last}</span>
</div>
</div>`;
})()}
<!-- Limit-Hinweis -->
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-3)">
Cloud-Limit: <strong>${s.ki_cloud_weekly_limit ?? 20} Anfragen / Woche</strong> pro User
</p>
<!-- Top-Nutzer -->
${(kiH?.top_users || []).length ? `
<p style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);margin:0 0 var(--space-2);text-transform:uppercase;letter-spacing:.04em">Aktivste Nutzer</p>
<div class="adm-table-scroll">
<table class="adm-table">
<thead><tr>
<th>Name</th><th>E-Mail</th><th>☁️ Cloud</th><th>Gesamt</th><th>Zuletzt</th>
</tr></thead>
<tbody>
${(kiH.top_users).map(u => `<tr>
<td style="font-weight:600">${_esc(u.name)}</td>
<td style="color:var(--c-text-muted)">${_esc(u.email.length > 22 ? u.email.split('@')[1] : u.email)}</td>
<td style="color:var(--c-primary);font-weight:600">${u.cloud}</td>
<td>${u.total}</td>
<td style="color:var(--c-text-muted)">${u.last_date?.slice(5) || '—'}</td>
</tr>`).join('')}
</tbody>
</table>
</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-ors-card"></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)">Medien</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-3)">
<button class="btn btn-secondary btn-sm" id="adm-generate-previews">
${UI.icon('images')} Previews generieren (Bestand)
</button>
</div>
<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-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 12 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 3060s 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;
}
});
const [, orsStats] = await Promise.all([
_loadSystemCards(el.querySelector('#adm-sys-cards')),
API.get('/admin/ors/stats').catch(() => null),
]);
_renderOrsCard(el.querySelector('#adm-ors-card'), orsStats);
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);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:4px">
<span>Python ${_esc(s.python_version)}</span>
<span style="font-family:monospace;font-size:var(--text-xs);background:var(--c-surface-2);padding:2px 8px;border-radius:4px;color:var(--c-text-secondary)">
APP v${typeof APP_VER !== 'undefined' ? APP_VER : '—'} · ${_esc(s.sw_version || '?')}
</span>
</div>
</div>
`;
}
function _renderOrsCard(el, d) {
if (!el) return;
if (!d || d.today_count == null) { el.innerHTML = ''; return; }
const limit = d.today_limit ?? 2000;
const count = d.today_count ?? 0;
const pct = Math.min(Math.round(count / limit * 100), 100);
const barColor = pct < 50 ? '#4ade80' : pct <= 80 ? '#facc15' : '#f87171';
// Wochensumme + Gesamtsumme aus daily_history
const hist7 = (d.daily_history || []).slice(-7);
const weekTotal = hist7.reduce((s, h) => s + (h.count ?? 0), 0);
const totalAll = (d.daily_history || []).reduce((s, h) => s + (h.count ?? 0), 0);
// Sparkline aus daily_history (letzte 30 Tage)
const hist = Array.isArray(d.daily_history) ? d.daily_history : [];
const W = 400, H = 60, padY = 5;
let sparkline = '';
if (hist.length >= 2) {
const maxC = Math.max(...hist.map(h => h.count), 1);
const pts = hist.map((h, i) => {
const x = (i / (hist.length - 1)) * W;
const y = H - padY - (h.count / maxC) * (H - 2 * padY);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
sparkline = `<polyline points="${pts}" fill="none" stroke="var(--c-primary)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>`;
} else {
sparkline = `<polyline points="0,55 ${W},55" fill="none" stroke="var(--c-primary)" stroke-width="1.5"/>`;
}
const lastDate = hist.length ? hist[hist.length - 1].date : '';
// Top-Nutzer-Tabelle
const topUsers = Array.isArray(d.top_users) ? d.top_users.slice(0, 10) : [];
const userRows = topUsers.map(u => {
const emailDisplay = (u.email || '').length > 20
? '@' + (u.email || '').split('@')[1]
: _esc(u.email || '');
return `<tr>
<td style="padding:5px 8px;font-weight:500">${_esc(u.name || '')}</td>
<td style="padding:5px 8px;color:var(--c-text-muted);font-size:var(--text-xs)">${emailDisplay}</td>
<td style="padding:5px 8px;text-align:right;font-weight:600">${u.total ?? 0}</td>
<td style="padding:5px 8px;text-align:right;color:var(--c-text-muted);font-size:var(--text-xs)">${u.last_week || ''}</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="card" style="margin-bottom:var(--space-4);padding:var(--space-4)">
<!-- Header -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#path"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text);flex:1">
OpenRouteService
</span>
<div style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap">
<span style="font-size:var(--text-xs);font-weight:700;padding:3px 10px;border-radius:999px;
background:${barColor}22;color:${barColor};border:1px solid ${barColor}44">
${count.toLocaleString('de')} / ${limit.toLocaleString('de')} heute
</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap">
${weekTotal} diese Woche · ${totalAll} gesamt (30 Tage)
</span>
</div>
</div>
<!-- Fortschrittsbalken -->
<div style="height:6px;border-radius:3px;background:var(--c-border);overflow:hidden;margin-bottom:var(--space-4)">
<div style="height:100%;width:${pct}%;background:${barColor};border-radius:3px;transition:width 0.6s ease"></div>
</div>
<!-- Sparkline -->
<div style="margin-bottom:var(--space-1)">
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:60px;display:block">
${sparkline}
</svg>
</div>
<div style="display:flex;justify-content:space-between;font-size:var(--text-xs);
color:var(--c-text-muted);margin-bottom:var(--space-4)">
<span>30 Tage</span>
<span>${_esc(lastDate)}</span>
</div>
${topUsers.length ? `
<!-- Top-Nutzer -->
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.05em;
color:var(--c-text-secondary);margin-bottom:var(--space-2)">Aktivste Nutzer</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:4px 8px;color:var(--c-text-muted);font-weight:600">Name</th>
<th style="text-align:left;padding:4px 8px;color:var(--c-text-muted);font-weight:600">E-Mail</th>
<th style="text-align:right;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Gesamt</th>
<th style="text-align:right;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Letzte Woche</th>
</tr>
</thead>
<tbody>${userRows}</tbody>
</table>
</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);
});
});
}
// ------------------------------------------------------------------
// TAB: ZÜCHTER-ANTRÄGE
// ------------------------------------------------------------------
async function _renderZuechter(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-zuchter-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-zuchter-list">Lade…</div>
`;
el.querySelector('#adm-zuchter-refresh').addEventListener('click', () =>
_loadZuechterAntraege(el.querySelector('#adm-zuchter-list'))
);
await _loadZuechterAntraege(el.querySelector('#adm-zuchter-list'));
}
async function _loadZuechterAntraege(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
let antraege;
try {
antraege = await API.breeder.pendingList();
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Anträge konnten nicht geladen werden.');
return;
}
if (!antraege.length) {
el.innerHTML = _emptyState('certificate', 'Keine offenen Anträge', 'Aktuell liegen keine Züchter-Anträge zur Prüfung vor.');
return;
}
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${antraege.map(a => `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
<!-- Infos -->
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);
color:var(--c-text);margin-bottom:var(--space-1)">
${_esc(a.name)}
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:400;margin-left:6px">
${_esc(a.email)}
</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-3);font-size:var(--text-xs);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
<span>${UI.icon('paw-print')} ${_esc(a.rasse_text || '')}</span>
<span>${UI.icon('house-line')} ${_esc(a.zwingername || '')}</span>
<span>${UI.icon('users')} ${_esc(a.verein || '')}</span>
<span>${UI.icon('map-pin')} ${_esc(a.stadt || '')}</span>
<span style="color:${a.vdh_mitglied ? 'var(--c-success)' : 'var(--c-text-muted)'}">
${UI.icon('certificate')} VDH: ${a.vdh_mitglied ? 'ja' : 'nein'}
</span>
${a.created_at ? `<span style="color:var(--c-text-muted)">${UI.icon('clock')} ${new Date(a.created_at).toLocaleDateString('de-DE')}</span>` : ''}
</div>
${a.beschreibung ? `
<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);margin-top:var(--space-1)">
${_esc(a.beschreibung)}
</div>` : ''}
</div>
<!-- Aktionen -->
<div style="display:flex;flex-direction:column;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-sm btn-secondary adm-breeder-docs"
data-uid="${a.user_id || a.id}">
${UI.icon('file-text')} Dokumente
</button>
<button class="btn btn-sm btn-primary adm-breeder-approve"
data-uid="${a.user_id || a.id}" data-name="${_esc(a.name)}">
${UI.icon('check')} Freischalten
</button>
<button class="btn btn-sm btn-ghost adm-breeder-reject"
data-uid="${a.user_id || a.id}" data-name="${_esc(a.name)}"
style="color:var(--c-danger)">
${UI.icon('x')} Ablehnen
</button>
</div>
</div>
</div>
`).join('')}
</div>
`;
// Dokumente anzeigen
el.querySelectorAll('.adm-breeder-docs').forEach(btn => {
btn.addEventListener('click', async () => {
const uid = btn.dataset.uid;
let docs;
try {
docs = await API.breeder.documents(uid);
} catch (e) {
UI.toast.error(e.message || 'Dokumente konnten nicht geladen werden.');
return;
}
UI.modal.open({
title: `${UI.icon('file-text')} Hochgeladene Dokumente`,
body: docs.length
? `<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${docs.map(d => `
<a href="${_esc(API.breeder.documentUrl(uid, d.id))}"
target="_blank" rel="noopener"
class="btn btn-secondary"
style="text-align:left;word-break:break-all">
${UI.icon('file')} ${_esc(d.filename || d.name || 'Dokument ' + d.id)}
</a>`).join('')}
</div>`
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine Dokumente hochgeladen.</p>`,
});
});
});
// Freischalten
el.querySelectorAll('.adm-breeder-approve').forEach(btn => {
btn.addEventListener('click', async () => {
const ok = window.confirm(`${btn.dataset.name} als Züchter freischalten?`);
if (!ok) return;
btn.disabled = true;
try {
const res = await API.breeder.approve(btn.dataset.uid);
UI.toast.success(res.message || `${btn.dataset.name} freigeschaltet.`);
await _loadZuechterAntraege(el);
} catch (e) {
UI.toast.error(e.message || 'Freischaltung fehlgeschlagen.');
btn.disabled = false;
}
});
});
// Ablehnen
el.querySelectorAll('.adm-breeder-reject').forEach(btn => {
btn.addEventListener('click', () => {
const uid = btn.dataset.uid;
const name = btn.dataset.name;
UI.modal.open({
title: `${UI.icon('x-circle')} Antrag ablehnen: ${name}`,
body: `
<form id="breeder-reject-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Bitte gib einen Ablehnungsgrund an. Dieser wird dem Antragsteller mitgeteilt.
</p>
<textarea id="breeder-reject-grund" name="grund" rows="4" required
placeholder="z. B. Dokumente unvollständig, Rasse nicht unterstützt…"
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);resize:vertical"></textarea>
</form>
`,
footer: `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary" id="breeder-reject-submit"
form="breeder-reject-form" style="width:100%;background:var(--c-danger);border-color:var(--c-danger)">
Antrag ablehnen
</button>
<button class="btn btn-ghost" data-modal-close>Abbrechen</button>
</div>
`,
});
document.getElementById('breeder-reject-form')?.addEventListener('submit', async ev => {
ev.preventDefault();
const grund = document.getElementById('breeder-reject-grund')?.value?.trim();
if (!grund) {
UI.toast.warning('Bitte einen Ablehnungsgrund angeben.');
return;
}
const submitBtn = document.getElementById('breeder-reject-submit');
if (submitBtn) submitBtn.disabled = true;
try {
const res = await API.breeder.reject(uid, grund);
UI.modal.close?.();
UI.toast.success(res.message || `Antrag von ${name} abgelehnt.`);
await _loadZuechterAntraege(el);
} catch (e) {
UI.toast.error(e.message || 'Ablehnung fehlgeschlagen.');
if (submitBtn) submitBtn.disabled = false;
}
});
});
});
}
// ------------------------------------------------------------------
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 _renderPartner(el) {
const codes = (await API.get('/admin/partner/codes')) || [];
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Anleitung -->
<div class="by-card" style="padding:var(--space-4);background:var(--c-surface-2);border-left:3px solid var(--c-primary)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-sm);font-weight:700">So funktioniert das Partner-System</h3>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);display:flex;flex-direction:column;gap:var(--space-2)">
<p style="margin:0"><strong>1. Partner-Code erstellen</strong> — Erstelle einen Code (z. B. <code>HUNDEBLOG</code>) für einen Influencer oder Partner. Der Code wird an die Person weitergegeben.</p>
<p style="margin:0"><strong>2. Registrierung mit Code</strong> — Wenn sich ein neuer User mit diesem Code registriert, wird er automatisch als <em>Gründer</em> markiert (Platz #1100, lebenslang kostenlos). Du siehst in der Tabelle wie viele Einlösungen jeder Code hat.</p>
<p style="margin:0"><strong>3. Partner-Status vergeben</strong> — Den Influencer selbst suchst du unten bei «Nutzer-Status» und setzt <em>Partner-Badge</em> (blaues Badge im Profil) und <em>Gründer-Lizenz</em>. So ist auch er als Gründer #X sichtbar.</p>
<p style="margin:0"><strong>Max. 100 Gründer</strong> — Ist die Zahl bei einem Code leer, ist sie unbegrenzt. Die globale Grenze über alle Codes hinweg sind 100 Gründer-Plätze.</p>
<p style="margin:0"><strong>Freunde werben</strong> — Jeder eingeloggte User hat einen persönlichen Einladungslink (Einstellungen → Freunde werben). Bei 10 geworbenen Usern gibt es 20 % Rabatt, bei 20 → 30 %, bei 50 → 50 % — lebenslang, sobald Bezahlfunktionen aktiv sind.</p>
</div>
</div>
<!-- Neuen Code anlegen -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Neuen Partner-Code erstellen</h3>
<form id="adm-partner-create" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Code</label>
<input class="form-control" name="code" placeholder="z. B. HUNDEBLOG"
style="text-transform:uppercase;font-family:monospace;letter-spacing:.08em" required>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Bezeichnung</label>
<input class="form-control" name="label" placeholder="z. B. Max Musterhund (Instagram)" required>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);align-items:center">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Max. Einlösungen <span style="color:var(--c-text-muted)">(leer = unbegrenzt)</span></label>
<input class="form-control" name="max_uses" type="number" min="1" placeholder="∞">
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);padding-top:var(--space-5)">
<input type="checkbox" id="adm-grants-founder" name="grants_founder" checked
style="width:16px;height:16px;accent-color:var(--c-primary)">
<label for="adm-grants-founder" style="font-size:var(--text-sm);cursor:pointer">
Gründer-Lizenz (lebenslang kostenlos)
</label>
</div>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="align-self:flex-start">
${UI.icon('plus')} Code erstellen
</button>
</form>
</div>
<!-- Aktive Codes -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Aktive Codes</h3>
<div id="adm-partner-codes-list">
${codes.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Partner-Codes angelegt.</p>`
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Code</th>
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Bezeichnung</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Nutzungen</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Gründer</th>
<th style="padding:var(--space-2) var(--space-3)"></th>
</tr>
</thead>
<tbody>
${codes.map(c => `
<tr style="border-bottom:1px solid var(--c-border)" data-code-id="${c.id}">
<td style="padding:var(--space-2) var(--space-3)">
<code style="font-weight:700;color:var(--c-primary);letter-spacing:.08em">${c.code}</code>
</td>
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text)">${c.label}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">
${c.uses}${c.max_uses ? `/${c.max_uses}` : ''}
</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center">
${c.grants_founder ? '✓' : '—'}
</td>
<td style="padding:var(--space-2) var(--space-3)">
<button class="btn btn-ghost btn-sm adm-del-code" data-id="${c.id}"
style="color:var(--c-danger,#dc2626);font-size:var(--text-xs)">
${UI.icon('trash')} Löschen
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>`
}
</div>
</div>
<!-- User-Status vergeben -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Nutzer-Status manuell vergeben</h3>
<form id="adm-partner-grant" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">User-ID oder Benutzername</label>
<input class="form-control" name="user_search" id="adm-grant-search"
placeholder="Name eingeben…" autocomplete="off">
<div id="adm-grant-result" style="margin-top:var(--space-2)"></div>
</div>
<div style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
<input type="checkbox" name="is_founder" value="1"
style="width:16px;height:16px;accent-color:var(--c-primary)">
Gründer-Lizenz
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
<input type="checkbox" name="is_partner" value="1"
style="width:16px;height:16px;accent-color:var(--c-primary)">
Partner-Badge (Creator)
</label>
</div>
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
${UI.icon('check')} Status setzen
</button>
</form>
</div>
</div>
`;
// Code erstellen
el.querySelector('#adm-partner-create')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
const code = (fd.code || '').trim().toUpperCase();
if (!code) return;
await UI.asyncButton(btn, async () => {
await API.post('/admin/partner/codes', {
code,
label: fd.label || code,
grants_founder: e.target.querySelector('[name="grants_founder"]').checked ? 1 : 0,
max_uses: fd.max_uses ? parseInt(fd.max_uses) : null,
});
UI.toast.success(`Code "${code}" erstellt.`);
await _renderPartner(el);
});
});
// Code löschen
el.querySelectorAll('.adm-del-code').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm(`Code wirklich löschen?`)) return;
const id = btn.dataset.id;
await UI.asyncButton(btn, async () => {
await API.del(`/admin/partner/codes/${id}`);
UI.toast.success('Code gelöscht.');
await _renderPartner(el);
});
});
});
// User suchen für Status-Vergabe
let _grantUserId = null;
const searchInput = el.querySelector('#adm-grant-search');
const grantResult = el.querySelector('#adm-grant-result');
let _searchTimeout = null;
searchInput?.addEventListener('input', () => {
clearTimeout(_searchTimeout);
_grantUserId = null;
const q = searchInput.value.trim();
if (q.length < 1) { grantResult.innerHTML = ''; return; }
_searchTimeout = setTimeout(async () => {
try {
const res = await API.get(`/admin/users?q=${encodeURIComponent(q)}&limit=10`);
const users = res?.users || res || [];
if (!users || !users.length) {
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-text-muted)">Kein User gefunden.</p>`;
return;
}
grantResult.innerHTML = users.map(u => `
<div class="adm-grant-user" data-id="${u.id}" data-name="${u.name}"
data-founder="${u.is_founder||0}" data-partner="${u.is_partner||0}"
style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);
cursor:pointer;background:var(--c-surface-2);margin-bottom:2px;
font-size:var(--text-sm);display:flex;justify-content:space-between">
<span><strong>${u.name}</strong></span>
<span style="color:var(--c-text-muted);font-size:var(--text-xs)">
${u.rolle}${u.is_founder ? ' · ⭐' : ''}${u.is_partner ? ' · 🤝' : ''}
</span>
</div>
`).join('');
grantResult.querySelectorAll('.adm-grant-user').forEach(div => {
div.addEventListener('click', () => {
_grantUserId = parseInt(div.dataset.id);
searchInput.value = div.dataset.name;
// Aktuellen Status in Checkboxen setzen
const form = el.querySelector('#adm-partner-grant');
if (form) {
form.querySelector('[name="is_founder"]').checked = div.dataset.founder === '1';
form.querySelector('[name="is_partner"]').checked = div.dataset.partner === '1';
}
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ ${div.dataset.name} ausgewählt${div.dataset.founder==='1' ? ' · ⭐ Gründer' : ''}${div.dataset.partner==='1' ? ' · 🤝 Partner' : ''}</p>`;
});
});
} catch(e) {
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-danger)">${e.message || 'Suchfehler'}</p>`;
}
}, 400);
});
el.querySelector('#adm-partner-grant')?.addEventListener('submit', async e => {
e.preventDefault();
if (!_grantUserId) { UI.toast.warning('Bitte erst einen User auswählen.'); return; }
const btn = e.target.querySelector('[type="submit"]');
const isFounder = e.target.querySelector('[name="is_founder"]').checked ? 1 : 0;
const isPartner = e.target.querySelector('[name="is_partner"]').checked ? 1 : 0;
await UI.asyncButton(btn, async () => {
const result = await API.post(`/admin/partner/users/${_grantUserId}/grant`, {
is_founder: isFounder,
is_partner: isPartner,
});
if (!result) throw new Error('Keine Antwort vom Server.');
UI.toast.success(`Status für ${result.name} gesetzt.`);
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'}</p>`;
}).catch(e => UI.toast.error(e.message || 'Fehler beim Speichern.'));
});
}
async function _renderOutreach(el) {
const [templates, log] = await Promise.all([
API.get('/outreach/templates').catch(() => []),
API.get('/outreach/log').catch(() => []),
]);
const accountBadge = a => a === 'support'
? `<span style="font-size:10px;background:var(--c-warning-bg,#FEF3C7);color:var(--c-warning,#D97706);padding:1px 6px;border-radius:999px">support@</span>`
: `<span style="font-size:10px;background:var(--c-primary-bg,#EFF6FF);color:var(--c-primary,#2563EB);padding:1px 6px;border-radius:999px">partner@</span>`;
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Vorlagen-Manager -->
<div class="by-card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<h3 style="margin:0;font-size:var(--text-base)">Vorlagen</h3>
<button class="btn btn-sm btn-secondary" id="adm-tpl-new">
${UI.icon('plus')} Neue Vorlage
</button>
</div>
${templates.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Vorlagen.</p>`
: `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${templates.map(t => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);
background:var(--c-bg-elevated);border-radius:var(--radius-md);border:1px solid var(--c-border)">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:var(--text-sm);font-weight:600">${_esc(t.label)}</span>
${accountBadge(t.from_account)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(t.subject)}
</div>
</div>
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-xs btn-secondary adm-tpl-load" data-id="${t.id}" title="In Compose laden">
${UI.icon('arrow-bend-up-left')}
</button>
<button class="btn btn-xs btn-secondary adm-tpl-edit" data-id="${t.id}" title="Bearbeiten">
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-xs btn-danger adm-tpl-del" data-id="${t.id}" title="Löschen">
${UI.icon('trash')}
</button>
</div>
</div>`).join('')}
</div>`}
</div>
<!-- Compose -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">E-Mail senden</h3>
<form id="adm-outreach-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<!-- Absender -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Absender</label>
<select id="adm-outreach-from" class="form-control">
<option value="partner">partner@banyaro.app (Influencer/Partner)</option>
<option value="support">support@banyaro.app (Support/Moderation)</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">
Empfänger <span style="color:var(--c-text-muted)">(Komma-getrennt)</span>
</label>
<input class="form-control" id="adm-outreach-to" type="text"
placeholder="name@example.com, andere@example.com">
</div>
</div>
<!-- Betreff -->
<div>
<label class="form-label" style="font-size:var(--text-xs)">Betreff</label>
<input class="form-control" id="adm-outreach-subject" type="text">
</div>
<!-- Text -->
<div>
<label class="form-label" style="font-size:var(--text-xs)">Text</label>
<textarea id="adm-outreach-body" class="form-control" rows="14"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical"></textarea>
</div>
<div style="display:flex;gap:var(--space-3);align-items:center">
<button type="submit" class="btn btn-primary">
${UI.icon('paper-plane-tilt')} Senden
</button>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
{name} wird nicht automatisch ersetzt — bitte manuell anpassen.
</span>
</div>
</form>
</div>
<!-- Versand-Log -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Versand-Log</h3>
${log.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine E-Mails gesendet.</p>`
: `<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:var(--space-2);color:var(--c-text-muted)">Von</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Empfänger</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Betreff</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wer</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wann</th>
</tr>
</thead>
<tbody>
${log.map(l => `
<tr style="border-bottom:1px solid var(--c-border)">
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td>
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
<td style="padding:var(--space-2);color:var(--c-text-muted)">${_esc(l.sent_by_name || '')}</td>
<td style="padding:var(--space-2);color:var(--c-text-muted)">${(l.sent_at||'').slice(0,16).replace('T',' ')}</td>
</tr>`).join('')}
</tbody>
</table>`}
</div>
</div>
`;
// Vorlage in Compose laden
function _loadTplIntoCompose(id) {
const tpl = templates.find(t => t.id === id);
if (!tpl) return;
el.querySelector('#adm-outreach-from').value = tpl.from_account || 'partner';
el.querySelector('#adm-outreach-subject').value = tpl.subject;
el.querySelector('#adm-outreach-body').value = tpl.body;
}
el.querySelectorAll('.adm-tpl-load').forEach(btn => {
btn.addEventListener('click', () => _loadTplIntoCompose(Number(btn.dataset.id)));
});
// Vorlage löschen
el.querySelectorAll('.adm-tpl-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Vorlage löschen?')) return;
await API.del(`/outreach/templates/${btn.dataset.id}`);
await _renderOutreach(el);
});
});
// Vorlage bearbeiten
el.querySelectorAll('.adm-tpl-edit').forEach(btn => {
btn.addEventListener('click', () => {
const tpl = templates.find(t => t.id === Number(btn.dataset.id));
if (tpl) _openTplModal(el, tpl);
});
});
// Neue Vorlage
el.querySelector('#adm-tpl-new')?.addEventListener('click', () => _openTplModal(el, null));
// Senden
el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const from_account = el.querySelector('#adm-outreach-from').value;
const to = (el.querySelector('#adm-outreach-to').value || '')
.split(',').map(s => s.trim()).filter(Boolean);
const subject = el.querySelector('#adm-outreach-subject').value.trim();
const body = el.querySelector('#adm-outreach-body').value.trim();
if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; }
if (!subject) { UI.toast.warning('Betreff fehlt.'); return; }
if (!body) { UI.toast.warning('Text fehlt.'); return; }
await UI.asyncButton(btn, async () => {
const res = await API.post('/outreach/send', { to, subject, body, from_account });
if (res.sent?.length) UI.toast.success(`${res.sent.length} E-Mail(s) gesendet.`);
if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f => f.error).join(', ')}`);
await _renderOutreach(el);
});
});
}
function _openTplModal(el, tpl) {
const isNew = !tpl;
const id = `adm-tpl-modal-${Date.now()}`;
UI.modal.open({
title: isNew ? 'Neue Vorlage' : 'Vorlage bearbeiten',
body: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Name (intern)</label>
<input class="form-control" id="${id}-key" type="text" placeholder="z.B. willkommen_neu"
value="${_esc(tpl?.key || '')}" ${isNew ? '' : 'readonly'}>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Absender</label>
<select id="${id}-from" class="form-control">
<option value="partner" ${(tpl?.from_account||'partner')==='partner'?'selected':''}>partner@banyaro.app</option>
<option value="support" ${tpl?.from_account==='support'?'selected':''}>support@banyaro.app</option>
</select>
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Bezeichnung (sichtbar)</label>
<input class="form-control" id="${id}-label" type="text" placeholder="z.B. Willkommensnachricht"
value="${_esc(tpl?.label || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Betreff</label>
<input class="form-control" id="${id}-subject" type="text"
value="${_esc(tpl?.subject || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Text</label>
<textarea id="${id}-body" class="form-control" rows="12"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical">${_esc(tpl?.body || '')}</textarea>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Speichern</button>`,
});
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const payload = {
label: document.getElementById(`${id}-label`).value.trim(),
subject: document.getElementById(`${id}-subject`).value.trim(),
body: document.getElementById(`${id}-body`).value.trim(),
from_account: document.getElementById(`${id}-from`).value,
};
if (!payload.label || !payload.subject || !payload.body) {
UI.toast.warning('Alle Felder ausfüllen.'); return;
}
if (isNew) {
const key = document.getElementById(`${id}-key`).value.trim();
if (!key) { UI.toast.warning('Interner Name fehlt.'); return; }
await API.post('/outreach/templates', { ...payload, key });
} else {
await API.put(`/outreach/templates/${tpl.id}`, payload);
}
UI.modal.close();
await _renderOutreach(el);
});
}
async function _renderAudit(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-audit-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-audit-list">Lade…</div>
`;
el.querySelector('#adm-audit-refresh').addEventListener('click', () => _loadAudit(el.querySelector('#adm-audit-list')));
await _loadAudit(el.querySelector('#adm-audit-list'));
}
async function _loadAudit(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const rows = await API.get('/admin/audit?limit=50');
if (!rows.length) {
el.innerHTML = _emptyState('list-bullets', 'Keine Einträge', 'Noch keine Admin-Aktionen protokolliert.');
return;
}
el.innerHTML = `
<div class="card adm-table-card">
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Wann</th>
<th class="adm-th">Admin</th>
<th class="adm-th">Aktion</th>
<th class="adm-th">Ziel</th>
</tr>
</thead>
<tbody>
${rows.map((r, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td" style="color:var(--c-text-muted);white-space:nowrap;font-size:var(--text-xs)">
${_formatDateTime(r.created_at)}
</td>
<td class="adm-td" style="color:var(--c-text);white-space:nowrap">
${_esc(r.admin_name || '—')}
</td>
<td class="adm-td">
<span class="adm-badge-mono">${_esc(r.action)}</span>
${r.detail ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(r.detail)}</div>` : ''}
</td>
<td class="adm-td" style="color:var(--c-text-secondary);font-size:var(--text-xs);white-space:nowrap">
${_esc(r.target || '—')}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
}
// ------------------------------------------------------------------
// HELPERS
// ------------------------------------------------------------------
function _prompt(msg) {
return new Promise(resolve => {
UI.modal.open({
title: 'Eingabe',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">${msg}</p>
<input id="adm-prompt-input" type="text"
style="width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)">
`,
footer: `
<button class="btn btn-primary" id="adm-prompt-ok" form="">OK</button>
<button class="btn btn-ghost" id="adm-prompt-cancel" form="">Abbrechen</button>
`,
});
document.getElementById('adm-prompt-ok')?.addEventListener('click', () => {
const val = document.getElementById('adm-prompt-input')?.value || '';
UI.modal.close();
resolve(val);
});
document.getElementById('adm-prompt-cancel')?.addEventListener('click', () => {
UI.modal.close();
resolve(null);
});
});
}
function _emptyState(icon, title, text) {
return `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<svg class="ph-icon" style="width:40px;height:40px;color:var(--c-border);
margin-bottom:var(--space-3)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<p style="font-weight:var(--weight-semibold);color:var(--c-text);margin:0 0 var(--space-1)">${title}</p>
${text ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">${text}</p>` : ''}
</div>
`;
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();