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

@ -330,79 +330,9 @@
<!-- Feature-Seiten werden lazy geladen -->
<!-- Google Analytics (Option B: cookieless/anonymisiert, kein Consent nötig)
Wechsel zu Option A (mit Consent-Banner): GA_MODE auf 'A' setzen -->
<script>
(function() {
var GA_ID = 'G-YLG780DV3Z';
var GA_MODE = 'B'; // 'B' = cookieless | 'A' = mit Consent-Banner
<!-- Umami Analytics (self-hosted, cookiefrei, DSGVO-konform) -->
<script defer src="https://umami.motocamp.de/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f"></script>
function _loadGA(withConsent) {
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID;
document.head.appendChild(s);
window.dataLayer = window.dataLayer || [];
window.gtag = function(){ dataLayer.push(arguments); };
gtag('js', new Date());
if (withConsent) {
gtag('config', GA_ID, { anonymize_ip: true });
} else {
// Option B: komplett cookieless
gtag('config', GA_ID, { anonymize_ip: true, storage: 'none', client_storage: 'none' });
}
}
// Opt-out respektieren (setzbar über Datenschutz-Seite)
if (localStorage.getItem('gaOptOut') === 'yes') return;
if (GA_MODE === 'B') {
_loadGA(false);
} else {
// Option A: Consent-Banner
var consent = localStorage.getItem('gaConsent');
if (consent === 'yes') { _loadGA(true); }
window.addEventListener('DOMContentLoaded', function() {
if (consent !== null) return;
var banner = document.getElementById('ga-consent-banner');
if (banner) banner.style.display = 'flex';
document.getElementById('ga-consent-accept')?.addEventListener('click', function() {
localStorage.setItem('gaConsent', 'yes');
_loadGA(true);
banner.style.display = 'none';
});
document.getElementById('ga-consent-decline')?.addEventListener('click', function() {
localStorage.setItem('gaConsent', 'no');
banner.style.display = 'none';
});
});
}
})();
</script>
<!-- GA Consent-Banner (nur für Option A aktiv, im Mode B versteckt) -->
<div id="ga-consent-banner"
style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9000;
background:var(--c-surface,#fff);border-top:1px solid var(--c-border,#e5e7eb);
padding:var(--space-3) var(--space-4);flex-wrap:wrap;
align-items:center;gap:var(--space-3)">
<span style="flex:1;min-width:200px;font-size:var(--text-xs);color:var(--c-text-secondary)">
Wir nutzen Google Analytics (anonymisiert) um die App zu verbessern.
<span data-page="datenschutz" style="color:var(--c-primary);cursor:pointer;text-decoration:underline">Mehr erfahren</span>
</span>
<div style="display:flex;gap:var(--space-2)">
<button id="ga-consent-decline"
style="padding:var(--space-2) var(--space-3);border:1px solid var(--c-border,#e5e7eb);
border-radius:var(--radius-md);background:transparent;cursor:pointer;
font-size:var(--text-xs);color:var(--c-text-secondary)">Ablehnen</button>
<button id="ga-consent-accept"
style="padding:var(--space-2) var(--space-3);border:none;
border-radius:var(--radius-md);background:var(--c-primary,#C4843A);
color:#fff;cursor:pointer;font-size:var(--text-xs)">Akzeptieren</button>
</div>
</div>
<!-- Offline-Banner Logik -->
<script>

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
// ------------------------------------------------------------------

View file

@ -5,36 +5,19 @@
window.Page_datenschutz = (() => {
function init(container) {
const optOut = localStorage.getItem('gaOptOut') === 'yes';
const gaSection = `
const umamiSection = `
<section style="margin-bottom:var(--space-6)">
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-2)">Google Analytics</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0 0 var(--space-3)">
Wir nutzen Google Analytics 4 (Google LLC, USA) zur anonymisierten Analyse der App-Nutzung.
Deine IP-Adresse wird gekürzt, es werden keine Cookies gesetzt und keine
personenbezogenen Daten gespeichert. Die Verarbeitung erfolgt auf Basis von
Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an anonymer Nutzungsanalyse).
Du kannst der Erhebung jederzeit widersprechen.
color:var(--c-text);margin:0 0 var(--space-2)">Nutzungsanalyse (Umami)</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0">
Wir verwenden Umami, ein datenschutzfreundliches Analysetool, das ausschließlich auf
unserem eigenen Server betrieben wird. Es werden keine Cookies gesetzt, keine
personenbezogenen Daten erhoben und keine Daten an Dritte weitergegeben.
Erfasst werden lediglich anonyme Seitenaufrufe zur Verbesserung der App.
Eine Rechtsgrundlage nach Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse) ist
gegeben; ein Widerspruch oder Opt-out ist nicht erforderlich, da keine
personenbezogenen Daten verarbeitet werden.
</p>
${optOut ? `
<p style="font-size:var(--text-sm);color:var(--c-success,#16a34a);margin:0 0 var(--space-3)">
Analytics ist für dich <strong>deaktiviert</strong>.
</p>
<button id="ga-optin-btn"
style="padding:var(--space-2) var(--space-4);border:1px solid var(--c-border,#e5e7eb);
border-radius:var(--radius-md);background:transparent;cursor:pointer;
font-size:var(--text-sm);color:var(--c-text-secondary)">
Analytics wieder aktivieren
</button>
` : `
<button id="ga-optout-btn"
style="padding:var(--space-2) var(--space-4);border:1px solid var(--c-border,#e5e7eb);
border-radius:var(--radius-md);background:transparent;cursor:pointer;
font-size:var(--text-sm);color:var(--c-text-secondary)">
Analytics deaktivieren (Opt-out)
</button>
`}
</section>
`;
@ -78,7 +61,7 @@ window.Page_datenschutz = (() => {
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0">
Die Verarbeitung erfolgt auf Basis von Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung)
für alle zur Bereitstellung des Dienstes notwendigen Daten, sowie Art. 6 Abs. 1 lit. a
DSGVO (Einwilligung) für optionale Funktionen wie Standortfreigabe und Analytics.
DSGVO (Einwilligung) für optionale Funktionen wie Standortfreigabe.
</p>
</section>
@ -92,7 +75,7 @@ window.Page_datenschutz = (() => {
</p>
</section>
${gaSection}
${umamiSection}
<section style="margin-bottom:var(--space-6)">
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
@ -123,16 +106,6 @@ window.Page_datenschutz = (() => {
</div>
`;
container.querySelector('#ga-optout-btn')?.addEventListener('click', () => {
localStorage.setItem('gaOptOut', 'yes');
UI.toast.success('Analytics deaktiviert. Wirksam nach nächstem App-Neustart.');
init(container);
});
container.querySelector('#ga-optin-btn')?.addEventListener('click', () => {
localStorage.removeItem('gaOptOut');
UI.toast.success('Analytics wieder aktiviert. Wirksam nach nächstem App-Neustart.');
init(container);
});
}
function refresh() {}

View file

@ -28,10 +28,11 @@ window.Page_diary = (() => {
if (!url) return false;
return _VIDEO_EXT.has(url.slice(url.lastIndexOf('.')).toLowerCase());
}
function _videoPoster(url) { return url.replace(/\.[^.]+$/, '_thumb.jpg'); }
function _mediaHtml(url, style = '') {
if (!url) return '';
return _isVideo(url)
? `<video src="${url}" controls playsinline style="width:100%;border-radius:var(--radius-md);${style}"></video>`
? `<video src="${url}" poster="${_videoPoster(url)}" controls playsinline style="width:100%;border-radius:var(--radius-md);${style}"></video>`
: `<img src="${url}" alt="Foto" style="width:100%;border-radius:var(--radius-md);${style}">`;
}
@ -454,7 +455,7 @@ window.Page_diary = (() => {
<span style="font-size:12px;color:var(--c-text-secondary)">PDF öffnen</span>
</a>`
: m.media_type === 'video'
? `<video src="${UI.escape(m.url)}" controls playsinline style="width:100%;max-height:55vh;display:block;object-fit:contain;background:#000"></video>`
? `<video src="${UI.escape(m.url)}" poster="${UI.escape(_videoPoster(m.url))}" controls playsinline style="width:100%;max-height:55vh;display:block;object-fit:contain;background:#000"></video>`
: `<img src="${UI.escape(m.url)}" data-idx="${allMedia.indexOf(m)}" style="width:100%;max-height:55vh;object-fit:cover;display:block;cursor:zoom-in">`;
let mediaSection = '';
@ -470,7 +471,7 @@ window.Page_diary = (() => {
${m.media_type === 'pdf'
? `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)"><svg class="ph-icon" style="width:28px;height:28px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`
: m.media_type === 'video'
? `<video src="${UI.escape(m.url)}" style="width:100%;height:100%;object-fit:cover;pointer-events:none"></video>`
? `<video src="${UI.escape(m.url)}" poster="${UI.escape(_videoPoster(m.url))}" style="width:100%;height:100%;object-fit:cover;pointer-events:none"></video>`
: `<img src="${UI.escape(m.url)}" style="width:100%;height:100%;object-fit:cover">`}
</div>`).join('')}
</div>`;
@ -761,7 +762,7 @@ window.Page_diary = (() => {
<div style="position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--c-surface-2)"
data-media-id="${m.id ?? ''}">
${m.media_type === 'video'
? `<video src="${m.url}" style="width:100%;height:100%;object-fit:cover;display:block" muted playsinline></video>`
? `<video src="${m.url}" poster="${_videoPoster(m.url)}" style="width:100%;height:100%;object-fit:cover;display:block" muted playsinline></video>`
: `<img src="${m.url}" alt="" style="width:100%;height:100%;object-fit:cover;display:block">`}
<button type="button" class="diary-media-thumb-del"
data-media-id="${m.id ?? ''}" data-legacy="${m.id == null ? '1' : ''}"

View file

@ -328,9 +328,11 @@ window.Page_forum = (() => {
if (u.endsWith('.pdf'))
return `<a href="${_esc(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${_esc(u.split('/').pop())}</span></a>`;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u))
return `<video src="${_esc(u)}" controls playsinline
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
const poster = u.replace(/\.[^.]+$/, '_thumb.jpg');
return `<video src="${_esc(u)}" poster="${_esc(poster)}" controls playsinline
style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`;
}
return `<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`;
};
const fotoGallery = (thread.foto_urls?.length)

View file

@ -429,6 +429,13 @@ window.Page_settings = (() => {
title: 'Profil bearbeiten',
body: `
<form id="profile-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Echter Name (privat)</label>
<input name="real_name" type="text" maxlength="80"
placeholder="z. B. Maria Müller"
value="${_esc(u.real_name || '')}"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Bio</label>
<textarea name="bio" maxlength="300" rows="4"
@ -473,6 +480,7 @@ window.Page_settings = (() => {
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const updated = await API.patch('/profile', {
real_name: fd.real_name || '',
bio: fd.bio || '',
wohnort: fd.wohnort || '',
erfahrung: fd.erfahrung || '',
@ -714,9 +722,13 @@ window.Page_settings = (() => {
return `
<form id="auth-form" autocomplete="on" novalidate>
<div class="form-group">
<label class="form-label">Dein Name</label>
<label class="form-label">Benutzername</label>
<input class="form-control" type="text" name="name"
placeholder="z. B. Maria" autocomplete="name" required>
placeholder="z. B. bellas_mama" autocomplete="username" required
pattern="^\\S+$" title="Kein Leerzeichen erlaubt">
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-secondary)">
Dieser Name ist öffentlich sichtbar und wird bei deinen Beiträgen, Pins und Kommentaren angezeigt.
</p>
</div>
<div class="form-group">
<label class="form-label">E-Mail</label>