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:
@@ -5,6 +5,7 @@ import Devlogs from './pages/Devlogs.jsx';
|
||||
import Changelog from './pages/Changelog.jsx';
|
||||
import Roadmap from './pages/Roadmap.jsx';
|
||||
import Galerie from './pages/Galerie.jsx';
|
||||
import Level from './pages/Level.jsx';
|
||||
import Commits from './pages/Commits.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
|
||||
@@ -74,6 +75,7 @@ export default function App() {
|
||||
<NavLink to="/devlogs">Devlog</NavLink>
|
||||
<NavLink to="/roadmap">Roadmap</NavLink>
|
||||
<NavLink to="/galerie">Galerie</NavLink>
|
||||
<NavLink to="/level">Level</NavLink>
|
||||
<NavLink to="/changelog">Changelog</NavLink>
|
||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
||||
@@ -100,6 +102,7 @@ export default function App() {
|
||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||
<Route path="/roadmap" element={<Roadmap />} />
|
||||
<Route path="/galerie" element={<Galerie />} />
|
||||
<Route path="/level" element={<Level />} />
|
||||
<Route path="/changelog" element={<Changelog />} />
|
||||
<Route path="/commits" element={<Commits me={me} />} />
|
||||
<Route path="/settings" element={<Settings me={me} />} />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,14 @@ export default function Settings({ me }) {
|
||||
const [menuDraft, setMenuDraft] = useState(emptyMenu);
|
||||
// Composer
|
||||
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
|
||||
// Tags + geplante Posts
|
||||
const [tags, setTags] = useState([]);
|
||||
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
|
||||
const [scheduled, setScheduled] = useState([]);
|
||||
const [schedDraft, setSchedDraft] = useState({
|
||||
channel_id: '', embed_title: '', content: '', type: 'once',
|
||||
date: '', time: '18:00', weekday: '5',
|
||||
});
|
||||
// API-Keys
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
@@ -66,8 +74,51 @@ export default function Settings({ me }) {
|
||||
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
|
||||
apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {});
|
||||
apiGet('/api/rolemenus').then((d) => setMenus(d.menus)).catch(() => {});
|
||||
apiGet('/api/tags').then((d) => setTags(d.tags)).catch(() => {});
|
||||
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
|
||||
}, [me.admin]);
|
||||
|
||||
async function saveTagDraft() {
|
||||
if (!tagDraft.name.trim() || !tagDraft.content.trim()) {
|
||||
flash('✗ Name und Inhalt nötig');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiPut(`/api/tags/${encodeURIComponent(tagDraft.name.trim().toLowerCase())}`, { content: tagDraft.content });
|
||||
setTags((await apiGet('/api/tags')).tags);
|
||||
setTagDraft({ name: '', content: '' });
|
||||
flash('✓ Tag gespeichert');
|
||||
} catch {
|
||||
flash('✗ Tag fehlgeschlagen (Name: a-z, 0-9, -, _)');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTag(name) {
|
||||
if (!window.confirm(`Tag „${name}" löschen?`)) return;
|
||||
await apiDelete(`/api/tags/${encodeURIComponent(name)}`).catch(() => {});
|
||||
setTags((await apiGet('/api/tags')).tags);
|
||||
}
|
||||
|
||||
async function createSchedule() {
|
||||
if (!schedDraft.channel_id || !schedDraft.content.trim()) {
|
||||
flash('✗ Kanal und Text nötig');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiPost('/api/scheduled', schedDraft);
|
||||
setScheduled((await apiGet('/api/scheduled')).posts);
|
||||
setSchedDraft({ ...schedDraft, content: '', embed_title: '' });
|
||||
flash('✓ Post geplant');
|
||||
} catch {
|
||||
flash('✗ Planung fehlgeschlagen — Datum/Zeit prüfen');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSchedule(id) {
|
||||
await apiDelete(`/api/scheduled/${id}`).catch(() => {});
|
||||
setScheduled((await apiGet('/api/scheduled')).posts);
|
||||
}
|
||||
|
||||
function flash(msg) {
|
||||
setFeedback(msg);
|
||||
setTimeout(() => setFeedback(null), 3000);
|
||||
@@ -375,11 +426,54 @@ export default function Settings({ me }) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Level-System</h2>
|
||||
<div className="field">
|
||||
<label className="toggle">
|
||||
<input type="checkbox" checked={form.levels_enabled} onChange={(e) => setForm({ ...form, levels_enabled: e.target.checked })} />
|
||||
<span>XP fürs Schreiben vergeben</span>
|
||||
<span className="field-hint">15–25 XP pro Nachricht, 60s Cooldown; /rank + Bestenliste auf /level</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="toggle">
|
||||
<input type="checkbox" checked={form.levels_announce} onChange={(e) => setForm({ ...form, levels_announce: e.target.checked })} />
|
||||
<span>Level-Up im Kanal feiern</span>
|
||||
<span className="field-hint">„🎉 Name ist jetzt Level 5!"</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Rollen-Belohnungen <span className="field-hint">Format: Level=Rollen-ID, kommagetrennt — z. B. 5=1234, 10=5678</span></label>
|
||||
<input type="text" placeholder="5=RollenID, 10=RollenID" value={form.level_rewards} onChange={(e) => setForm({ ...form, level_rewards: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const tabRollen = (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Rollen bei Join</h2>
|
||||
<div className="field">
|
||||
<label>Auto-Rolle <span className="field-hint">bekommt jeder neue Member automatisch — leer = aus</span></label>
|
||||
<select value={form.autorole_id ?? ''} onChange={(e) => setForm({ ...form, autorole_id: e.target.value })}>
|
||||
<option value="">— deaktiviert —</option>
|
||||
{roleOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="toggle">
|
||||
<input type="checkbox" checked={form.sticky_roles_enabled} onChange={(e) => setForm({ ...form, sticky_roles_enabled: e.target.checked })} />
|
||||
<span>Sticky-Roles</span>
|
||||
<span className="field-hint">wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-actions">
|
||||
<button className="btn btn-save" onClick={save}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Rollen-Menüs</h2>
|
||||
<p className="section-intro">
|
||||
@@ -497,6 +591,7 @@ export default function Settings({ me }) {
|
||||
);
|
||||
|
||||
const tabComposer = (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Post verfassen</h2>
|
||||
<div className="field">
|
||||
@@ -524,6 +619,90 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Geplante Posts</h2>
|
||||
{scheduled.length > 0 && (
|
||||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||
{scheduled.map((p) => (
|
||||
<div className="key-row" key={p.id}>
|
||||
<span className="key-name">{channelName(p.channel_id)}</span>
|
||||
<span className="key-scopes">
|
||||
{{ once: 'einmalig', daily: 'täglich', weekly: 'wöchentlich' }[p.type]} {p.time}
|
||||
{p.enabled ? '' : ' · erledigt'}
|
||||
</span>
|
||||
<span className="key-used" title={p.content}>{p.content.slice(0, 40)}…</span>
|
||||
<button className="card-delete" title="Löschen" onClick={() => removeSchedule(p.id)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label>Neuer geplanter Post</label>
|
||||
<div className="field-row">
|
||||
<select value={schedDraft.channel_id} onChange={(e) => setSchedDraft({ ...schedDraft, channel_id: e.target.value })}>
|
||||
<option value="" disabled>— Kanal wählen —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
<select style={{ flex: 'none', width: '9rem' }} value={schedDraft.type} onChange={(e) => setSchedDraft({ ...schedDraft, type: e.target.value })}>
|
||||
<option value="once">einmalig</option>
|
||||
<option value="daily">täglich</option>
|
||||
<option value="weekly">wöchentlich</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="field-row">
|
||||
{schedDraft.type === 'once' && (
|
||||
<input type="date" value={schedDraft.date} onChange={(e) => setSchedDraft({ ...schedDraft, date: e.target.value })} />
|
||||
)}
|
||||
{schedDraft.type === 'weekly' && (
|
||||
<select value={schedDraft.weekday} onChange={(e) => setSchedDraft({ ...schedDraft, weekday: e.target.value })}>
|
||||
{['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'].map((d, i) => (
|
||||
<option key={i} value={String(i)}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input type="time" value={schedDraft.time} onChange={(e) => setSchedDraft({ ...schedDraft, time: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<input type="text" placeholder="Embed-Titel (leer = normale Nachricht)" value={schedDraft.embed_title} onChange={(e) => setSchedDraft({ ...schedDraft, embed_title: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<textarea rows={4} placeholder="Text (Discord-Markdown)" value={schedDraft.content} onChange={(e) => setSchedDraft({ ...schedDraft, content: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<button className="btn btn-save" onClick={createSchedule}>Planen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Tags (/tag)</h2>
|
||||
<p className="section-intro">
|
||||
Text-Bausteine für wiederkehrende Antworten — jeder Member kann sie mit <code>/tag name</code> abrufen.
|
||||
</p>
|
||||
{tags.length > 0 && (
|
||||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||
{tags.map((t) => (
|
||||
<div className="key-row" key={t.name}>
|
||||
<span className="key-name">{t.name}</span>
|
||||
<span className="key-used" title={t.content}>{t.content.slice(0, 50)}</span>
|
||||
<button className="btn btn-mini" onClick={() => setTagDraft({ name: t.name, content: t.content })}>Bearbeiten</button>
|
||||
<button className="card-delete" title="Tag löschen" onClick={() => removeTag(t.name)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<div className="field-row">
|
||||
<input type="text" style={{ flex: 'none', width: '12rem' }} placeholder="name (z. B. server-ip)" value={tagDraft.name} onChange={(e) => setTagDraft({ ...tagDraft, name: e.target.value })} />
|
||||
<input type="text" placeholder="Inhalt des Tags" value={tagDraft.content} onChange={(e) => setTagDraft({ ...tagDraft, content: e.target.value })} />
|
||||
<button className="btn" onClick={saveTagDraft}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const tabSystem = (
|
||||
|
||||
@@ -881,3 +881,42 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
}
|
||||
.toggle input { border-radius: 99px; }
|
||||
.toggle input::after { border-radius: 50%; }
|
||||
|
||||
/* ── LEVEL-BESTENLISTE ─────────────────────────────── */
|
||||
.lvl-list { border: 1px solid rgba(255, 255, 255, .06); background: var(--bg2); border-radius: 12px; }
|
||||
.lvl-row {
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
padding: .65rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
||||
}
|
||||
.lvl-row:last-child { border-bottom: none; }
|
||||
.lvl-row:first-child { border-radius: 12px 12px 0 0; }
|
||||
.lvl-rank { font-family: var(--mono); font-size: .85rem; color: var(--muted); min-width: 2.6rem; }
|
||||
.lvl-name { font-weight: 600; min-width: 10rem; }
|
||||
.lvl-progress { flex: 1; }
|
||||
.lvl-level {
|
||||
font-family: var(--mono); font-size: .72rem; letter-spacing: .1em;
|
||||
color: var(--neon); white-space: nowrap;
|
||||
}
|
||||
.lvl-xp { font-family: var(--mono); font-size: .68rem; color: var(--muted2); white-space: nowrap; min-width: 6rem; text-align: right; }
|
||||
@media (max-width: 640px) {
|
||||
.lvl-name { min-width: 0; flex: 1; }
|
||||
.lvl-progress { display: none; }
|
||||
}
|
||||
|
||||
/* Datums-/Zeit-Inputs wie Text-Inputs stylen */
|
||||
.field input[type="date"], .field input[type="time"] {
|
||||
background: rgba(0, 0, 0, .35);
|
||||
border: 1px solid rgba(255, 255, 255, .09);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: var(--body);
|
||||
font-size: .95rem;
|
||||
padding: .5rem .7rem;
|
||||
outline: none;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.field input[type="date"]:focus, .field input[type="time"]:focus {
|
||||
border-color: var(--neon);
|
||||
box-shadow: 0 0 0 3px rgba(245, 197, 24, .12);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user