diff --git a/README.md b/README.md index 667c735..f6da1c5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,15 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | 📈 **Level-System** | XP pro Nachricht (60s-Cooldown, MEE6-Formel), `/rank`, Level-Up-Announce, Rollen-Belohnungen, öffentliche Bestenliste auf `/level` | | 🏷️ **Tags** | `/tag ` (mit Autocomplete) postet gespeicherte Text-Bausteine — verwaltet im Composer-Tab | | ⏰ **Geplante Posts** | Einmalig, täglich oder wöchentlich — im Composer-Tab geplant, vom Bot gepostet (bei Carl Premium) | -| 🎨 **Branding** | Brand-Tab: Name + Embed-Farben für alle Bot-Posts, Bot-Status (Presence), Avatar-/Banner-Upload, Guild-ID & Gitea-Token ohne Env-Redeploy | +| 🎨 **Branding** | Brand-Tab mit Bot-Identity-Karte (Avatar-Vorschau, Name, Status online/idle/dnd, Aktivität), Embed-Farben, Banner-Upload, Guild-ID & Gitea-Token ohne Env-Redeploy | +| 🎟️ **Alpha-Keys** | Key-Pool im Community-Tab; Ein-Klick-Verteilung an alle Playtester per DM | +| 📋 **Bewerbungen** | Formulare (bis 5 Fragen) als Discord-Modal, Review mit ✅/❌ im Staff-Kanal, Rolle + DM bei Annahme — z. B. FiveM-Whitelist | +| 📅 **Events** | Neue Discord-Events werden angekündigt + öffentliche `/events`-Seite | +| 💬 **Triggers** | Auto-Antworten auf Schlüsselwörter (30s-Cooldown), verwaltet im Composer-Tab | +| ⏰ **/remind** | Erinnerungen per DM (`/remind dauer:2h text:…`) | +| 📣 **Twitch/YouTube** | 🔴-Live- und ▶️-Neues-Video-Announcements (YouTube ohne Key via RSS, Twitch mit App-Credentials) | +| 🔊 **Temp-Voice** | „Join to Create": Hub-Kanal betreten → eigener Voice-Kanal, löscht sich wenn leer | +| 📊 **Server-Stats** | Nachrichten/Joins/Leaves pro Tag → Aktivitäts-Chart auf der Level-Seite | | 👋 **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 | @@ -48,7 +56,8 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St | `/devlogs` | öffentlich | Devlog-Archiv: Timeline, Bildergalerien, Projekt-Chips, **Volltextsuche** | | `/roadmap` | öffentlich | Meilensteine aus Gitea mit Fortschrittsbalken | | `/galerie` | öffentlich | Community-Screenshots aus dem Discord | -| `/level` | öffentlich | XP-Bestenliste mit Fortschrittsbalken | +| `/level` | öffentlich | XP-Bestenliste + Server-Aktivitäts-Chart | +| `/events` | öffentlich | Discord-Events (Playtests, Streams …) | | `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips | | `/feed.xml` | öffentlich | RSS-Feed der Devlogs | | `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) | diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 748ca20..3206094 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ 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 Events from './pages/Events.jsx'; import Commits from './pages/Commits.jsx'; import Settings from './pages/Settings.jsx'; @@ -76,6 +77,7 @@ export default function App() { Roadmap Galerie Level + Events Changelog {me.admin && Commits} {me.admin && Setup} @@ -103,6 +105,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Events.jsx b/frontend/src/pages/Events.jsx new file mode 100644 index 0000000..4448d49 --- /dev/null +++ b/frontend/src/pages/Events.jsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; +import { apiGet } from '../api.js'; +import { Markdown } from '../markdown.jsx'; + +const dateFmt = new Intl.DateTimeFormat('de-DE', { + weekday: 'long', day: '2-digit', month: 'long', hour: '2-digit', minute: '2-digit', +}); + +export default function Events() { + const [data, setData] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + apiGet('/api/events').then(setData).catch(() => setError(true)); + window.scrollTo(0, 0); + }, []); + + return ( + <> +
+
EVENTS
+
+ +
+ {error &&

Events konnten nicht geladen werden.

} + {!error && !data &&

Lade …

} + {data?.events.length === 0 &&

Gerade keine geplanten Events — schau bald wieder rein!

} + + {data?.events.map((e) => ( +
+ {e.cover && } +
+ {e.start ? dateFmt.format(new Date(e.start)) : 'Termin folgt'} + {e.interested != null && ⭐ {e.interested} interessiert} +
+

{e.name}

+ {e.description && ( +
+ +
+ )} + +
+ ))} +
+ + ); +} diff --git a/frontend/src/pages/Level.jsx b/frontend/src/pages/Level.jsx index 9086d29..4875382 100644 --- a/frontend/src/pages/Level.jsx +++ b/frontend/src/pages/Level.jsx @@ -10,12 +10,41 @@ function xpForLevel(level) { const MEDALS = ['🥇', '🥈', '🥉']; +/** Nachrichten/Tag der letzten 30 Tage als Balken */ +function ActivityChart({ stats }) { + const byDay = new Map(stats.days.map((d) => [d.day, d])); + const days = []; + for (let i = 29; i >= 0; i--) { + const key = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10); + days.push({ key, ...(byDay.get(key) ?? { messages: 0, joins: 0, leaves: 0 }) }); + } + const max = Math.max(1, ...days.map((d) => d.messages)); + const total = days.reduce((s, d) => s + d.messages, 0); + return ( +
+

// Server-Aktivität — {total.toLocaleString('de-DE')} Nachrichten in 30 Tagen · {stats.members} Member

+
+ {days.map((d) => ( + + ))} +
+
+ ); +} + export default function Level() { const [rows, setRows] = useState(null); + const [stats, setStats] = useState(null); const [error, setError] = useState(false); useEffect(() => { apiGet('/api/levels').then((d) => setRows(d.levels)).catch(() => setError(true)); + apiGet('/api/serverstats').then(setStats).catch(() => {}); window.scrollTo(0, 0); }, []); @@ -58,6 +87,8 @@ export default function Level() { })} )} + + {stats && } ); diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 46bb722..af480ec 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -15,6 +15,7 @@ const TABS = [ { id: 'community', icon: '🙌', label: 'Community' }, { id: 'rollen', icon: '🎭', label: 'Rollen' }, { id: 'support', icon: '📬', label: 'Support' }, + { id: 'bewerbungen', icon: '📋', label: 'Bewerbungen' }, { id: 'composer', icon: '📝', label: 'Composer' }, { id: 'system', icon: '⚙️', label: 'System' }, { id: 'api', icon: '🔑', label: 'API' }, @@ -52,6 +53,21 @@ export default function Settings({ me }) { const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' }); // Branding const [giteaToken, setGiteaToken] = useState(''); + const [botName, setBotName] = useState(''); + const [twitchCreds, setTwitchCreds] = useState({ id: '', secret: '' }); + // Alpha-Keys + const [alphaKeys, setAlphaKeys] = useState(null); + const [keyUpload, setKeyUpload] = useState(''); + // Bewerbungs-Formulare + const emptyForm = { + id: null, title: '', description: '', review_channel_id: '', + approve_role_id: '', post_channel_id: '', questions: [''], + }; + const [appForms, setAppForms] = useState([]); + const [formDraft, setFormDraft] = useState(emptyForm); + // Triggers + const [triggers, setTriggers] = useState([]); + const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' }); // Tags + geplante Posts const [tags, setTags] = useState([]); const [tagDraft, setTagDraft] = useState({ name: '', content: '' }); @@ -79,8 +95,85 @@ export default function Settings({ me }) { 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(() => {}); + apiGet('/api/alphakeys').then(setAlphaKeys).catch(() => {}); + apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {}); + apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {}); }, [me.admin]); + useEffect(() => { + if (data?.status?.botName) setBotName(data.status.botName); + }, [data]); + + async function uploadAlphaKeys() { + if (!keyUpload.trim()) return; + try { + const res = await apiPost('/api/alphakeys', { keys: keyUpload }); + setAlphaKeys(res); + setKeyUpload(''); + flash(`✓ ${res.added} Keys hinzugefügt`); + } catch { + flash('✗ Upload fehlgeschlagen'); + } + } + + async function distributeKeys() { + if (!window.confirm(`Alpha-Keys jetzt per DM an ${alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen?`)) return; + try { + const res = await apiPost('/api/alphakeys/distribute'); + setAlphaKeys(res); + flash(`✓ ${res.sent} Keys verschickt${res.failed.length ? ` — ${res.failed.length}× DM blockiert` : ''}`); + } catch { + flash('✗ Verteilung fehlgeschlagen'); + } + } + + async function saveForm(publish = false) { + const questions = formDraft.questions.map((q) => q.trim()).filter(Boolean); + if (!formDraft.title.trim() || questions.length === 0) { + flash('✗ Titel und mindestens eine Frage nötig'); + return; + } + try { + const body = { ...formDraft, questions }; + const res = formDraft.id + ? await apiPut(`/api/appforms/${formDraft.id}`, body) + : await apiPost('/api/appforms', body); + if (publish) await apiPost(`/api/appforms/${res.form.id}/publish`); + setAppForms((await apiGet('/api/appforms')).forms); + setFormDraft(emptyForm); + flash(publish ? '✓ Formular gepostet' : '✓ Gespeichert'); + } catch { + flash('✗ Fehlgeschlagen — Kanäle/Fragen prüfen'); + } + } + + async function removeForm(id) { + if (!window.confirm('Formular löschen? Der Discord-Post wird mit entfernt.')) return; + await apiDelete(`/api/appforms/${id}`).catch(() => {}); + setAppForms((await apiGet('/api/appforms')).forms); + if (formDraft.id === id) setFormDraft(emptyForm); + } + + async function saveTriggerDraft() { + if (triggerDraft.keyword.trim().length < 3 || !triggerDraft.reply.trim()) { + flash('✗ Keyword (min 3 Zeichen) und Antwort nötig'); + return; + } + try { + await apiPut('/api/triggers', triggerDraft); + setTriggers((await apiGet('/api/triggers')).triggers); + setTriggerDraft({ keyword: '', reply: '' }); + flash('✓ Trigger gespeichert'); + } catch { + flash('✗ Trigger fehlgeschlagen'); + } + } + + async function removeTrigger(keyword) { + await apiDelete(`/api/triggers/${encodeURIComponent(keyword)}`).catch(() => {}); + setTriggers((await apiGet('/api/triggers')).triggers); + } + async function uploadImage(target, file) { if (!file) return; const reader = new FileReader(); @@ -97,14 +190,17 @@ export default function Settings({ me }) { async function saveBrand() { try { - const body = { ...form }; + const body = { ...form, bot_name: botName }; if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim(); + if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim(); + if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim(); const res = await apiPut('/api/settings', body); setForm(res.settings); setGiteaToken(''); + setTwitchCreds({ id: '', secret: '' }); flash('✓ Gespeichert & angewendet'); } catch { - flash('✗ Speichern fehlgeschlagen (Farben als #rrggbb?)'); + flash('✗ Speichern fehlgeschlagen (Farben/Name prüfen)'); } } @@ -156,8 +252,12 @@ export default function Settings({ me }) { async function save() { try { - const res = await apiPut('/api/settings', form); + const body = { ...form }; + if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim(); + if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim(); + const res = await apiPut('/api/settings', body); setForm(res.settings); + setTwitchCreds({ id: '', secret: '' }); flash('✓ Gespeichert'); } catch { flash('✗ Speichern fehlgeschlagen'); @@ -323,10 +423,56 @@ export default function Settings({ me }) { ); + const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a'; const tabBrand = ( <> +
+
+
+ {data.status.botAvatar + ? Bot-Avatar + :
🤖
} + +
+ + +
+
+
+ + setBotName(e.target.value)} /> +
+
+ + +
+
+ +
+ + setForm({ ...form, bot_status_text: e.target.value })} /> +
+
+
+
+
-

// Identität

+

// Embeds

setForm({ ...form, brand_name: e.target.value })} /> @@ -341,37 +487,6 @@ export default function Settings({ me }) {
-
-

// Bot-Status

-
- -
- - setForm({ ...form, bot_status_text: e.target.value })} /> -
-
-
- -
-

// Avatar & Banner

-

- Wird direkt beim Bot-Account gesetzt. Discord erlaubt nur ~2 Änderungen pro 10 Minuten. -

-
- - uploadImage('avatar', e.target.files?.[0])} /> -
-
- - uploadImage('banner', e.target.files?.[0])} /> -
-
-

// Aus der Env verlagert

@@ -498,6 +613,39 @@ export default function Settings({ me }) { )}
+
+

// Alpha-Keys

+
+
{alphaKeys?.free ?? '—'}Frei
+
{alphaKeys?.assigned?.length ?? '—'}Vergeben
+
{alphaKeys?.playtestersWithout?.length ?? '—'}Warten
+
+
+ +