diff --git a/README.md b/README.md index a2ccd7d..b12bde8 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche `/galerie` (lokal gespeichert); Historie via `/galerie-backfill` | | 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück | | 🎫 **Tickets** | `/ticket-setup` postet den Button; Klick öffnet einen privaten Thread (ein offenes Ticket pro User, Schließen-Button) | +| 🎭 **Rollen-Menüs** | Carl-Bot-Ersatz: Menüs im Webinterface bauen (Emoji, Label, Rolle, optional exklusiv), Bot postet Button-Embeds; Klick = Rolle nehmen/abgeben, Posts jederzeit editierbar | | 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) | | 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal | | 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence | @@ -51,8 +52,9 @@ Design: D4RKST3R-Brand (Neon-Gelb/Orange auf Schwarz, Bebas Neue + Barlow Conden Share Tech Mono, selbst gehostet). ### Setup-Seite (`/settings`) -Alles zur Laufzeit änderbar — gespeichert in SQLite, Env-Variablen sind nur Fallback, -kein Redeploy nötig: +Aufgeteilt in **Tabs mit Sidebar-Navigation** (Status · Feeds · Community · Rollen · +Support · Composer · System · API). Alles zur Laufzeit änderbar — gespeichert in +SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig: - **Allgemein:** Öffentliche URL, Gitea-URL - **Kanäle** (Devlog / Commit / Release) als Dropdown + „Test senden"-Button je Kanal @@ -237,6 +239,7 @@ urllib.request.urlopen(req) | `GET /api/playtesters` | Admin | Playtester-Liste | | `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben | | `GET/POST/DELETE /api/apikeys` | Admin | API-Key-Verwaltung | +| `GET/POST/PUT/DELETE /api/rolemenus`, `POST /api/rolemenus/:id/publish` | Admin | Rollen-Menüs | | `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite | | `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen | | `GET /auth/login` · `/auth/callback` · `/auth/logout` | — | Discord-OAuth2 | diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index b80a618..b6a725c 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -8,6 +8,17 @@ const API_SCOPES = [ { id: 'read', label: 'read', hint: 'Member-Infos & Statistiken lesen' }, ]; +const TABS = [ + { id: 'status', icon: '📊', label: 'Status' }, + { id: 'feeds', icon: '📔', label: 'Feeds' }, + { id: 'community', icon: '🙌', label: 'Community' }, + { id: 'rollen', icon: '🎭', label: 'Rollen' }, + { id: 'support', icon: '📬', label: 'Support' }, + { id: 'composer', icon: '📝', label: 'Composer' }, + { id: 'system', icon: '⚙️', label: 'System' }, + { id: 'api', icon: '🔑', label: 'API' }, +]; + /** Sekunden → "2d 4h 13m" */ function fmtUptime(s) { const d = Math.floor(s / 86400); @@ -22,12 +33,20 @@ function fmtBytes(b) { } export default function Settings({ me }) { + const [tab, setTab] = useState('status'); const [data, setData] = useState(null); const [form, setForm] = useState(null); const [error, setError] = useState(null); const [feedback, setFeedback] = useState(null); // Community const [playtesters, setPlaytesters] = useState([]); + // Rollen-Menüs + const emptyMenu = { + id: null, title: '', description: '', channel_id: '', exclusive: false, + entries: [{ emoji: '', label: '', role_id: '' }], + }; + const [menus, setMenus] = useState([]); + const [menuDraft, setMenuDraft] = useState(emptyMenu); // Composer const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' }); // API-Keys @@ -46,8 +65,33 @@ export default function Settings({ me }) { .catch((e) => setError(e.status)); 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(() => {}); }, [me.admin]); + function flash(msg) { + setFeedback(msg); + setTimeout(() => setFeedback(null), 3000); + } + + async function save() { + try { + const res = await apiPut('/api/settings', form); + setForm(res.settings); + flash('✓ Gespeichert'); + } catch { + flash('✗ Speichern fehlgeschlagen'); + } + } + + async function sendTest(target) { + try { + await apiPost(`/api/settings/test/${target}`); + flash('✓ Test-Nachricht gesendet'); + } catch { + flash('✗ Test fehlgeschlagen — Kanal/Rechte prüfen'); + } + } + async function createKey() { if (!newKeyName.trim() || newKeyScopes.length === 0) return; try { @@ -67,21 +111,6 @@ export default function Settings({ me }) { if (createdKey?.id === id) setCreatedKey(null); } - function flash(msg) { - setFeedback(msg); - setTimeout(() => setFeedback(null), 3000); - } - - async function save() { - try { - const res = await apiPut('/api/settings', form); - setForm(res.settings); - flash('✓ Gespeichert'); - } catch { - flash('✗ Speichern fehlgeschlagen'); - } - } - async function sendCompose() { if (!composer.channel || !composer.text.trim()) { flash('✗ Kanal und Text nötig'); @@ -101,15 +130,57 @@ export default function Settings({ me }) { } } - async function sendTest(target) { + const reloadMenus = async () => setMenus((await apiGet('/api/rolemenus')).menus); + + async function saveMenu(publish = false) { + const entries = menuDraft.entries.filter((e) => e.label.trim() && e.role_id); + if (!menuDraft.title.trim() || entries.length === 0) { + flash('✗ Titel und mindestens eine Rolle nötig'); + return; + } + if (publish && !menuDraft.channel_id) { + flash('✗ Zum Posten einen Kanal wählen'); + return; + } try { - await apiPost(`/api/settings/test/${target}`); - flash('✓ Test-Nachricht gesendet'); + const body = { ...menuDraft, entries }; + const res = menuDraft.id + ? await apiPut(`/api/rolemenus/${menuDraft.id}`, body) + : await apiPost('/api/rolemenus', body); + let menu = res.menu; + if (publish) { + await apiPost(`/api/rolemenus/${menu.id}/publish`); + flash('✓ Menü in Discord gepostet'); + } else { + flash(res.published ? '✓ Gespeichert + Discord-Post aktualisiert' : '✓ Gespeichert'); + } + await reloadMenus(); + setMenuDraft(emptyMenu); } catch { - flash('✗ Test fehlgeschlagen — Kanal/Rechte prüfen'); + flash('✗ Speichern fehlgeschlagen — Einträge prüfen'); } } + async function removeMenu(id) { + if (!window.confirm('Menü löschen? Der Discord-Post wird mit entfernt.')) return; + await apiDelete(`/api/rolemenus/${id}`).catch(() => {}); + if (menuDraft.id === id) setMenuDraft(emptyMenu); + await reloadMenus(); + } + + const editMenu = (m) => + setMenuDraft({ + id: m.id, title: m.title, description: m.description, + channel_id: m.channel_id ?? '', exclusive: m.exclusive, + entries: m.entries.length ? m.entries : [{ emoji: '', label: '', role_id: '' }], + }); + + const setMenuEntry = (i, patch) => + setMenuDraft({ + ...menuDraft, + entries: menuDraft.entries.map((e, j) => (j === i ? { ...e, ...patch } : e)), + }); + const header = (
SETUP
@@ -117,7 +188,7 @@ export default function Settings({ me }) {
// Admin

Setup

-

Kanäle, Feed-Regeln und Status — Änderungen greifen sofort.

+

Alles an einem Ort — Änderungen greifen sofort.

); @@ -140,494 +211,478 @@ export default function Settings({ me }) { if (!data || !form) return <>{header}

Lade …

; const channelOptions = data.channels.map((c) => ( - + )); + const roleOptions = (data.roles ?? []).map((r) => ( + + )); + const channelName = (id) => { + const c = data.channels.find((x) => x.id === id); + return c ? `#${c.name}` : id; + }; + + /* ── Tab-Inhalte ───────────────────────────────── */ + + const tabStatus = ( +
+

// Status

+
+
{data.status.botTag ?? '—'}Bot
+
{fmtUptime(data.status.uptimeSeconds)}Uptime
+
{data.status.devlogs}Devlogs
+
{data.status.commits}Commits
+
{fmtBytes(data.status.dbSizeBytes)}Datenbank
+
+ {data.status.lastBackup ? data.status.lastBackup.slice(0, 10) : '—'} + Letztes Backup +
+
+
+ ); + + const tabFeeds = ( + <> +
+

// Kanäle

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+

// Devlog

+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+

// Commit-Feed

+
+ +
+
+ + setForm({ ...form, commit_branch_filter: e.target.value })} /> +
+
+ + setForm({ ...form, ignored_repos: e.target.value })} /> +
+
+ + ); + + const tabCommunity = ( + <> +
+

// Playtester

+
+ + +
+ {playtesters.length > 0 && ( +
+ {playtesters.map((p) => ( +
+ {p.username} + seit {p.applied_at.slice(0, 10)} +
+ ))} +
+ )} +
+ +
+

// Starboard & Galerie

+
+ +
+ + setForm({ ...form, starboard_threshold: e.target.value })} /> +
+
+
+ + +
+
+ + +
+
+ + ); + + const tabRollen = ( + <> +
+

// Rollen-Menüs

+

+ Selbstbedienungs-Rollen wie bei Carl-bot — nur ohne Paywall. 😄 Der Bot postet + ein Embed mit Buttons; Klick = Rolle nehmen, nochmal = abgeben. Menüs sind + jederzeit editierbar, der Discord-Post zieht automatisch mit. +

+ + {menus.length > 0 && ( +
+ {menus.map((m) => ( +
+ {m.title} + + {m.channel_id ? channelName(m.channel_id) : '— kein Kanal —'} + {m.exclusive ? ' · exklusiv' : ''} + + + {m.entries.length} Rollen · {m.message_id ? 'gepostet' : 'Entwurf'} + + + +
+ ))} +
+ )} + +
+ +
+ setMenuDraft({ ...menuDraft, title: e.target.value })} /> + +
+
+
+ + setMenuDraft({ ...menuDraft, description: e.target.value })} /> +
+
+ +
+ +
+ + {menuDraft.entries.map((entry, i) => ( +
+ setMenuEntry(i, { emoji: e.target.value })} /> + setMenuEntry(i, { label: e.target.value })} /> + + +
+ ))} +
+ + + + {menuDraft.id && ( + + )} +
+
+
+ + ); + + const tabSupport = ( +
+

// Moderation & Kontakt

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + + const tabComposer = ( +
+

// Post verfassen

+
+ + +
+
+ + setComposer({ ...composer, title: e.target.value })} /> +
+
+ +