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:
parent
9a78121a3e
commit
5141ba9969
20 changed files with 524 additions and 143 deletions
|
|
@ -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
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue