Session 2026-04-20: Medien-Konvertierung, Umami Analytics, Username/Privacy

- HEIC→JPEG, MOV/AVI→MP4 Konvertierung bei allen Upload-Endpoints (media_utils.py)
- ffmpeg im Docker-Image, Video-Thumbnails (extract_video_thumb, poster-Attribut)
- Google Analytics entfernt, Umami self-hosted eingebunden (index.html, datenschutz.js)
- Admin-Panel Analytics-Tab: Stat-Cards, Sparkline 7 Tage, Top-Seiten (Umami-API-Proxy)
- Admin-Panel Tab-Icons korrigiert (aus vorhandenem Phosphor-Sprite)
- users.real_name Spalte: Username öffentlich, echter Name privat und optional
- Registrierung: Label "Benutzername", Leerzeichen verboten, Profanity-Blockliste
- Datenschutzerklärung: GA-Abschnitt durch Umami-Text ersetzt
This commit is contained in:
rene 2026-04-20 18:36:58 +02:00
parent 9a78121a3e
commit 5141ba9969
20 changed files with 524 additions and 143 deletions

View file

@ -10,12 +10,13 @@ window.Page_admin = (() => {
let _tab = 'uebersicht';
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'chart-bar' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'forum', label: 'Forum & Meldungen',icon: 'chat-circle-dots' },
{ id: 'system', label: 'System', icon: 'cpu' },
{ id: 'jobs', label: 'Jobs', icon: 'timer' },
{ id: 'audit', label: 'Audit-Log', icon: 'list-bullets' },
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' },
{ id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
];
// ------------------------------------------------------------------
@ -73,18 +74,108 @@ window.Page_admin = (() => {
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 'forum': await _renderForum(el); break;
case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break;
case 'audit': await _renderAudit(el); break;
case 'uebersicht': await _renderStats(el); break;
case 'nutzer': await _renderUsers(el); break;
case 'forum': await _renderForum(el); break;
case 'analytics': await _renderAnalytics(el); break;
case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break;
case 'audit': await _renderAudit(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
}
}
// ------------------------------------------------------------------
// TAB: ANALYTICS
async function _renderAnalytics(el) {
const d = await API.get('/admin/analytics');
const pv = d.pageviews?.pageviews ?? [];
const ses = d.pageviews?.sessions ?? [];
// Sparkline SVG (Seitenaufrufe 7 Tage)
function _sparkline(data, color) {
if (!data.length) return '<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Keine Daten</span>';
const vals = data.map(p => p.y ?? 0);
const max = Math.max(...vals, 1);
const W = 200, H = 48, pad = 4;
const pts = vals.map((v, i) => {
const x = pad + i * ((W - 2*pad) / Math.max(vals.length - 1, 1));
const y = H - pad - (v / max) * (H - 2*pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:48px">
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round"/>
</svg>`;
}
const tv = v => v?.value ?? 0;
const fmt = v => Number(v).toLocaleString('de');
// Bounce Rate & Verweildauer
const bounceToday = d.today?.bounceRate?.value != null
? (d.today.bounceRate.value * 100).toFixed(0) + ' %'
: (d.today?.bounces?.value != null && d.today?.visits?.value > 0
? ((d.today.bounces.value / d.today.visits.value) * 100).toFixed(0) + ' %'
: '—');
const timeWeek = d.week?.totaltime?.value > 0 && d.week?.visits?.value > 0
? Math.round(d.week.totaltime.value / d.week.visits.value) + ' s'
: '—';
el.innerHTML = `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
${_statCard('user', 'Besucher heute', fmt(tv(d.today?.visitors)), 'var(--c-primary)')}
${_statCard('eye', 'Aufrufe heute', fmt(tv(d.today?.pageviews)), 'var(--c-primary)')}
${_statCard('users','Besucher 7 Tage', fmt(tv(d.week?.visitors)), 'var(--c-success)')}
${_statCard('eye', 'Aufrufe 7 Tage', fmt(tv(d.week?.pageviews)), 'var(--c-success)')}
${_statCard('arrow-u-up-left','Bounce heute', bounceToday, 'var(--c-text-secondary)')}
${_statCard('timer','Ø Verweildauer 7 Tage', timeWeek, 'var(--c-text-secondary)')}
</div>
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Seitenaufrufe letzte 7 Tage</div>
${_sparkline(pv, 'var(--c-primary)')}
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
${pv.map(p => `<span>${new Date(p.x).toLocaleDateString('de',{weekday:'short'})}</span>`).join('')}
</div>
</div>
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Top Seiten letzte 7 Tage</div>
${(d.top_pages ?? []).length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Daten</p>`
: `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${d.top_pages.map(p => {
const maxY = d.top_pages[0].y;
const pct = maxY > 0 ? (p.y / maxY * 100).toFixed(0) : 0;
return `
<div>
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);margin-bottom:3px">
<span style="color:var(--c-text);overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;max-width:75%">${UI.escape(p.x)}</span>
<span style="color:var(--c-text-secondary);flex-shrink:0">${fmt(p.y)}</span>
</div>
<div style="height:4px;border-radius:2px;background:var(--c-surface-3)">
<div style="height:100%;width:${pct}%;border-radius:2px;
background:var(--c-primary);transition:width .3s"></div>
</div>
</div>`;
}).join('')}
</div>`}
</div>
</div>`;
}
// ------------------------------------------------------------------
// TAB: ÜBERSICHT
// ------------------------------------------------------------------