Carl-Bot-Paket: Moderation, Auto-/Sticky-Roles, Level-System, Tags, geplante Posts

- Moderation: /warn (DM + Mod-Log), /warns (Historie + löschen), /timeout (max 28d),
  /purge (bulk, 14-Tage-Limit sauber gemeldet); Mod-Log erweitert um Join/Leave
  und Nick-/Rollen-Änderungen
- Auto-Rolle bei Join + Sticky-Roles (Rollen bei Leave gesichert, bei Rejoin
  wiederhergestellt; managed/@everyone ausgenommen) — Rollen-Tab
- Level-System (opt-in): 15-25 XP/Nachricht mit 60s-Cooldown, MEE6-Formel,
  Level-Up-Announce, Rollen-Belohnungen (Level=RolleID), /rank mit Fortschritt,
  öffentliche Bestenliste /level; Community-Tab
- Tags: /tag mit Autocomplete, Verwaltung im Composer-Tab (/api/tags)
- Geplante Posts: einmalig/täglich/wöchentlich, Minuten-Scheduler, DST-sicher
  über Neuberechnung aus Zeitfeldern; Composer-Tab (/api/scheduled)
- Autocomplete-Dispatch im InteractionCreate; alles smoke-getestet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:30:23 +02:00
co-authored by Claude Fable 5
parent a27a594a07
commit db9c7d07c9
19 changed files with 1066 additions and 5 deletions
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
/** Muss der Formel in src/bot/levels.js entsprechen */
function xpForLevel(level) {
let total = 0;
for (let n = 0; n < level; n++) total += 5 * n * n + 50 * n + 100;
return total;
}
const MEDALS = ['🥇', '🥈', '🥉'];
export default function Level() {
const [rows, setRows] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
apiGet('/api/levels').then((d) => setRows(d.levels)).catch(() => setError(true));
window.scrollTo(0, 0);
}, []);
return (
<>
<section className="page-header">
<div className="page-bg-text">LEVEL</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Bestenliste</div>
<h1 className="page-title">Level</h1>
<p className="page-subtitle">
XP gibt's fürs Mitreden im Discord — hier stehen die Aktivsten. <code>/rank</code> zeigt deinen Platz.
</p>
</div>
</section>
<section className="content">
{error && <p className="notice">Bestenliste konnte nicht geladen werden.</p>}
{!error && !rows && <p className="notice">Lade …</p>}
{rows?.length === 0 && <p className="notice">Noch keine XP vergeben — schreibt was im Discord! ✍️</p>}
{rows?.length > 0 && (
<div className="lvl-list">
{rows.map((r, i) => {
const base = xpForLevel(r.level);
const next = xpForLevel(r.level + 1);
const pct = Math.floor(((r.xp - base) / (next - base)) * 100);
return (
<div className="lvl-row" key={r.user_id}>
<span className="lvl-rank">{MEDALS[i] ?? `#${i + 1}`}</span>
<span className="lvl-name">{r.username ?? r.user_id}</span>
<div className="lvl-progress">
<div className="progress"><div className="progress-bar" style={{ width: `${pct}%` }} /></div>
</div>
<span className="lvl-level">Lv. {r.level}</span>
<span className="lvl-xp">{r.xp.toLocaleString('de-DE')} XP</span>
</div>
);
})}
</div>
)}
</section>
</>
);
}