Moderations-Panel: neue Seite /moderation für Mods und Admins

- Backend: routes/moderation.py mit GET /stats, /reports, /users, /fotos
  und PATCH-Endpoints für Ban/Unban und Foto-Review
- Frontend: pages/moderation.js mit 4 Tabs (Übersicht, Fotos, User, Forum)
- Sidebar-Eintrag (nur für Moderatoren/Admins sichtbar, gelb)
- Page in index.html registriert, pages-Objekt in app.js ergänzt
- Router in main.py eingebunden (/api/moderation)
- SW-Cache by-v357, app.js/ui.js/api.js auf v=94
This commit is contained in:
rene 2026-04-25 08:19:19 +02:00
parent d0abb6de9b
commit 8ba8f4dfa3
6 changed files with 692 additions and 4 deletions

View file

@ -121,6 +121,7 @@ from routes.training import router as training_router
from routes.praise import router as praise_router from routes.praise import router as praise_router
from routes.weather import router as weather_router from routes.weather import router as weather_router
from routes.social import router as social_router from routes.social import router as social_router
from routes.moderation import router as moderation_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -161,6 +162,7 @@ app.include_router(stats_router, prefix="/api/stats", tags=[
app.include_router(achievements_router, prefix="/api/achievements", tags=["Achievements"]) app.include_router(achievements_router, prefix="/api/achievements", tags=["Achievements"])
app.include_router(training_router, prefix="/api/training", tags=["Training"]) app.include_router(training_router, prefix="/api/training", tags=["Training"])
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"]) app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -0,0 +1,223 @@
"""BAN YARO — Moderations-Panel Backend"""
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
router = APIRouter()
# ------------------------------------------------------------------
# Dependency: Moderator oder Admin
# ------------------------------------------------------------------
def require_moderator(user=Depends(get_current_user)):
if not (user.get("is_moderator") or user["rolle"] == "admin"):
raise HTTPException(403, "Nur für Moderatoren.")
return user
# ------------------------------------------------------------------
# GET /api/moderation/stats — Übersicht
# ------------------------------------------------------------------
@router.get("/stats")
async def mod_stats(user=Depends(require_moderator)):
with db() as conn:
open_reports = conn.execute(
"SELECT COUNT(*) FROM forum_reports WHERE resolved=0"
).fetchone()[0]
pending_fotos = 0
try:
pending_fotos = conn.execute(
"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'"
).fetchone()[0]
except Exception:
pass
banned_users = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_banned=1"
).fetchone()[0]
pending_zuchter = 0
try:
pending_zuchter = conn.execute(
"SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0"
).fetchone()[0]
except Exception:
pass
return {
"open_reports": open_reports,
"pending_fotos": pending_fotos,
"banned_users": banned_users,
"pending_zuchter": pending_zuchter,
}
# ------------------------------------------------------------------
# GET /api/moderation/reports — gemeldete Inhalte
# ------------------------------------------------------------------
@router.get("/reports")
async def mod_reports(user=Depends(require_moderator)):
with db() as conn:
rows = conn.execute("""
SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at,
u.name AS melder_name,
CASE r.target_type
WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id)
WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id)
END AS content_preview
FROM forum_reports r
LEFT JOIN users u ON u.id=r.user_id
WHERE r.resolved=0
ORDER BY r.created_at DESC
LIMIT 100
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/moderation/reports/{id} — Meldung erledigen
# ------------------------------------------------------------------
@router.patch("/reports/{rid}")
async def mod_resolve_report(rid: int, user=Depends(require_moderator)):
with db() as conn:
r = conn.execute(
"SELECT resolved FROM forum_reports WHERE id=?", (rid,)
).fetchone()
if not r:
raise HTTPException(404, "Meldung nicht gefunden.")
new_state = 0 if r["resolved"] else 1
conn.execute(
"UPDATE forum_reports SET resolved=? WHERE id=?",
(new_state, rid)
)
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/moderation/users — User-Liste (Basisinfos)
# ------------------------------------------------------------------
@router.get("/users")
async def mod_users(
q: str = "",
banned: int = 0,
limit: int = 50,
offset: int = 0,
user=Depends(require_moderator),
):
with db() as conn:
where = "WHERE 1=1"
params = []
if q.strip():
where += " AND (name LIKE ? OR email LIKE ?)"
params.extend([f"%{q.strip()}%", f"%{q.strip()}%"])
if banned:
where += " AND is_banned=1"
# E-Mail nur für Admins; Moderatoren sehen maskierte Version
email_col = "email" if user["rolle"] == "admin" else \
"SUBSTR(email,1,2)||'***@'||SUBSTR(email,INSTR(email,'@')+1) AS email"
rows = conn.execute(f"""
SELECT id, name, {email_col}, rolle, is_moderator, is_banned, ban_reason, created_at
FROM users
{where}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""", [*params, limit, offset]).fetchall()
total = conn.execute(
f"SELECT COUNT(*) FROM users {where}", params
).fetchone()[0]
return {"users": [dict(r) for r in rows], "total": total}
# ------------------------------------------------------------------
# PATCH /api/moderation/users/{id} — Ban / Unban
# ------------------------------------------------------------------
@router.patch("/users/{uid}")
async def mod_patch_user(uid: int, data: dict, user=Depends(require_moderator)):
allowed_fields = {"is_banned", "ban_reason"}
updates = {k: v for k, v in data.items() if k in allowed_fields}
if not updates:
raise HTTPException(400, "Keine erlaubten Felder.")
with db() as conn:
target = conn.execute(
"SELECT id, rolle, name FROM users WHERE id=?", (uid,)
).fetchone()
if not target:
raise HTTPException(404, "User nicht gefunden.")
if target["rolle"] == "admin" and user["rolle"] != "admin":
raise HTTPException(403, "Admins können nur von Admins verwaltet werden.")
cols = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE users SET {cols} WHERE id=?",
[*updates.values(), uid]
)
row = conn.execute(
"SELECT id, name, rolle, is_banned, ban_reason FROM users WHERE id=?",
(uid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# GET /api/moderation/fotos — Wiki-Foto-Einreichungen (pending)
# ------------------------------------------------------------------
@router.get("/fotos")
async def mod_fotos(user=Depends(require_moderator)):
with db() as conn:
try:
rows = conn.execute("""
SELECT s.id, s.rasse_slug, s.foto_url, s.created_at,
u.name AS user_name,
r.name AS rasse_name, r.foto_url AS aktuell_foto
FROM wiki_foto_submissions s
LEFT JOIN users u ON u.id=s.user_id
LEFT JOIN wiki_rassen r ON r.slug=s.rasse_slug
WHERE s.status='pending'
ORDER BY s.created_at ASC
LIMIT 50
""").fetchall()
return [dict(r) for r in rows]
except Exception:
return []
# ------------------------------------------------------------------
# PATCH /api/moderation/fotos/{id} — Foto genehmigen / ablehnen
# ------------------------------------------------------------------
@router.patch("/fotos/{foto_id}")
async def mod_foto_action(foto_id: int, data: dict, user=Depends(require_moderator)):
action = data.get("action")
if action not in ("approve", "reject"):
raise HTTPException(400, "action muss 'approve' oder 'reject' sein.")
with db() as conn:
sub = conn.execute(
"SELECT id, rasse_slug, foto_url FROM wiki_foto_submissions WHERE id=?",
(foto_id,)
).fetchone()
if not sub:
raise HTTPException(404, "Einreichung nicht gefunden.")
if action == "approve":
conn.execute(
"UPDATE wiki_foto_submissions SET status='approved' WHERE id=?",
(foto_id,)
)
conn.execute(
"UPDATE wiki_rassen SET foto_url=? WHERE slug=?",
(sub["foto_url"], sub["rasse_slug"])
)
else:
reason = data.get("reject_reason", "Nicht geeignet.")
conn.execute(
"UPDATE wiki_foto_submissions SET status='rejected', reject_reason=? WHERE id=?",
(reason, foto_id)
)
return {"ok": True}

View file

@ -185,6 +185,11 @@
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Social Media <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Social Media
</div> </div>
<div class="sidebar-item" data-page="moderation" id="sidebar-moderation"
style="display:none;color:var(--c-warning,#f59e0b)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg> Moderation
</div>
<div class="sidebar-item" data-page="admin" id="sidebar-admin" <div class="sidebar-item" data-page="admin" id="sidebar-admin"
style="display:none;color:var(--c-danger,#ef4444)"> style="display:none;color:var(--c-danger,#ef4444)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg> Admin <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg> Admin
@ -338,6 +343,10 @@
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
<section class="page" id="page-moderation">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-friends"> <section class="page" id="page-friends">
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
@ -397,9 +406,9 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=93"></script> <script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=93"></script> <script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=93"></script> <script src="/js/app.js?v=94"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->

View file

@ -56,6 +56,7 @@ const App = (() => {
chat: { title: 'Nachrichten', module: null, requiresAuth: true }, chat: { title: 'Nachrichten', module: null, requiresAuth: true },
social: { title: 'Social Media', module: null, requiresAuth: true }, social: { title: 'Social Media', module: null, requiresAuth: true },
admin: { title: 'Admin', module: null, requiresAuth: true }, admin: { title: 'Admin', module: null, requiresAuth: true },
moderation: { title: 'Moderation', module: null, requiresAuth: true },
impressum: { title: 'Impressum', module: null }, impressum: { title: 'Impressum', module: null },
datenschutz: { title: 'Datenschutz', module: null }, datenschutz: { title: 'Datenschutz', module: null },
widget: { title: 'Widget', module: null, requiresAuth: true }, widget: { title: 'Widget', module: null, requiresAuth: true },
@ -422,6 +423,12 @@ const App = (() => {
|| state.user.is_moderator; || state.user.is_moderator;
adminItem.style.display = isMod ? '' : 'none'; adminItem.style.display = isMod ? '' : 'none';
} }
const moderationItem = document.getElementById('sidebar-moderation');
if (moderationItem) {
const isMod = state.user.rolle === 'admin' || state.user.rolle === 'moderator'
|| state.user.is_moderator;
moderationItem.style.display = isMod ? '' : 'none';
}
const socialItem = document.getElementById('sidebar-social'); const socialItem = document.getElementById('sidebar-social');
if (socialItem) { if (socialItem) {
const isSocial = state.user.is_social_media || state.user.rolle === 'admin'; const isSocial = state.user.is_social_media || state.user.rolle === 'admin';

View file

@ -0,0 +1,447 @@
/* ============================================================
BAN YARO Moderations-Panel
Nur für Moderatoren und Admins.
============================================================ */
window.Page_moderation = (() => {
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'fotos', label: 'Fotos', icon: 'image' },
{ id: 'user', label: 'User', icon: 'users' },
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
];
// ------------------------------------------------------------------
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 Moderatoren und Admins.');
return;
}
_render();
}
function refresh() { _renderTab(); }
function onDogChange() {}
// ------------------------------------------------------------------
// SHELL
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="by-tabs adm-tabs" id="mod-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>
<div id="mod-content"></div>
`;
_container.querySelector('#mod-tabs')
?.style.setProperty('--adm-tab-cols', Math.ceil(TABS.length / 2));
_container.querySelectorAll('#mod-tabs .by-tab').forEach(btn => {
btn.addEventListener('click', () => {
_tab = btn.dataset.tab;
_container.querySelectorAll('#mod-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
});
});
_renderTab();
}
async function _renderTab() {
const el = _container.querySelector('#mod-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 'fotos': await _renderFotos(el); break;
case 'user': await _renderUsers(el); break;
case 'forum': await _renderForum(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
}
}
// ------------------------------------------------------------------
// TAB: ÜBERSICHT
// ------------------------------------------------------------------
async function _renderStats(el) {
const s = await API.get('/moderation/stats');
el.innerHTML = `
<div class="adm-stats-grid">
${_statCard('warning',
'Offene Meldungen',
s.open_reports,
s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')}
${_statCard('image',
'Fotos ausstehend',
s.pending_fotos,
s.pending_fotos > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
${_statCard('skull',
'Gesperrte User',
s.banned_users,
s.banned_users > 0 ? '#f59e0b' : 'var(--c-text-muted)')}
${_statCard('storefront',
'Züchter ausstehend',
s.pending_zuchter,
s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
</div>
<div class="card" style="padding:var(--space-4);margin-top:var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.6">
${UI.icon('info')}
Das Moderations-Panel zeigt dir alle ausstehenden Aufgaben auf einen Blick.
Verwende die Tabs oben für Details zu Fotos, Usern und Forum-Meldungen.
</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: FOTOS
// ------------------------------------------------------------------
async function _renderFotos(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="mod-fotos-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="mod-fotos-list">Lade</div>
`;
el.querySelector('#mod-fotos-refresh').addEventListener('click', () =>
_loadFotos(el.querySelector('#mod-fotos-list'))
);
await _loadFotos(el.querySelector('#mod-fotos-list'));
}
async function _loadFotos(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;
color:var(--c-text-muted)">Lade</div>`;
const fotos = await API.get('/moderation/fotos');
if (!fotos.length) {
el.innerHTML = _emptyState('check-circle', 'Keine ausstehenden Fotos',
'Alle Foto-Einreichungen wurden bearbeitet.');
return;
}
el.innerHTML = `
<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 || f.rasse_slug)}
</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 mod-foto-approve"
data-id="${f.id}" style="flex:1">Freigeben</button>
<button class="btn btn-sm btn-ghost mod-foto-reject"
data-id="${f.id}" style="color:var(--c-danger)">Ablehnen</button>
</div>
</div>
`).join('')}
</div>
`;
el.querySelectorAll('.mod-foto-approve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/fotos/${btn.dataset.id}`, { action: 'approve' });
UI.toast.success('Foto freigegeben.');
await _loadFotos(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
el.querySelectorAll('.mod-foto-reject').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/fotos/${btn.dataset.id}`, {
action: 'reject',
reject_reason: 'Nicht geeignet.'
});
UI.toast.success('Foto abgelehnt.');
await _loadFotos(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
}
// ------------------------------------------------------------------
// TAB: USER
// ------------------------------------------------------------------
async function _renderUsers(el) {
el.innerHTML = `
<div class="adm-filter-row">
<input id="mod-user-q" type="search" placeholder="Name oder E-Mail…"
class="adm-filter-input">
<label style="display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-secondary)">
<input type="checkbox" id="mod-only-banned"> Nur gesperrte
</label>
</div>
<div id="mod-user-list">Lade</div>
`;
const load = async () => {
const q = el.querySelector('#mod-user-q').value;
const banned = el.querySelector('#mod-only-banned').checked ? 1 : 0;
const data = await API.get(
`/moderation/users?q=${encodeURIComponent(q)}&banned=${banned}`
);
_renderUserList(el.querySelector('#mod-user-list'), data.users, data.total, el);
};
let timer;
el.querySelector('#mod-user-q').addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(load, 350);
});
el.querySelector('#mod-only-banned').addEventListener('change', load);
await load();
}
function _renderUserList(el, users, total, parentEl) {
if (!users.length) {
el.innerHTML = _emptyState('users', 'Keine Nutzer gefunden', '');
return;
}
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)">
<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>
<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>
</div>
</div>
<div style="flex-shrink:0">
${u.is_banned
? `<button class="btn btn-sm btn-ghost mod-unban"
data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperre aufheben" style="color:var(--c-success)">
${UI.icon('lock-open')}
</button>`
: `<button class="btn btn-sm btn-ghost mod-ban"
data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperren" style="color:var(--c-danger)">
${UI.icon('lock')}
</button>`
}
</div>
</div>
</div>
`).join('')}
</div>
`;
el.querySelectorAll('.mod-ban').forEach(btn => {
btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, true, parentEl));
});
el.querySelectorAll('.mod-unban').forEach(btn => {
btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, false, parentEl));
});
}
async function _banUser(uid, name, ban, parentEl) {
if (ban) {
const reason = window.prompt(`${name} sperren — Grund (optional):`);
if (reason === null) return;
try {
await API.patch(`/moderation/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(`/moderation/users/${uid}`, {
is_banned: 0,
ban_reason: null
});
UI.toast.success(`Sperre für ${name} aufgehoben.`);
_renderTab();
} catch (e) { UI.toast.error(e.message); }
}
}
// ------------------------------------------------------------------
// TAB: FORUM
// ------------------------------------------------------------------
async function _renderForum(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="mod-forum-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="mod-forum-list">Lade</div>
`;
el.querySelector('#mod-forum-refresh').addEventListener('click', () =>
_loadReports(el.querySelector('#mod-forum-list'))
);
await _loadReports(el.querySelector('#mod-forum-list'));
}
async function _loadReports(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;
color:var(--c-text-muted)">Lade</div>`;
const reports = await API.get('/moderation/reports');
if (!reports.length) {
el.innerHTML = _emptyState('check-circle', '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);
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)">
${_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>
<button class="btn btn-sm btn-primary mod-resolve-btn"
data-rid="${r.id}" title="Als erledigt markieren">
${UI.icon('check')}
</button>
</div>
</div>
`).join('')}
</div>
`;
el.querySelectorAll('.mod-resolve-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/reports/${btn.dataset.rid}`, {});
UI.toast.success('Meldung als erledigt markiert.');
await _loadReports(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
}
// ------------------------------------------------------------------
// HELPERS
// ------------------------------------------------------------------
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 };
})();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v356'; const CACHE_VERSION = 'by-v357';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten