Community-Endgame: Alpha-Keys, Bewerbungen, Events, Stats, Triggers, /remind, Social, Temp-Voice + MEE6-Bot-Karte
- Alpha-Keys: Pool im Community-Tab, Ein-Klick-Verteilung per DM an alle Playtester (Key bleibt frei, wenn die DM geblockt wird) - Bewerbungs-Formulare: Builder im neuen Bewerbungen-Tab (bis 5 Fragen), Button → Discord-Modal → Review-Embed mit ✅/❌, Rolle + DM bei Entscheidung - Events: GuildScheduledEventCreate → Announce-Embed; öffentliche /events-Seite aus den Discord-Events (5-min-Cache) - Server-Stats: activity_daily (Nachrichten/Joins/Leaves) → Balken-Chart auf /level - Triggers (Auto-Antworten, 30s-Cooldown), /remind (DM-Scheduler), Twitch-Live (Helix, App-Creds write-only) + YouTube-RSS-Announcements, Temp-Voice (Join to Create, Cleanup bei Leerstand + Start) - Brand-Tab: MEE6-Style Bot-Identity-Karte (Avatar-Vorschau mit Status-Dot, Bot-Name via setUsername, Presence online/idle/dnd, Aktivität) - Neue Intents: GuildVoiceStates, GuildScheduledEvents; alles smoke-getestet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<section className="page-header">
|
||||
<div className="page-bg-text">EVENTS</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Termine</div>
|
||||
<h1 className="page-title">Events</h1>
|
||||
<p className="page-subtitle">Playtests, Streams & Community-Abende — direkt aus dem Discord.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <p className="notice">Events konnten nicht geladen werden.</p>}
|
||||
{!error && !data && <p className="notice">Lade …</p>}
|
||||
{data?.events.length === 0 && <p className="notice">Gerade keine geplanten Events — schau bald wieder rein!</p>}
|
||||
|
||||
{data?.events.map((e) => (
|
||||
<article className="card event-card" key={e.url}>
|
||||
{e.cover && <a href={e.url} target="_blank" rel="noreferrer"><img className="event-cover" src={e.cover} alt="" /></a>}
|
||||
<div className="card-meta">
|
||||
<span className="card-date">{e.start ? dateFmt.format(new Date(e.start)) : 'Termin folgt'}</span>
|
||||
{e.interested != null && <span className="card-rel">⭐ {e.interested} interessiert</span>}
|
||||
</div>
|
||||
<h3 className="release-name">{e.name}</h3>
|
||||
{e.description && (
|
||||
<div className="card-body">
|
||||
<Markdown text={e.description} />
|
||||
</div>
|
||||
)}
|
||||
<div className="card-footer">
|
||||
<a href={e.url} target="_blank" rel="noreferrer">Im Discord ansehen →</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||||
<h2 className="settings-title">// Server-Aktivität — {total.toLocaleString('de-DE')} Nachrichten in 30 Tagen · {stats.members} Member</h2>
|
||||
<div className="act-chart">
|
||||
{days.map((d) => (
|
||||
<span
|
||||
key={d.key}
|
||||
className="act-bar"
|
||||
style={{ height: `${Math.max(4, (d.messages / max) * 100)}%` }}
|
||||
title={`${d.key}: ${d.messages} Nachrichten · +${d.joins}/−${d.leaves} Member`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && <ActivityChart stats={stats} />}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
+322
-35
@@ -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 }) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a';
|
||||
const tabBrand = (
|
||||
<>
|
||||
<div className="settings-section bot-card">
|
||||
<div className="bot-card-left">
|
||||
<div className="bot-avatar-wrap">
|
||||
{data.status.botAvatar
|
||||
? <img className="bot-avatar" src={data.status.botAvatar} alt="Bot-Avatar" />
|
||||
: <div className="bot-avatar bot-avatar-empty">🤖</div>}
|
||||
<span className="bot-dot" style={{ background: statusDot }} />
|
||||
</div>
|
||||
<label className="btn btn-upload">
|
||||
⬆ Bild ändern
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
|
||||
</label>
|
||||
<label className="btn btn-upload btn-ghost">
|
||||
🖼 Banner
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="bot-card-right">
|
||||
<div className="field">
|
||||
<label>Bot-Name <span className="field-hint">Discord erlaubt ~2 Umbenennungen pro Stunde</span></label>
|
||||
<input type="text" maxLength={32} value={botName} onChange={(e) => setBotName(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Bot-Status</label>
|
||||
<select value={form.bot_presence_status} onChange={(e) => setForm({ ...form, bot_presence_status: e.target.value })}>
|
||||
<option value="online">🟢 Online</option>
|
||||
<option value="idle">🌙 Abwesend</option>
|
||||
<option value="dnd">⛔ Bitte nicht stören</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Aktivität <span className="field-hint">leer = Server-Monitor darf die Spielerzahl anzeigen</span></label>
|
||||
<div className="field-row">
|
||||
<select style={{ flex: 'none', width: '9rem' }} value={form.bot_status_type} onChange={(e) => setForm({ ...form, bot_status_type: e.target.value })}>
|
||||
<option value="custom">Status</option>
|
||||
<option value="playing">Spielt</option>
|
||||
<option value="watching">Schaut</option>
|
||||
<option value="listening">Hört</option>
|
||||
</select>
|
||||
<input type="text" placeholder="z. B. ⛏️ EcoGame" value={form.bot_status_text} onChange={(e) => setForm({ ...form, bot_status_text: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Identität</h2>
|
||||
<h2 className="settings-title">// Embeds</h2>
|
||||
<div className="field">
|
||||
<label>Brand-Name <span className="field-hint">erscheint in allen Embed-Footern („NAME // DEVLOG")</span></label>
|
||||
<input type="text" placeholder="D4RKST3R" value={form.brand_name} onChange={(e) => setForm({ ...form, brand_name: e.target.value })} />
|
||||
@@ -341,37 +487,6 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Bot-Status</h2>
|
||||
<div className="field">
|
||||
<label>Presence <span className="field-hint">leer = Server-Monitor darf die Spielerzahl anzeigen</span></label>
|
||||
<div className="field-row">
|
||||
<select style={{ flex: 'none', width: '9rem' }} value={form.bot_status_type} onChange={(e) => setForm({ ...form, bot_status_type: e.target.value })}>
|
||||
<option value="custom">Status</option>
|
||||
<option value="playing">Spielt</option>
|
||||
<option value="watching">Schaut</option>
|
||||
<option value="listening">Hört</option>
|
||||
</select>
|
||||
<input type="text" placeholder="z. B. ⛏️ baut an EcoGame" value={form.bot_status_text} onChange={(e) => setForm({ ...form, bot_status_text: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Avatar & Banner</h2>
|
||||
<p className="section-intro">
|
||||
Wird direkt beim Bot-Account gesetzt. Discord erlaubt nur ~2 Änderungen pro 10 Minuten.
|
||||
</p>
|
||||
<div className="field">
|
||||
<label>Bot-Avatar <span className="field-hint">PNG/JPG/GIF, quadratisch empfohlen</span></label>
|
||||
<input type="file" accept="image/*" onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Bot-Banner <span className="field-hint">breites Bild fürs Bot-Profil</span></label>
|
||||
<input type="file" accept="image/*" onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Aus der Env verlagert</h2>
|
||||
<div className="field">
|
||||
@@ -498,6 +613,39 @@ export default function Settings({ me }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Alpha-Keys</h2>
|
||||
<div className="stats" style={{ marginBottom: '1.2rem' }}>
|
||||
<div className="stat"><span className="stat-value">{alphaKeys?.free ?? '—'}</span><span className="stat-label">Frei</span></div>
|
||||
<div className="stat"><span className="stat-value">{alphaKeys?.assigned?.length ?? '—'}</span><span className="stat-label">Vergeben</span></div>
|
||||
<div className="stat"><span className="stat-value">{alphaKeys?.playtestersWithout?.length ?? '—'}</span><span className="stat-label">Warten</span></div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Keys hinzufügen <span className="field-hint">einer pro Zeile</span></label>
|
||||
<textarea rows={3} placeholder={'KEY-AAAA-0001\nKEY-AAAA-0002'} value={keyUpload} onChange={(e) => setKeyUpload(e.target.value)} />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<button className="btn" onClick={uploadAlphaKeys}>Hinzufügen</button>
|
||||
<button
|
||||
className="btn btn-save"
|
||||
onClick={distributeKeys}
|
||||
disabled={!alphaKeys || alphaKeys.free === 0 || alphaKeys.playtestersWithout.length === 0}
|
||||
>
|
||||
🎟️ An {alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen
|
||||
</button>
|
||||
</div>
|
||||
{alphaKeys?.assigned?.length > 0 && (
|
||||
<div className="key-list" style={{ marginTop: '1rem' }}>
|
||||
{alphaKeys.assigned.map((k) => (
|
||||
<div className="key-row" key={k.key}>
|
||||
<span className="key-name">{k.key}</span>
|
||||
<span className="key-used">→ {k.assigned_to} · {k.assigned_at?.slice(0, 10)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Starboard & Galerie</h2>
|
||||
<div className="field">
|
||||
@@ -700,6 +848,80 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const tabBewerbungen = (
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Bewerbungs-Formulare</h2>
|
||||
<p className="section-intro">
|
||||
Button-Post → Discord-Formular (bis 5 Fragen) → Review mit ✅/❌ im Staff-Kanal.
|
||||
Annahme vergibt optional eine Rolle — z. B. für FiveM-Whitelist oder Team-Bewerbungen.
|
||||
</p>
|
||||
|
||||
{appForms.length > 0 && (
|
||||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||
{appForms.map((f) => (
|
||||
<div className="key-row" key={f.id}>
|
||||
<span className="key-name">{f.title}</span>
|
||||
<span className="key-scopes">{f.questions.length} Fragen{f.message_id ? ' · gepostet' : ' · Entwurf'}</span>
|
||||
<button className="btn btn-mini" onClick={() => setFormDraft({
|
||||
id: f.id, title: f.title, description: f.description,
|
||||
review_channel_id: f.review_channel_id ?? '', approve_role_id: f.approve_role_id ?? '',
|
||||
post_channel_id: f.post_channel_id ?? '',
|
||||
questions: f.questions.length ? f.questions : [''],
|
||||
})}>Bearbeiten</button>
|
||||
<button className="card-delete" title="Formular + Post löschen" onClick={() => removeForm(f.id)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>{formDraft.id ? `Formular #${formDraft.id} bearbeiten` : 'Neues Formular'}</label>
|
||||
<div className="field-row">
|
||||
<input type="text" placeholder="Titel, z. B. 🎮 FiveM-Whitelist" value={formDraft.title} onChange={(e) => setFormDraft({ ...formDraft, title: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<input type="text" placeholder="Beschreibung überm Button (optional)" value={formDraft.description} onChange={(e) => setFormDraft({ ...formDraft, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="field-row">
|
||||
<select value={formDraft.post_channel_id} onChange={(e) => setFormDraft({ ...formDraft, post_channel_id: e.target.value })}>
|
||||
<option value="">— Post-Kanal (öffentlich) —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
<select value={formDraft.review_channel_id} onChange={(e) => setFormDraft({ ...formDraft, review_channel_id: e.target.value })}>
|
||||
<option value="">— Review-Kanal (Staff) —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
<select value={formDraft.approve_role_id} onChange={(e) => setFormDraft({ ...formDraft, approve_role_id: e.target.value })}>
|
||||
<option value="">— Rolle bei Annahme —</option>
|
||||
{roleOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Fragen <span className="field-hint">max 5 (Discord-Modal-Limit)</span></label>
|
||||
{formDraft.questions.map((q, i) => (
|
||||
<div className="rr-row" key={i}>
|
||||
<input type="text" placeholder={`Frage ${i + 1}`} maxLength={45} value={q} onChange={(e) => setFormDraft({
|
||||
...formDraft,
|
||||
questions: formDraft.questions.map((x, j) => (j === i ? e.target.value : x)),
|
||||
})} />
|
||||
<button className="card-delete" title="Frage entfernen" disabled={formDraft.questions.length === 1}
|
||||
onClick={() => setFormDraft({ ...formDraft, questions: formDraft.questions.filter((_, j) => j !== i) })}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||||
<button className="btn" disabled={formDraft.questions.length >= 5}
|
||||
onClick={() => setFormDraft({ ...formDraft, questions: [...formDraft.questions, ''] })}>+ Frage</button>
|
||||
<button className="btn" onClick={() => saveForm(false)}>Speichern</button>
|
||||
<button className="btn btn-save" onClick={() => saveForm(true)}>Speichern + posten</button>
|
||||
{formDraft.id && <button className="btn btn-ghost" onClick={() => setFormDraft(emptyForm)}>Abbrechen</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tabComposer = (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
@@ -787,6 +1009,32 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Triggers</h2>
|
||||
<p className="section-intro">
|
||||
Auto-Antworten: Enthält eine Nachricht das Keyword, antwortet der Bot (30s-Cooldown pro Kanal).
|
||||
</p>
|
||||
{triggers.length > 0 && (
|
||||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||
{triggers.map((t) => (
|
||||
<div className="key-row" key={t.keyword}>
|
||||
<span className="key-name">{t.keyword}</span>
|
||||
<span className="key-used" title={t.reply}>{t.reply.slice(0, 50)}</span>
|
||||
<button className="btn btn-mini" onClick={() => setTriggerDraft({ keyword: t.keyword, reply: t.reply })}>Bearbeiten</button>
|
||||
<button className="card-delete" title="Trigger löschen" onClick={() => removeTrigger(t.keyword)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<div className="field-row">
|
||||
<input type="text" style={{ flex: 'none', width: '12rem' }} placeholder="Keyword (min 3 Zeichen)" value={triggerDraft.keyword} onChange={(e) => setTriggerDraft({ ...triggerDraft, keyword: e.target.value })} />
|
||||
<input type="text" placeholder="Antwort des Bots" value={triggerDraft.reply} onChange={(e) => setTriggerDraft({ ...triggerDraft, reply: e.target.value })} />
|
||||
<button className="btn" onClick={saveTriggerDraft}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Tags (/tag)</h2>
|
||||
<p className="section-intro">
|
||||
@@ -852,6 +1100,44 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Events, Social & Voice</h2>
|
||||
<div className="field">
|
||||
<label>Event-Ankündigungs-Kanal <span className="field-hint">neue Discord-Events werden hier angekündigt + Events-Seite</span></label>
|
||||
<select value={form.events_announce_channel_id ?? ''} onChange={(e) => setForm({ ...form, events_announce_channel_id: e.target.value })}>
|
||||
<option value="">— deaktiviert —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Social-Kanal <span className="field-hint">🔴 Twitch-Live + ▶️ neue YouTube-Videos</span></label>
|
||||
<select value={form.social_announce_channel_id ?? ''} onChange={(e) => setForm({ ...form, social_announce_channel_id: e.target.value })}>
|
||||
<option value="">— deaktiviert —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="field-row">
|
||||
<input type="text" placeholder="YouTube-Kanal-ID (UC…)" value={form.youtube_channel_id} onChange={(e) => setForm({ ...form, youtube_channel_id: e.target.value })} />
|
||||
<input type="text" placeholder="Twitch-Login (z. B. d4rkst3r)" value={form.twitch_channel} onChange={(e) => setForm({ ...form, twitch_channel: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Twitch-App-Credentials <span className="field-hint">{form.twitch_creds_set ? '✓ gesetzt' : 'dev.twitch.tv → App registrieren'} — write-only</span></label>
|
||||
<div className="field-row">
|
||||
<input type="password" placeholder="Client-ID" value={twitchCreds.id} onChange={(e) => setTwitchCreds({ ...twitchCreds, id: e.target.value })} />
|
||||
<input type="password" placeholder="Client-Secret" value={twitchCreds.secret} onChange={(e) => setTwitchCreds({ ...twitchCreds, secret: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Temp-Voice-Hub <span className="field-hint">„Join to Create": Beitritt erzeugt eigenen Voice-Kanal (Bot braucht Kanäle verwalten + Member verschieben)</span></label>
|
||||
<select value={form.tempvoice_channel_id ?? ''} onChange={(e) => setForm({ ...form, tempvoice_channel_id: e.target.value })}>
|
||||
<option value="">— deaktiviert —</option>
|
||||
{channelOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Backups & Watchdog</h2>
|
||||
<div className="field">
|
||||
@@ -937,6 +1223,7 @@ export default function Settings({ me }) {
|
||||
community: tabCommunity,
|
||||
rollen: tabRollen,
|
||||
support: tabSupport,
|
||||
bewerbungen: tabBewerbungen,
|
||||
composer: tabComposer,
|
||||
system: tabSystem,
|
||||
api: tabApi,
|
||||
|
||||
Reference in New Issue
Block a user