Playtester bekommen einen eigenen Bereich, Module ziehen um
Playtester und Alpha-Keys standen als zwei Kaesten mitten im Community-Bereich zwischen Geburtstagen und Starboard. Die Liste war reine Anzeige: Namen und Datum, sonst nichts. Wer einem Einzelnen einen Schluessel geben wollte, konnte nur "an alle verteilen" druecken, und wer jemanden wieder rausnehmen wollte, musste an die Datenbank. Jetzt ist es ein eigener Bereich mit drei Zahlen oben und einer Liste, in der neben jedem Namen sein Schluessel steht. Daneben, je nach Zustand, "Schluessel geben" oder "Zurueckziehen", und ein ✕ zum Entfernen — die Rolle geht dabei mit, denn nur aus der Liste zu streichen und die Rolle stehen zu lassen waere eine Halbwahrheit. Der Vorrat ist jetzt sichtbar, nicht nur seine Groesse: einen vertippten Schluessel findet man sonst nie wieder. Nachlegen und "wer bekommt einen?" laufen ueber Dialoge, damit der Bereich nicht zumuellt — dafuer gibt es jetzt eine kleine Dialog-Komponente, gebaut wie die Lightbox (Portal, Escape, Hintergrund festhalten). Zurueckziehen legt den Schluessel zurueck in den Vorrat. Die Person hat ihn per DM natuerlich weiterhin — das ist eine Buchhaltung, keine Sperre. Loeschen geht absichtlich nur bei freien Schluesseln. ## Module dorthin, wo man sie sucht Ein paar Module standen im falschen Bereich, teils seit Langem: - Willkommens-Karte: Support → Brand. Sie ist Aussehen, kein Support-Fall, und im Brand-Bereich steht der Rest vom Auftritt. Der Block ist woertlich umgezogen, nur in eine eigene Konstante — so kann sich beim Umzug nichts am Inhalt geaendert haben. - Twitch & YouTube: System → Feeds. Es ist ein Feed. - Events: System → Community. - Auto-Rolle und Rollen merken: Ihre Felder standen laengst im Rollen-Bereich, nur das Register sagte "Community" — dadurch sprang "Einrichten" woanders hin als die Einstellung liegt. - Playtester und Alpha-Keys: Community → eigener Bereich. Damit ist System kein Sammelbecken mehr, sondern nur noch Technik: Waechter, Sprachkanaele, Erinnerungen, Sicherungen, Member-Gate. Geprueft: Routen (alle vier neuen bewacht), SQL gegen das Schema, und im Browser beide Dialoge (Escape schliesst, Hintergrund wird wieder freigegeben, Hinzufuegen bleibt bei leerem Feld aus), die Liste in allen drei Zustaenden, und die Suche — "willkommen" fuehrt jetzt nach Brand, "twitch" zu Feeds, "sticky" zu Rollen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,8 +76,8 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen.
|
|||||||
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste |
|
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste |
|
||||||
| 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle |
|
| 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle |
|
||||||
| 📅 **Events** | Neue Discord-Events werden angekündigt + öffentliche Events-Seite |
|
| 📅 **Events** | Neue Discord-Events werden angekündigt + öffentliche Events-Seite |
|
||||||
| 🧪 **Playtester-Programm** | `/playtester-setup` postet den Bewerbungs-Button; Rolle + Liste im Panel |
|
| 🧪 **Playtester-Programm** | `/playtester-setup` postet den Bewerbungs-Button; eigener Panel-Bereich mit Liste, Rolle und dem Schlüssel-Vorrat — Playtester lassen sich dort auch wieder entfernen (die Rolle geht mit) |
|
||||||
| 🎟️ **Alpha-Keys** | Key-Pool im Panel; Ein-Klick-Verteilung an alle Playtester per DM |
|
| 🎟️ **Alpha-Keys** | Schlüssel-Vorrat im Panel: nachlegen, gezielt an eine Person schicken, an alle Wartenden verteilen, zurückziehen oder löschen. Zustellung per Direktnachricht — kommt sie nicht an, bleibt der Schlüssel frei |
|
||||||
| 🎭 **Rollen-Menüs** | Menüs im Webinterface bauen (Emoji, Label, Rolle, optional exklusiv), Bot postet Button-Embeds; Posts jederzeit editierbar |
|
| 🎭 **Rollen-Menüs** | Menüs im Webinterface bauen (Emoji, Label, Rolle, optional exklusiv), Bot postet Button-Embeds; Posts jederzeit editierbar |
|
||||||
| 📋 **Bewerbungen** | Formulare (bis 5 Fragen) als Discord-Modal, Review mit ✅/❌ im Staff-Kanal, Rolle + DM bei Annahme — z. B. FiveM-Whitelist |
|
| 📋 **Bewerbungen** | Formulare (bis 5 Fragen) als Discord-Modal, Review mit ✅/❌ im Staff-Kanal, Rolle + DM bei Annahme — z. B. FiveM-Whitelist |
|
||||||
| 🪪 **Verknüpfte Rollen** | Discords *Linked Roles*: Level, Playtester-Status und Dabei-seit werden als geprüfte Kennzahlen an Discord gemeldet — Rollen vergibt dann Discord selbst |
|
| 🪪 **Verknüpfte Rollen** | Discords *Linked Roles*: Level, Playtester-Status und Dabei-seit werden als geprüfte Kennzahlen an Discord gemeldet — Rollen vergibt dann Discord selbst |
|
||||||
@@ -169,14 +169,14 @@ selbst gehostet).
|
|||||||
|
|
||||||
## Config-Seite (`/settings`)
|
## Config-Seite (`/settings`)
|
||||||
|
|
||||||
**16 Bereiche**, in der Seitenleiste nach Themen gruppiert:
|
**17 Bereiche**, in der Seitenleiste nach Themen gruppiert:
|
||||||
|
|
||||||
| Gruppe | Bereiche |
|
| Gruppe | Bereiche |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Überblick | Status · Module · Texte · Werte |
|
| Überblick | Status · Module · Texte · Werte |
|
||||||
| Auftritt | Brand · Seiten |
|
| Auftritt | Brand (samt Willkommens-Karte) · Seiten |
|
||||||
| Inhalte | Feeds · Composer |
|
| Inhalte | Feeds · Composer |
|
||||||
| Community | Community · Rollen · Support · Bewerbungen |
|
| Community | Community · Rollen · Support · Playtester · Bewerbungen |
|
||||||
| Technik | Server · System |
|
| Technik | Server · System |
|
||||||
| Zugang | API · Team |
|
| Zugang | API · Team |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Kleiner Dialog fürs Panel — für Dinge, die den Bereich sonst zumüllen:
|
||||||
|
// Schlüssel nachlegen, jemanden auswählen, etwas bestätigen.
|
||||||
|
//
|
||||||
|
// Rendert per Portal direkt in <body>, damit Kopfzeile und Scanlines nicht
|
||||||
|
// durchscheinen — dieselbe Bauart wie die Lightbox.
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { IconX } from '../icons.jsx';
|
||||||
|
|
||||||
|
export default function Dialog({ title, onClose, children, footer, weit = false }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
// Hintergrund festhalten, sonst scrollt die Seite unter dem Dialog weg
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="dlg-backdrop" onClick={onClose} role="dialog" aria-modal="true">
|
||||||
|
{/* Klicks im Kasten dürfen nicht bis zum Hintergrund durchfallen */}
|
||||||
|
<div className={`dlg${weit ? ' weit' : ''}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="dlg-head">
|
||||||
|
<h3 className="dlg-title">{title}</h3>
|
||||||
|
<button className="card-delete" onClick={onClose} title="Schließen (Esc)">
|
||||||
|
<IconX size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="dlg-body">{children}</div>
|
||||||
|
{footer && <div className="dlg-foot">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
+331
-156
@@ -6,11 +6,12 @@ import {
|
|||||||
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
|
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
|
||||||
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
|
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
|
||||||
IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh,
|
IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh,
|
||||||
IconSearch, IconSystem as IconTune, IconStar,
|
IconSearch, IconSystem as IconTune, IconStar, IconGame, IconTrash,
|
||||||
} from '../icons.jsx';
|
} from '../icons.jsx';
|
||||||
import { moduleIcon } from '../module-icons.jsx';
|
import { moduleIcon } from '../module-icons.jsx';
|
||||||
import ModulePage from '../components/ModulePage.jsx';
|
import ModulePage from '../components/ModulePage.jsx';
|
||||||
import UptimeTrack from '../components/UptimeTrack.jsx';
|
import UptimeTrack from '../components/UptimeTrack.jsx';
|
||||||
|
import Dialog from '../components/Dialog.jsx';
|
||||||
import { findSettings, SETTING_INDEX } from '../setting-index.js';
|
import { findSettings, SETTING_INDEX } from '../setting-index.js';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
|
||||||
@@ -59,6 +60,8 @@ const TABS = [
|
|||||||
find: 'rollen menü selbstbedienung buttons reaction roles' },
|
find: 'rollen menü selbstbedienung buttons reaction roles' },
|
||||||
{ id: 'support', Icon: IconSupport, label: 'Support', section: 'community',
|
{ id: 'support', Icon: IconSupport, label: 'Support', section: 'community',
|
||||||
find: 'ticket modmail willkommen protokoll modlog moderation warnung' },
|
find: 'ticket modmail willkommen protokoll modlog moderation warnung' },
|
||||||
|
{ id: 'playtester', Icon: IconGame, label: 'Playtester', section: 'community',
|
||||||
|
find: 'tester alpha keys schlüssel vergeben pool testen einladen' },
|
||||||
{ id: 'bewerbungen', Icon: IconApplications, label: 'Bewerbungen', section: 'community',
|
{ id: 'bewerbungen', Icon: IconApplications, label: 'Bewerbungen', section: 'community',
|
||||||
find: 'formular fragen bewerbung prüfen annehmen ablehnen' },
|
find: 'formular fragen bewerbung prüfen annehmen ablehnen' },
|
||||||
|
|
||||||
@@ -88,6 +91,7 @@ const TAB_SCOPES = {
|
|||||||
community: 'community',
|
community: 'community',
|
||||||
rollen: 'rollen',
|
rollen: 'rollen',
|
||||||
support: 'settings',
|
support: 'settings',
|
||||||
|
playtester: 'community',
|
||||||
bewerbungen: 'bewerbungen',
|
bewerbungen: 'bewerbungen',
|
||||||
composer: 'content',
|
composer: 'content',
|
||||||
seiten: 'content',
|
seiten: 'content',
|
||||||
@@ -298,7 +302,6 @@ export default function Settings({ me }) {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [feedback, setFeedback] = useState(null);
|
const [feedback, setFeedback] = useState(null);
|
||||||
// Community
|
// Community
|
||||||
const [playtesters, setPlaytesters] = useState([]);
|
|
||||||
// Rollen-Menüs
|
// Rollen-Menüs
|
||||||
const emptyMenu = {
|
const emptyMenu = {
|
||||||
id: null, title: '', description: '', channel_id: '', exclusive: false,
|
id: null, title: '', description: '', channel_id: '', exclusive: false,
|
||||||
@@ -312,6 +315,9 @@ export default function Settings({ me }) {
|
|||||||
const [twitchCreds, setTwitchCreds] = useState({ id: '', secret: '' });
|
const [twitchCreds, setTwitchCreds] = useState({ id: '', secret: '' });
|
||||||
// Alpha-Keys
|
// Alpha-Keys
|
||||||
const [alphaKeys, setAlphaKeys] = useState(null);
|
const [alphaKeys, setAlphaKeys] = useState(null);
|
||||||
|
// Offene Dialoge: Schlüssel nachlegen bzw. jemanden auswählen
|
||||||
|
const [keyDialog, setKeyDialog] = useState(false);
|
||||||
|
const [keyEmpfaenger, setKeyEmpfaenger] = useState(null);
|
||||||
const [keyUpload, setKeyUpload] = useState('');
|
const [keyUpload, setKeyUpload] = useState('');
|
||||||
// Bewerbungs-Formulare
|
// Bewerbungs-Formulare
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
@@ -459,7 +465,6 @@ export default function Settings({ me }) {
|
|||||||
})
|
})
|
||||||
.catch((e) => setError(e.status));
|
.catch((e) => setError(e.status));
|
||||||
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
|
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/rolemenus').then((d) => setMenus(d.menus)).catch(() => {});
|
||||||
apiGet('/api/tags').then((d) => setTags(d.tags)).catch(() => {});
|
apiGet('/api/tags').then((d) => setTags(d.tags)).catch(() => {});
|
||||||
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
|
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
|
||||||
@@ -820,6 +825,48 @@ export default function Settings({ me }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Einen Schlüssel an eine ausgewählte Person schicken */
|
||||||
|
async function keyGeben(userId, name) {
|
||||||
|
try {
|
||||||
|
const res = await apiPost('/api/alphakeys/assign', { user_id: userId });
|
||||||
|
setAlphaKeys(res);
|
||||||
|
setKeyEmpfaenger(null);
|
||||||
|
flash(`✓ Schlüssel an ${name} verschickt`);
|
||||||
|
} catch (e) {
|
||||||
|
flash(`✗ ${e.body?.error ?? 'Vergeben fehlgeschlagen'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vergebenen Schlüssel zurück in den Vorrat */
|
||||||
|
async function keyZurueck(key) {
|
||||||
|
if (!window.confirm(`Schlüssel ${key} wieder freigeben? Die Person behält die Nachricht mit dem Schlüssel.`)) return;
|
||||||
|
try {
|
||||||
|
setAlphaKeys(await apiPost('/api/alphakeys/revoke', { key }));
|
||||||
|
} catch {
|
||||||
|
flash('✗ Zurückziehen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freien Schlüssel löschen — z. B. einen vertippten */
|
||||||
|
async function keyLoeschen(key) {
|
||||||
|
try {
|
||||||
|
setAlphaKeys(await apiDelete(`/api/alphakeys/${encodeURIComponent(key)}`));
|
||||||
|
} catch (e) {
|
||||||
|
flash(`✗ ${e.body?.error ?? 'Löschen fehlgeschlagen'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playtesterEntfernen(userId, name) {
|
||||||
|
if (!window.confirm(`${name} aus dem Playtester-Programm nehmen? Die Rolle wird mit entfernt.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await apiDelete(`/api/playtesters/${userId}`);
|
||||||
|
setAlphaKeys(await apiGet('/api/alphakeys'));
|
||||||
|
flash(res.rolleWeg ? `✓ ${name} entfernt` : `✓ ${name} entfernt — Rolle blieb (Rechte prüfen)`);
|
||||||
|
} catch {
|
||||||
|
flash('✗ Entfernen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function distributeKeys() {
|
async function distributeKeys() {
|
||||||
if (!window.confirm(`Alpha-Keys jetzt per DM an ${alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen?`)) return;
|
if (!window.confirm(`Alpha-Keys jetzt per DM an ${alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen?`)) return;
|
||||||
try {
|
try {
|
||||||
@@ -1400,6 +1447,106 @@ export default function Settings({ me }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a';
|
const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a';
|
||||||
|
// Willkommens-Karte: eigener Block, damit sie im Brand-Bereich stehen
|
||||||
|
// kann, wo das uebrige Aussehen gepflegt wird — inhaltlich unveraendert.
|
||||||
|
const willkommensKarte = (
|
||||||
|
<>
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Willkommen</h2>
|
||||||
|
<div className="field" data-setting="welcome_channel_id">
|
||||||
|
<label>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member</span></label>
|
||||||
|
<PickSelect
|
||||||
|
items={channelItems}
|
||||||
|
value={form.welcome_channel_id ?? ''}
|
||||||
|
onChange={(v) => setForm({ ...form, welcome_channel_id: v })}
|
||||||
|
empty="— deaktiviert —"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Willkommens-Karte</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
Das Bild, das neue Mitglieder im Willkommens-Kanal begrüßt. Die Vorschau
|
||||||
|
zeigt sofort, wie es aussieht — gespeichert wird erst mit dem Knopf unten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="field" data-setting="welcome_card_enabled">
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.welcome_card_enabled}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_card_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Karte rendern</span>
|
||||||
|
<span className="field-hint">aus = nur das Text-Embed mit Avatar als Vorschaubild</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.welcome_card_enabled && (
|
||||||
|
<>
|
||||||
|
<img className="welcome-preview" src={welcomePreviewUrl} alt="Vorschau der Willkommens-Karte" />
|
||||||
|
|
||||||
|
<div className="wc-colors">
|
||||||
|
{[
|
||||||
|
['welcome_card_bg', 'Hintergrund'],
|
||||||
|
['welcome_card_accent', 'Akzent 1'],
|
||||||
|
['welcome_card_accent2', 'Akzent 2'],
|
||||||
|
['welcome_card_text', 'Name'],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<label className="wc-color" key={key}>
|
||||||
|
<input
|
||||||
|
className="color-pick"
|
||||||
|
type="color"
|
||||||
|
value={form[key] ?? '#000000'}
|
||||||
|
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field" data-setting="welcome_card_kicker">
|
||||||
|
<label>Kleine Zeile <span className="field-hint">über dem Namen — leer = weglassen</span></label>
|
||||||
|
<input
|
||||||
|
type="text" maxLength={24} placeholder="WILLKOMMEN"
|
||||||
|
value={form.welcome_card_kicker ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_card_kicker: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field" data-setting="welcome_card_sub">
|
||||||
|
<label>Zeile darunter <span className="field-hint">{'{count}'} wird zur Mitglieds-Nummer — leer = weglassen</span></label>
|
||||||
|
<input
|
||||||
|
type="text" maxLength={60} placeholder="Member #{count}"
|
||||||
|
value={form.welcome_card_sub ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_card_sub: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field" data-setting="welcome_card_image">
|
||||||
|
<label>Hintergrundbild <span className="field-hint">https-Adresse, 900×300 passt genau — leer = nur Farbe mit Raster</span></label>
|
||||||
|
<input
|
||||||
|
type="text" placeholder="https://cdn.d4rkst3r.de/welcome.png"
|
||||||
|
value={form.welcome_card_image ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_card_image: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field" data-setting="welcome_card_watermark">
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.welcome_card_watermark}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_card_watermark: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Markenname im Hintergrund</span>
|
||||||
|
<span className="field-hint">großer, schwach sichtbarer Schriftzug hinten rechts</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const tabBrand = (
|
const tabBrand = (
|
||||||
<>
|
<>
|
||||||
<div className="settings-section bot-card">
|
<div className="settings-section bot-card">
|
||||||
@@ -1482,6 +1629,7 @@ export default function Settings({ me }) {
|
|||||||
<div className="settings-actions">
|
<div className="settings-actions">
|
||||||
<button className="btn btn-save" onClick={saveBrand}>Speichern & anwenden</button>
|
<button className="btn btn-save" onClick={saveBrand}>Speichern & anwenden</button>
|
||||||
</div>
|
</div>
|
||||||
|
{willkommensKarte}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1500,6 +1648,32 @@ export default function Settings({ me }) {
|
|||||||
/>
|
/>
|
||||||
<button className="btn" onClick={() => sendTest('devlog')}>Test</button>
|
<button className="btn" onClick={() => sendTest('devlog')}>Test</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Twitch & YouTube</h2>
|
||||||
|
<div className="field" data-setting="social_announce_channel_id">
|
||||||
|
<label>Social-Kanal <span className="field-hint">🔴 Twitch-Live + ▶️ neue YouTube-Videos</span></label>
|
||||||
|
<PickSelect
|
||||||
|
items={channelItems}
|
||||||
|
value={form.social_announce_channel_id ?? ''}
|
||||||
|
onChange={(v) => setForm({ ...form, social_announce_channel_id: v })}
|
||||||
|
empty="— deaktiviert —"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field" data-setting="youtube_channel_id">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="field" data-setting="commit_channel_id">
|
<div className="field" data-setting="commit_channel_id">
|
||||||
<label>Commit-Kanal <span className="field-hint">privat — hier landen die Push-Embeds</span></label>
|
<label>Commit-Kanal <span className="field-hint">privat — hier landen die Push-Embeds</span></label>
|
||||||
@@ -1603,59 +1777,16 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Playtester</h2>
|
<h2 className="settings-title">// Events</h2>
|
||||||
<div className="field" data-setting="playtester_role_id">
|
<div className="field" data-setting="events_announce_channel_id">
|
||||||
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
|
<label>Event-Ankündigungs-Kanal <span className="field-hint">neue Discord-Events werden hier angekündigt + Events-Seite</span></label>
|
||||||
<PickSelect
|
<PickSelect
|
||||||
items={roleItems}
|
items={channelItems}
|
||||||
value={form.playtester_role_id ?? ''}
|
value={form.events_announce_channel_id ?? ''}
|
||||||
onChange={(v) => setForm({ ...form, playtester_role_id: v })}
|
onChange={(v) => setForm({ ...form, events_announce_channel_id: v })}
|
||||||
empty="— deaktiviert —"
|
empty="— deaktiviert —"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{playtesters.length > 0 && (
|
|
||||||
<div className="key-list">
|
|
||||||
{playtesters.map((p) => (
|
|
||||||
<div className="key-row" key={p.user_id}>
|
|
||||||
<span className="key-name">{p.username}</span>
|
|
||||||
<span className="key-used">seit {p.applied_at.slice(0, 10)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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}
|
|
||||||
>
|
|
||||||
<IconKey size={14} /> 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>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
@@ -2046,15 +2177,6 @@ export default function Settings({ me }) {
|
|||||||
empty="— deaktiviert —"
|
empty="— deaktiviert —"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field" data-setting="welcome_channel_id">
|
|
||||||
<label>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member</span></label>
|
|
||||||
<PickSelect
|
|
||||||
items={channelItems}
|
|
||||||
value={form.welcome_channel_id ?? ''}
|
|
||||||
onChange={(v) => setForm({ ...form, welcome_channel_id: v })}
|
|
||||||
empty="— deaktiviert —"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field" data-setting="modlog_channel_id">
|
<div className="field" data-setting="modlog_channel_id">
|
||||||
<label>Mod-Log-Kanal <span className="field-hint">PRIVAT — gelöschte/bearbeitete Nachrichten</span></label>
|
<label>Mod-Log-Kanal <span className="field-hint">PRIVAT — gelöschte/bearbeitete Nachrichten</span></label>
|
||||||
<PickSelect
|
<PickSelect
|
||||||
@@ -2065,87 +2187,170 @@ export default function Settings({ me }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabPlaytester = (
|
||||||
|
<>
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Willkommens-Karte</h2>
|
<h2 className="settings-title">// Playtester</h2>
|
||||||
<p className="section-intro">
|
<p className="section-intro">
|
||||||
Das Bild, das neue Mitglieder im Willkommens-Kanal begrüßt. Die Vorschau
|
Wer sich über den Knopf aus <strong>/playtester-setup</strong> meldet, landet
|
||||||
zeigt sofort, wie es aussieht — gespeichert wird erst mit dem Knopf unten.
|
hier — mit Rolle. Schlüssel gehen per Direktnachricht raus; kommt sie nicht
|
||||||
|
an, bleibt der Schlüssel im Vorrat.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="field" data-setting="playtester_role_id">
|
||||||
<div className="field" data-setting="welcome_card_enabled">
|
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
|
||||||
<label className="toggle">
|
<PickSelect
|
||||||
<input
|
items={roleItems}
|
||||||
type="checkbox"
|
value={form.playtester_role_id ?? ''}
|
||||||
checked={form.welcome_card_enabled}
|
onChange={(v) => setForm({ ...form, playtester_role_id: v })}
|
||||||
onChange={(e) => setForm({ ...form, welcome_card_enabled: e.target.checked })}
|
empty="— deaktiviert —"
|
||||||
/>
|
/>
|
||||||
<span>Karte rendern</span>
|
|
||||||
<span className="field-hint">aus = nur das Text-Embed mit Avatar als Vorschaubild</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.welcome_card_enabled && (
|
<div className="stats" style={{ margin: '1.2rem 0' }}>
|
||||||
<>
|
<div className="stat"><span className="stat-value">{alphaKeys?.playtesters?.length ?? '—'}</span><span className="stat-label">Playtester</span></div>
|
||||||
<img className="welcome-preview" src={welcomePreviewUrl} alt="Vorschau der Willkommens-Karte" />
|
<div className="stat"><span className="stat-value">{alphaKeys?.free ?? '—'}</span><span className="stat-label">Freie Schlüssel</span></div>
|
||||||
|
<div className="stat"><span className="stat-value">{alphaKeys?.playtestersWithout?.length ?? '—'}</span><span className="stat-label">Warten</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="wc-colors">
|
{(alphaKeys?.playtesters?.length ?? 0) === 0 ? (
|
||||||
{[
|
<p className="notice">Noch niemand dabei.</p>
|
||||||
['welcome_card_bg', 'Hintergrund'],
|
) : (
|
||||||
['welcome_card_accent', 'Akzent 1'],
|
<div className="key-list">
|
||||||
['welcome_card_accent2', 'Akzent 2'],
|
{alphaKeys.playtesters.map((p) => (
|
||||||
['welcome_card_text', 'Name'],
|
<div className="key-row" key={p.user_id}>
|
||||||
].map(([key, label]) => (
|
<span className="key-name">{p.username}</span>
|
||||||
<label className="wc-color" key={key}>
|
<span className="key-scopes">
|
||||||
<input
|
{p.key
|
||||||
className="color-pick"
|
? <code className="pt-key">{p.key}</code>
|
||||||
type="color"
|
: <span className="pt-wartet">wartet auf Schlüssel</span>}
|
||||||
value={form[key] ?? '#000000'}
|
</span>
|
||||||
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
|
<span className="key-used">seit {p.applied_at?.slice(0, 10)}</span>
|
||||||
/>
|
{p.key ? (
|
||||||
<span>{label}</span>
|
<button className="btn btn-mini" title="Schlüssel wieder freigeben" onClick={() => keyZurueck(p.key)}>
|
||||||
</label>
|
Zurückziehen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-mini"
|
||||||
|
disabled={!alphaKeys.free}
|
||||||
|
title={alphaKeys.free ? 'Schlüssel per Direktnachricht schicken' : 'Vorrat ist leer'}
|
||||||
|
onClick={() => keyGeben(p.user_id, p.username)}
|
||||||
|
>
|
||||||
|
Schlüssel geben
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="card-delete" title="Aus dem Programm nehmen" onClick={() => playtesterEntfernen(p.user_id, p.username)}>✕</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="field" data-setting="welcome_card_kicker">
|
<div className="settings-section">
|
||||||
<label>Kleine Zeile <span className="field-hint">über dem Namen — leer = weglassen</span></label>
|
<h2 className="settings-title">// Schlüssel-Vorrat</h2>
|
||||||
<input
|
<div className="field-row" style={{ marginBottom: '1rem' }}>
|
||||||
type="text" maxLength={24} placeholder="WILLKOMMEN"
|
<button className="btn" onClick={() => setKeyDialog(true)}>
|
||||||
value={form.welcome_card_kicker ?? ''}
|
<IconKey size={14} /> Schlüssel nachlegen
|
||||||
onChange={(e) => setForm({ ...form, welcome_card_kicker: e.target.value })}
|
</button>
|
||||||
/>
|
<button
|
||||||
|
className="btn"
|
||||||
|
disabled={!alphaKeys?.free || !alphaKeys?.playtestersWithout?.length}
|
||||||
|
onClick={() => setKeyEmpfaenger('wahl')}
|
||||||
|
>
|
||||||
|
Gezielt vergeben …
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-save"
|
||||||
|
onClick={distributeKeys}
|
||||||
|
disabled={!alphaKeys || alphaKeys.free === 0 || alphaKeys.playtestersWithout.length === 0}
|
||||||
|
>
|
||||||
|
An alle {alphaKeys?.playtestersWithout?.length ?? 0} Wartenden
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="field" data-setting="welcome_card_sub">
|
|
||||||
<label>Zeile darunter <span className="field-hint">{'{count}'} wird zur Mitglieds-Nummer — leer = weglassen</span></label>
|
{(alphaKeys?.freieKeys?.length ?? 0) === 0 ? (
|
||||||
<input
|
<p className="notice">Der Vorrat ist leer.</p>
|
||||||
type="text" maxLength={60} placeholder="Member #{count}"
|
) : (
|
||||||
value={form.welcome_card_sub ?? ''}
|
<div className="key-list">
|
||||||
onChange={(e) => setForm({ ...form, welcome_card_sub: e.target.value })}
|
{alphaKeys.freieKeys.map((k) => (
|
||||||
/>
|
<div className="key-row" key={k}>
|
||||||
|
<span className="key-name"><code className="pt-key">{k}</code></span>
|
||||||
|
<span className="key-used">frei</span>
|
||||||
|
<button className="card-delete" title="Schlüssel löschen" onClick={() => keyLoeschen(k)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="field" data-setting="welcome_card_image">
|
))}
|
||||||
<label>Hintergrundbild <span className="field-hint">https-Adresse, 900×300 passt genau — leer = nur Farbe mit Raster</span></label>
|
|
||||||
<input
|
|
||||||
type="text" placeholder="https://cdn.d4rkst3r.de/welcome.png"
|
|
||||||
value={form.welcome_card_image ?? ''}
|
|
||||||
onChange={(e) => setForm({ ...form, welcome_card_image: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field" data-setting="welcome_card_watermark">
|
)}
|
||||||
<label className="toggle">
|
|
||||||
<input
|
{alphaKeys?.assigned?.length > 0 && (
|
||||||
type="checkbox"
|
<>
|
||||||
checked={form.welcome_card_watermark}
|
<h3 className="tm-form-title" style={{ marginTop: '1.6rem' }}>Vergeben</h3>
|
||||||
onChange={(e) => setForm({ ...form, welcome_card_watermark: e.target.checked })}
|
<div className="key-list">
|
||||||
/>
|
{alphaKeys.assigned.map((k) => (
|
||||||
<span>Markenname im Hintergrund</span>
|
<div className="key-row" key={k.key}>
|
||||||
<span className="field-hint">großer, schwach sichtbarer Schriftzug hinten rechts</span>
|
<span className="key-name"><code className="pt-key">{k.key}</code></span>
|
||||||
</label>
|
<span className="key-used">
|
||||||
|
→ {alphaKeys.playtesters?.find((p) => p.user_id === k.assigned_to)?.username ?? k.assigned_to}
|
||||||
|
{' · '}{k.assigned_at?.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-mini" onClick={() => keyZurueck(k.key)}>Zurückziehen</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{keyDialog && (
|
||||||
|
<Dialog
|
||||||
|
title="Schlüssel nachlegen"
|
||||||
|
onClose={() => setKeyDialog(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn btn-ghost" onClick={() => setKeyDialog(false)}>Abbrechen</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-save"
|
||||||
|
disabled={!keyUpload.trim()}
|
||||||
|
onClick={async () => { await uploadAlphaKeys(); setKeyDialog(false); }}
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="field">
|
||||||
|
<label>
|
||||||
|
Schlüssel <span className="field-hint">einer pro Zeile; schon bekannte werden übersprungen</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
placeholder={'KEY-AAAA-0001\nKEY-AAAA-0002'}
|
||||||
|
value={keyUpload}
|
||||||
|
onChange={(e) => setKeyUpload(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keyEmpfaenger && (
|
||||||
|
<Dialog title="Wer bekommt einen Schlüssel?" onClose={() => setKeyEmpfaenger(null)}>
|
||||||
|
<p className="section-intro">
|
||||||
|
{alphaKeys?.free ?? 0} im Vorrat. Der Schlüssel geht sofort als
|
||||||
|
Direktnachricht raus — kommt sie nicht an, bleibt er frei.
|
||||||
|
</p>
|
||||||
|
<div className="dlg-pick">
|
||||||
|
{alphaKeys?.playtestersWithout?.map((p) => (
|
||||||
|
<button key={p.user_id} onClick={() => keyGeben(p.user_id, p.username)}>
|
||||||
|
{p.username}
|
||||||
|
<span className="pick-hint">seit {p.applied_at?.slice(0, 10)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2468,38 +2673,7 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Events, Social & Voice</h2>
|
<h2 className="settings-title">// Sprachkanäle</h2>
|
||||||
<div className="field" data-setting="events_announce_channel_id">
|
|
||||||
<label>Event-Ankündigungs-Kanal <span className="field-hint">neue Discord-Events werden hier angekündigt + Events-Seite</span></label>
|
|
||||||
<PickSelect
|
|
||||||
items={channelItems}
|
|
||||||
value={form.events_announce_channel_id ?? ''}
|
|
||||||
onChange={(v) => setForm({ ...form, events_announce_channel_id: v })}
|
|
||||||
empty="— deaktiviert —"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field" data-setting="social_announce_channel_id">
|
|
||||||
<label>Social-Kanal <span className="field-hint">🔴 Twitch-Live + ▶️ neue YouTube-Videos</span></label>
|
|
||||||
<PickSelect
|
|
||||||
items={channelItems}
|
|
||||||
value={form.social_announce_channel_id ?? ''}
|
|
||||||
onChange={(v) => setForm({ ...form, social_announce_channel_id: v })}
|
|
||||||
empty="— deaktiviert —"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field" data-setting="youtube_channel_id">
|
|
||||||
<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" data-setting="tempvoice_channel_id">
|
<div className="field" data-setting="tempvoice_channel_id">
|
||||||
<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>
|
<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>
|
||||||
<PickSelect
|
<PickSelect
|
||||||
@@ -3404,6 +3578,7 @@ export default function Settings({ me }) {
|
|||||||
community: tabCommunity,
|
community: tabCommunity,
|
||||||
rollen: tabRollen,
|
rollen: tabRollen,
|
||||||
support: tabSupport,
|
support: tabSupport,
|
||||||
|
playtester: tabPlaytester,
|
||||||
bewerbungen: tabBewerbungen,
|
bewerbungen: tabBewerbungen,
|
||||||
composer: tabComposer,
|
composer: tabComposer,
|
||||||
seiten: tabSeiten,
|
seiten: tabSeiten,
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ export const SETTING_INDEX = [
|
|||||||
{ id: 'birthday_channel_id', tab: 'community', label: 'Geburtstags-Kanal', find: 'geburtstag gratulation' },
|
{ id: 'birthday_channel_id', tab: 'community', label: 'Geburtstags-Kanal', find: 'geburtstag gratulation' },
|
||||||
{ id: 'modmail_channel_id', tab: 'support', label: 'Modmail-Kanal', find: 'direktnachricht dm staff' },
|
{ id: 'modmail_channel_id', tab: 'support', label: 'Modmail-Kanal', find: 'direktnachricht dm staff' },
|
||||||
{ id: 'ticket_channel_id', tab: 'support', label: 'Ticket-Kanal', find: 'support anfrage thread' },
|
{ id: 'ticket_channel_id', tab: 'support', label: 'Ticket-Kanal', find: 'support anfrage thread' },
|
||||||
{ id: 'welcome_channel_id', tab: 'support', label: 'Willkommens-Kanal', find: 'begrüßung neue member' },
|
{ id: 'welcome_channel_id', tab: 'brand', label: 'Willkommens-Kanal', find: 'begrüßung neue member' },
|
||||||
{ id: 'modlog_channel_id', tab: 'support', label: 'Protokoll-Kanal', find: 'modlog logging gelöscht' },
|
{ id: 'modlog_channel_id', tab: 'support', label: 'Protokoll-Kanal', find: 'modlog logging gelöscht' },
|
||||||
{ id: 'events_announce_channel_id', tab: 'system', label: 'Event-Kanal', find: 'events ankündigung' },
|
{ id: 'events_announce_channel_id', tab: 'community', label: 'Event-Kanal', find: 'events ankündigung' },
|
||||||
{ id: 'social_announce_channel_id', tab: 'system', label: 'Social-Kanal', find: 'twitch youtube live stream' },
|
{ id: 'social_announce_channel_id', tab: 'feeds', label: 'Social-Kanal', find: 'twitch youtube live stream' },
|
||||||
{ id: 'tempvoice_channel_id', tab: 'system', label: 'Sprachkanal-Hub', find: 'temp voice join to create' },
|
{ id: 'tempvoice_channel_id', tab: 'system', label: 'Sprachkanal-Hub', find: 'temp voice join to create' },
|
||||||
{ id: 'backup_channel_id', tab: 'system', label: 'Backup-Kanal', find: 'sicherung upload' },
|
{ id: 'backup_channel_id', tab: 'system', label: 'Backup-Kanal', find: 'sicherung upload' },
|
||||||
{ id: 'status_channel_id', tab: 'server', label: 'Server-Status-Kanal', find: 'monitor game embed' },
|
{ id: 'status_channel_id', tab: 'server', label: 'Server-Status-Kanal', find: 'monitor game embed' },
|
||||||
@@ -36,9 +36,9 @@ export const SETTING_INDEX = [
|
|||||||
|
|
||||||
/* ── Rollen ─────────────────────────────────────── */
|
/* ── Rollen ─────────────────────────────────────── */
|
||||||
{ id: 'devlog_ping_role_id', tab: 'feeds', label: 'Devlog-Ping-Rolle', find: 'benachrichtigung abo erwähnen' },
|
{ id: 'devlog_ping_role_id', tab: 'feeds', label: 'Devlog-Ping-Rolle', find: 'benachrichtigung abo erwähnen' },
|
||||||
{ id: 'playtester_role_id', tab: 'community', label: 'Playtester-Rolle', find: 'alpha tester' },
|
{ id: 'playtester_role_id', tab: 'playtester', label: 'Playtester-Rolle', find: 'alpha tester' },
|
||||||
{ id: 'birthday_role_id', tab: 'community', label: 'Geburtstags-Rolle', find: 'tagesrolle' },
|
{ id: 'birthday_role_id', tab: 'community', label: 'Geburtstags-Rolle', find: 'tagesrolle' },
|
||||||
{ id: 'autorole_id', tab: 'community', label: 'Auto-Rolle', find: 'startrolle beitritt automatisch' },
|
{ id: 'autorole_id', tab: 'rollen', label: 'Auto-Rolle', find: 'startrolle beitritt automatisch' },
|
||||||
|
|
||||||
/* ── Schalter & Werte in den Bereichen ──────────── */
|
/* ── Schalter & Werte in den Bereichen ──────────── */
|
||||||
{ id: 'commit_feed_enabled', tab: 'feeds', label: 'Push-Embeds posten', find: 'commit feed an aus' },
|
{ id: 'commit_feed_enabled', tab: 'feeds', label: 'Push-Embeds posten', find: 'commit feed an aus' },
|
||||||
@@ -50,14 +50,14 @@ export const SETTING_INDEX = [
|
|||||||
{ id: 'levels_enabled', tab: 'community', label: 'Level-System', find: 'xp rang erfahrung' },
|
{ id: 'levels_enabled', tab: 'community', label: 'Level-System', find: 'xp rang erfahrung' },
|
||||||
{ id: 'levels_announce', tab: 'community', label: 'Level-Up ankündigen', find: 'aufstieg nachricht' },
|
{ id: 'levels_announce', tab: 'community', label: 'Level-Up ankündigen', find: 'aufstieg nachricht' },
|
||||||
{ id: 'level_rewards', tab: 'community', label: 'Level-Belohnungen', find: 'rollen ab level' },
|
{ id: 'level_rewards', tab: 'community', label: 'Level-Belohnungen', find: 'rollen ab level' },
|
||||||
{ id: 'sticky_roles_enabled', tab: 'community', label: 'Rollen merken', find: 'sticky rejoin wiederherstellen' },
|
{ id: 'sticky_roles_enabled', tab: 'rollen', label: 'Rollen merken', find: 'sticky rejoin wiederherstellen' },
|
||||||
|
|
||||||
/* ── Willkommens-Karte ──────────────────────────── */
|
/* ── Willkommens-Karte ──────────────────────────── */
|
||||||
{ id: 'welcome_card_enabled', tab: 'support', label: 'Willkommens-Karte rendern', find: 'bild karte begrüßung' },
|
{ id: 'welcome_card_enabled', tab: 'brand', label: 'Willkommens-Karte rendern', find: 'bild karte begrüßung' },
|
||||||
{ id: 'welcome_card_kicker', tab: 'support', label: 'Willkommens-Karte: kleine Zeile', find: 'überschrift text karte' },
|
{ id: 'welcome_card_kicker', tab: 'brand', label: 'Willkommens-Karte: kleine Zeile', find: 'überschrift text karte' },
|
||||||
{ id: 'welcome_card_sub', tab: 'support', label: 'Willkommens-Karte: Zeile darunter', find: 'member nummer text' },
|
{ id: 'welcome_card_sub', tab: 'brand', label: 'Willkommens-Karte: Zeile darunter', find: 'member nummer text' },
|
||||||
{ id: 'welcome_card_image', tab: 'support', label: 'Willkommens-Karte: Hintergrundbild', find: 'grafik bild hintergrund' },
|
{ id: 'welcome_card_image', tab: 'brand', label: 'Willkommens-Karte: Hintergrundbild', find: 'grafik bild hintergrund' },
|
||||||
{ id: 'welcome_card_watermark', tab: 'support', label: 'Willkommens-Karte: Markenname', find: 'wasserzeichen hintergrund' },
|
{ id: 'welcome_card_watermark', tab: 'brand', label: 'Willkommens-Karte: Markenname', find: 'wasserzeichen hintergrund' },
|
||||||
|
|
||||||
/* ── Marke ──────────────────────────────────────── */
|
/* ── Marke ──────────────────────────────────────── */
|
||||||
{ id: 'brand_name', tab: 'brand', label: 'Markenname', find: 'footer embed name' },
|
{ id: 'brand_name', tab: 'brand', label: 'Markenname', find: 'footer embed name' },
|
||||||
@@ -71,8 +71,8 @@ export const SETTING_INDEX = [
|
|||||||
{ id: 'bug_report_repo', tab: 'system', label: 'Repo für Fehlermeldungen', find: 'bug issue gitea' },
|
{ id: 'bug_report_repo', tab: 'system', label: 'Repo für Fehlermeldungen', find: 'bug issue gitea' },
|
||||||
{ id: 'roadmap_repo', tab: 'system', label: 'Repo für die Roadmap', find: 'milestones meilensteine' },
|
{ id: 'roadmap_repo', tab: 'system', label: 'Repo für die Roadmap', find: 'milestones meilensteine' },
|
||||||
{ id: 'watchdog_urls', tab: 'system', label: 'Zu prüfende Adressen', find: 'watchdog erreichbarkeit uptime' },
|
{ id: 'watchdog_urls', tab: 'system', label: 'Zu prüfende Adressen', find: 'watchdog erreichbarkeit uptime' },
|
||||||
{ id: 'twitch_channel', target: 'youtube_channel_id', tab: 'system', label: 'Twitch-Kanal', find: 'stream live' },
|
{ id: 'twitch_channel', target: 'youtube_channel_id', tab: 'feeds', label: 'Twitch-Kanal', find: 'stream live' },
|
||||||
{ id: 'youtube_channel_id', tab: 'system', label: 'YouTube-Kanal', find: 'video rss' },
|
{ id: 'youtube_channel_id', tab: 'feeds', label: 'YouTube-Kanal', find: 'video rss' },
|
||||||
{ id: 'backup_enabled', tab: 'system', label: 'Datenbank-Sicherung', find: 'backup nächtlich' },
|
{ id: 'backup_enabled', tab: 'system', label: 'Datenbank-Sicherung', find: 'backup nächtlich' },
|
||||||
{ id: 'repo_backup_enabled', tab: 'system', label: 'Repository-Sicherung', find: 'gitea bundle backup' },
|
{ id: 'repo_backup_enabled', tab: 'system', label: 'Repository-Sicherung', find: 'gitea bundle backup' },
|
||||||
{ id: 'member_gate_enabled', tab: 'system', label: 'Login nur für Mitglieder', find: 'gate zugang sperren' },
|
{ id: 'member_gate_enabled', tab: 'system', label: 'Login nur für Mitglieder', find: 'gate zugang sperren' },
|
||||||
|
|||||||
@@ -1884,6 +1884,56 @@ label.toggle {
|
|||||||
font-size: .62rem; letter-spacing: .06em; color: var(--muted2); white-space: nowrap;
|
font-size: .62rem; letter-spacing: .06em; color: var(--muted2); white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Schlüssel stehen als Code — sonst verwechselt man O und 0 */
|
||||||
|
.pt-key { font-family: var(--mono); font-size: .74rem; color: var(--neon); }
|
||||||
|
.pt-wartet { font-family: var(--mono); font-size: .66rem; color: var(--muted2); }
|
||||||
|
|
||||||
|
/* ── DIALOG ────────────────────────────────────────── */
|
||||||
|
.dlg-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 1200;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(0, 0, 0, .82);
|
||||||
|
animation: lbIn .18s ease both;
|
||||||
|
}
|
||||||
|
.dlg {
|
||||||
|
width: min(34rem, 100%);
|
||||||
|
/* Hoher Inhalt scrollt im Kasten, nicht die Seite dahinter */
|
||||||
|
max-height: min(85vh, 46rem);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--bg2); border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; min-width: 0;
|
||||||
|
animation: riseIn .2s ease both;
|
||||||
|
}
|
||||||
|
.dlg.weit { width: min(48rem, 100%); }
|
||||||
|
.dlg-head {
|
||||||
|
display: flex; align-items: center; gap: .8rem;
|
||||||
|
padding: 1rem 1.2rem; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.dlg-title {
|
||||||
|
margin: 0; flex: 1; min-width: 0;
|
||||||
|
font-family: var(--mono); font-size: .72rem;
|
||||||
|
letter-spacing: .12em; text-transform: uppercase; color: var(--neon);
|
||||||
|
}
|
||||||
|
.dlg-body { padding: 1.2rem; overflow-y: auto; min-height: 0; }
|
||||||
|
.dlg-body > .field:last-child { margin-bottom: 0; }
|
||||||
|
.dlg-foot {
|
||||||
|
display: flex; gap: .6rem; justify-content: flex-end; flex-wrap: wrap;
|
||||||
|
padding: .9rem 1.2rem; border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auswahlliste im Dialog — eine Zeile je Person */
|
||||||
|
.dlg-pick { display: flex; flex-direction: column; gap: .35rem; }
|
||||||
|
.dlg-pick button {
|
||||||
|
display: flex; align-items: center; gap: .6rem; width: 100%;
|
||||||
|
padding: .6rem .8rem; text-align: left;
|
||||||
|
background: var(--bg3); border: 1px solid var(--border); color: var(--text);
|
||||||
|
font: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.dlg-pick button:hover:not(:disabled) { border-color: var(--neon); color: var(--neon); }
|
||||||
|
.dlg-pick button:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.dlg-pick .pick-hint { margin-left: auto; font-family: var(--mono); font-size: .62rem; color: var(--muted2); }
|
||||||
|
|
||||||
/* Emoji eines Ticket-Anliegens — gleiche Breite wie der Statuspunkt daneben,
|
/* Emoji eines Ticket-Anliegens — gleiche Breite wie der Statuspunkt daneben,
|
||||||
damit die Namen untereinander auf einer Linie stehen */
|
damit die Namen untereinander auf einer Linie stehen */
|
||||||
.tk-emoji { font-size: 1rem; line-height: 1; width: 1.2rem; text-align: center; flex: 0 0 auto; }
|
.tk-emoji { font-size: 1rem; line-height: 1; width: 1.2rem; text-align: center; flex: 0 0 auto; }
|
||||||
|
|||||||
@@ -299,6 +299,12 @@ const reserveKeyStmt = db.prepare(`
|
|||||||
RETURNING key
|
RETURNING key
|
||||||
`);
|
`);
|
||||||
const unreserveKeyStmt = db.prepare('UPDATE alpha_keys SET assigned_to = NULL, assigned_at = NULL WHERE key = ?');
|
const unreserveKeyStmt = db.prepare('UPDATE alpha_keys SET assigned_to = NULL, assigned_at = NULL WHERE key = ?');
|
||||||
|
const freeKeysStmt = db.prepare(
|
||||||
|
'SELECT key FROM alpha_keys WHERE assigned_to IS NULL ORDER BY key'
|
||||||
|
);
|
||||||
|
// Nur freie Schlüssel löschen: einen vergebenen zu entfernen würde die Spur
|
||||||
|
// verwischen, wer ihn hat — zurückziehen geht über unreserveAlphaKey.
|
||||||
|
const deleteFreeKeyStmt = db.prepare('DELETE FROM alpha_keys WHERE key = ? AND assigned_to IS NULL');
|
||||||
export const addAlphaKeys = db.transaction((keys) => {
|
export const addAlphaKeys = db.transaction((keys) => {
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const k of keys) added += insertAlphaKey.run(k).changes;
|
for (const k of keys) added += insertAlphaKey.run(k).changes;
|
||||||
@@ -309,6 +315,8 @@ export const assignedAlphaKeys = () => assignedKeysStmt.all();
|
|||||||
export const alphaKeyOf = (userId) => hasKeyForUserStmt.get(userId)?.key ?? null;
|
export const alphaKeyOf = (userId) => hasKeyForUserStmt.get(userId)?.key ?? null;
|
||||||
export const reserveAlphaKey = (userId) => reserveKeyStmt.get(userId)?.key ?? null;
|
export const reserveAlphaKey = (userId) => reserveKeyStmt.get(userId)?.key ?? null;
|
||||||
export const unreserveAlphaKey = (key) => unreserveKeyStmt.run(key);
|
export const unreserveAlphaKey = (key) => unreserveKeyStmt.run(key);
|
||||||
|
export const freeAlphaKeys = () => freeKeysStmt.all().map((r) => r.key);
|
||||||
|
export const deleteFreeAlphaKey = (key) => deleteFreeKeyStmt.run(key).changes > 0;
|
||||||
|
|
||||||
// Bewerbungs-Formulare (Modals) + eingereichte Bewerbungen
|
// Bewerbungs-Formulare (Modals) + eingereichte Bewerbungen
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
+9
-9
@@ -59,7 +59,7 @@ export const MODULES = [
|
|||||||
{
|
{
|
||||||
id: 'social', group: 'inhalte', name: 'Twitch & YouTube',
|
id: 'social', group: 'inhalte', name: 'Twitch & YouTube',
|
||||||
desc: 'Meldet Livestreams und neue Videos.',
|
desc: 'Meldet Livestreams und neue Videos.',
|
||||||
tab: 'system', default: true,
|
tab: 'feeds', default: true,
|
||||||
requires: [{ setting: 'social_announce_channel_id', label: 'Social-Kanal', create: { name: 'streams', topic: 'Twitch- und YouTube-Meldungen' } }],
|
requires: [{ setting: 'social_announce_channel_id', label: 'Social-Kanal', create: { name: 'streams', topic: 'Twitch- und YouTube-Meldungen' } }],
|
||||||
fields: [
|
fields: [
|
||||||
{ setting: 'twitch_channel', label: 'Twitch-Kanal', art: 'text', hint: 'Login-Name, z. B. d4rkst3r' },
|
{ setting: 'twitch_channel', label: 'Twitch-Kanal', art: 'text', hint: 'Login-Name, z. B. d4rkst3r' },
|
||||||
@@ -120,21 +120,21 @@ export const MODULES = [
|
|||||||
{
|
{
|
||||||
id: 'events', group: 'community', name: 'Event-Ankündigungen',
|
id: 'events', group: 'community', name: 'Event-Ankündigungen',
|
||||||
desc: 'Neue Discord-Events werden angekündigt und erscheinen auf der Events-Seite.',
|
desc: 'Neue Discord-Events werden angekündigt und erscheinen auf der Events-Seite.',
|
||||||
tab: 'system', default: true,
|
tab: 'community', default: true,
|
||||||
requires: [{ setting: 'events_announce_channel_id', label: 'Event-Kanal', create: { name: 'events', topic: 'Playtests, Streams, Runden' } }],
|
requires: [{ setting: 'events_announce_channel_id', label: 'Event-Kanal', create: { name: 'events', topic: 'Playtests, Streams, Runden' } }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'playtester', group: 'community', name: 'Playtester-Programm',
|
id: 'playtester', group: 'community', name: 'Playtester-Programm',
|
||||||
desc: 'Bewerbungs-Knopf, Rolle und Teilnehmerliste.',
|
desc: 'Bewerbungs-Knopf, Rolle und Teilnehmerliste.',
|
||||||
tab: 'community', default: true,
|
tab: 'playtester', default: true,
|
||||||
requires: [{ setting: 'playtester_role_id', label: 'Playtester-Rolle', create: { rolle: true, name: 'Playtester', farbe: '#23a55a' } }],
|
requires: [{ setting: 'playtester_role_id', label: 'Playtester-Rolle', create: { rolle: true, name: 'Playtester', farbe: '#23a55a' } }],
|
||||||
tool: { tab: 'community', label: 'Playtester-Liste' },
|
tool: { tab: 'playtester', label: 'Playtester-Liste' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'alpha_keys', group: 'community', name: 'Alpha-Keys',
|
id: 'alpha_keys', group: 'community', name: 'Alpha-Keys',
|
||||||
desc: 'Schlüssel-Pool mit Verteilung per Direktnachricht.',
|
desc: 'Schlüssel-Pool mit Verteilung per Direktnachricht.',
|
||||||
tab: 'community', default: true,
|
tab: 'playtester', default: true,
|
||||||
tool: { tab: 'community', label: 'Schlüssel-Pool' },
|
tool: { tab: 'playtester', label: 'Schlüssel-Vorrat' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'role_menus', group: 'community', name: 'Rollen-Menüs',
|
id: 'role_menus', group: 'community', name: 'Rollen-Menüs',
|
||||||
@@ -188,7 +188,7 @@ export const MODULES = [
|
|||||||
{
|
{
|
||||||
id: 'welcome', group: 'moderation', name: 'Willkommens-Karten',
|
id: 'welcome', group: 'moderation', name: 'Willkommens-Karten',
|
||||||
desc: 'Begrüßung neuer Mitglieder mit gerendertem Bild.',
|
desc: 'Begrüßung neuer Mitglieder mit gerendertem Bild.',
|
||||||
tab: 'support', default: true,
|
tab: 'brand', default: true,
|
||||||
requires: [{ setting: 'welcome_channel_id', label: 'Willkommens-Kanal', create: { name: 'willkommen', topic: 'Begruessung neuer Mitglieder' } }],
|
requires: [{ setting: 'welcome_channel_id', label: 'Willkommens-Kanal', create: { name: 'willkommen', topic: 'Begruessung neuer Mitglieder' } }],
|
||||||
fields: [
|
fields: [
|
||||||
{ setting: 'welcome_card_enabled', label: 'Karte rendern', art: 'schalter', hint: 'aus = nur das Text-Embed' },
|
{ setting: 'welcome_card_enabled', label: 'Karte rendern', art: 'schalter', hint: 'aus = nur das Text-Embed' },
|
||||||
@@ -206,7 +206,7 @@ export const MODULES = [
|
|||||||
{
|
{
|
||||||
id: 'autorole', group: 'moderation', name: 'Auto-Rolle',
|
id: 'autorole', group: 'moderation', name: 'Auto-Rolle',
|
||||||
desc: 'Neue Mitglieder bekommen automatisch eine Startrolle.',
|
desc: 'Neue Mitglieder bekommen automatisch eine Startrolle.',
|
||||||
tab: 'community', default: true,
|
tab: 'rollen', default: true,
|
||||||
requires: [{ setting: 'autorole_id', label: 'Auto-Rolle', create: { rolle: true, name: 'Member' } }],
|
requires: [{ setting: 'autorole_id', label: 'Auto-Rolle', create: { rolle: true, name: 'Member' } }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -217,7 +217,7 @@ export const MODULES = [
|
|||||||
{
|
{
|
||||||
id: 'sticky_roles', group: 'moderation', name: 'Rollen merken',
|
id: 'sticky_roles', group: 'moderation', name: 'Rollen merken',
|
||||||
desc: 'Wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder.',
|
desc: 'Wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder.',
|
||||||
tab: 'community', setting: 'sticky_roles_enabled', defaultOn: false,
|
tab: 'rollen', setting: 'sticky_roles_enabled', defaultOn: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bug_reports', group: 'moderation', name: 'Fehlermeldungen',
|
id: 'bug_reports', group: 'moderation', name: 'Fehlermeldungen',
|
||||||
|
|||||||
+96
-16
@@ -17,6 +17,7 @@ import {
|
|||||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||||
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
||||||
heartbeatBuckets, heartbeatFirst,
|
heartbeatBuckets, heartbeatFirst,
|
||||||
|
freeAlphaKeys, deleteFreeAlphaKey, removePlaytester,
|
||||||
listTicketCategories, createTicketCategory, updateTicketCategory, deleteTicketCategory,
|
listTicketCategories, createTicketCategory, updateTicketCategory, deleteTicketCategory,
|
||||||
listIncidents, listMonitored, createMonitored, updateMonitored, deleteMonitored,
|
listIncidents, listMonitored, createMonitored, updateMonitored, deleteMonitored,
|
||||||
deleteUserData,
|
deleteUserData,
|
||||||
@@ -38,7 +39,7 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men
|
|||||||
import { computeNextRun } from './scheduled-posts.js';
|
import { computeNextRun } from './scheduled-posts.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { removeDevlog } from '../bot/devlog-archive.js';
|
import { removeDevlog } from '../bot/devlog-archive.js';
|
||||||
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl, welcomeCard } from '../runtime-settings.js';
|
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl, welcomeCard, playtesterRoleId } from '../runtime-settings.js';
|
||||||
import { xpForLevel } from '../bot/levels.js';
|
import { xpForLevel } from '../bot/levels.js';
|
||||||
import { getMilestones } from '../gitea-api.js';
|
import { getMilestones } from '../gitea-api.js';
|
||||||
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
|
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
|
||||||
@@ -158,6 +159,28 @@ export function registerApiRoutes(app, client) {
|
|||||||
return { playtesters: listPlaytesters() };
|
return { playtesters: listPlaytesters() };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Playtester entfernen. Die Rolle geht mit — nur aus der Liste zu streichen
|
||||||
|
// und die Rolle stehen zu lassen wäre eine Halbwahrheit, die spätestens
|
||||||
|
// beim nächsten Verteilen auffällt.
|
||||||
|
app.delete('/api/playtesters/:id', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'community')) return;
|
||||||
|
const userId = String(request.params.id);
|
||||||
|
const weg = removePlaytester(userId);
|
||||||
|
let rolleWeg = false;
|
||||||
|
const roleId = playtesterRoleId();
|
||||||
|
if (roleId) {
|
||||||
|
try {
|
||||||
|
const gid = discordGuildId();
|
||||||
|
const guild = gid ? client.guilds.cache.get(gid) : client.guilds.cache.first();
|
||||||
|
const member = await guild?.members.fetch(userId);
|
||||||
|
await member?.roles.remove(roleId);
|
||||||
|
rolleWeg = true;
|
||||||
|
} catch { /* nicht mehr auf dem Server, oder Rolle steht über dem Bot */ }
|
||||||
|
}
|
||||||
|
logAudit(getSessionUser(request), 'playtester entfernt', userId);
|
||||||
|
return { ok: weg, rolleWeg, playtesters: listPlaytesters() };
|
||||||
|
});
|
||||||
|
|
||||||
// Community-Wünsche (Top 20) + Commit-Heatmap — öffentlich
|
// Community-Wünsche (Top 20) + Commit-Heatmap — öffentlich
|
||||||
app.get('/api/wishes', async (request) => {
|
app.get('/api/wishes', async (request) => {
|
||||||
const user = getSessionUser(request);
|
const user = getSessionUser(request);
|
||||||
@@ -1286,11 +1309,42 @@ ${rssItems}
|
|||||||
|
|
||||||
// --- Alpha-Keys (Admin) ---
|
// --- Alpha-Keys (Admin) ---
|
||||||
|
|
||||||
const keyStatus = () => ({
|
/** Wortlaut der Schlüssel-DM — an zwei Stellen gebraucht */
|
||||||
|
const keyText = (key) =>
|
||||||
|
`🎟️ **Dein EcoGame-Alpha-Key:**\n\`\`\`\n${key}\n\`\`\`\nViel Spaß beim Testen — Feedback gern per \`/bug\` oder \`/wunsch\`! 💛`;
|
||||||
|
|
||||||
|
const keyStatus = () => {
|
||||||
|
const belegt = new Map(assignedAlphaKeys().map((k) => [k.assigned_to, k]));
|
||||||
|
return {
|
||||||
free: freeAlphaKeyCount(),
|
free: freeAlphaKeyCount(),
|
||||||
|
// Der freie Vorrat selbst, nicht nur seine Größe: einen vertippten
|
||||||
|
// Schlüssel findet man sonst nie wieder
|
||||||
|
freieKeys: freeAlphaKeys(),
|
||||||
assigned: assignedAlphaKeys(),
|
assigned: assignedAlphaKeys(),
|
||||||
playtestersWithout: listPlaytesters().filter((p) => !alphaKeyOf(p.user_id)),
|
// Jeder Playtester mit seinem Schlüssel daneben — die Liste ist die
|
||||||
});
|
// eine Stelle, an der man beides zusammen sieht
|
||||||
|
playtesters: listPlaytesters().map((t) => ({
|
||||||
|
...t,
|
||||||
|
key: belegt.get(t.user_id)?.key ?? null,
|
||||||
|
keySeit: belegt.get(t.user_id)?.assigned_at ?? null,
|
||||||
|
})),
|
||||||
|
playtestersWithout: listPlaytesters().filter((t) => !alphaKeyOf(t.user_id)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Einen Schlüssel per DM zustellen. Klappt es nicht, bleibt er frei. */
|
||||||
|
async function keySenden(userId) {
|
||||||
|
const key = reserveAlphaKey(userId);
|
||||||
|
if (!key) return { ok: false, grund: 'Vorrat leer' };
|
||||||
|
try {
|
||||||
|
const user = await client.users.fetch(userId);
|
||||||
|
await user.send(keyText(key));
|
||||||
|
return { ok: true, key };
|
||||||
|
} catch {
|
||||||
|
unreserveAlphaKey(key);
|
||||||
|
return { ok: false, grund: 'Direktnachricht nicht zustellbar' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/api/alphakeys', async (request, reply) => {
|
app.get('/api/alphakeys', async (request, reply) => {
|
||||||
if (requireScope(request, reply, 'community')) return;
|
if (requireScope(request, reply, 'community')) return;
|
||||||
@@ -1315,23 +1369,49 @@ ${rssItems}
|
|||||||
let sent = 0;
|
let sent = 0;
|
||||||
const failed = [];
|
const failed = [];
|
||||||
for (const p of keyStatus().playtestersWithout) {
|
for (const p of keyStatus().playtestersWithout) {
|
||||||
const key = reserveAlphaKey(p.user_id);
|
const res = await keySenden(p.user_id);
|
||||||
if (!key) break; // Pool leer
|
if (!res.ok && res.grund === 'Vorrat leer') break;
|
||||||
try {
|
if (res.ok) sent++;
|
||||||
const user = await client.users.fetch(p.user_id);
|
else failed.push(p.username ?? p.user_id);
|
||||||
await user.send(
|
|
||||||
`🎟️ **Dein EcoGame-Alpha-Key:**\n\`\`\`\n${key}\n\`\`\`\nViel Spaß beim Testen — Feedback gern per \`/bug\` oder \`/wunsch\`! 💛`
|
|
||||||
);
|
|
||||||
sent++;
|
|
||||||
} catch {
|
|
||||||
unreserveAlphaKey(key);
|
|
||||||
failed.push(p.username ?? p.user_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
request.log.info(`Alpha-Keys verteilt: ${sent} gesendet, ${failed.length} fehlgeschlagen`);
|
request.log.info(`Alpha-Keys verteilt: ${sent} gesendet, ${failed.length} fehlgeschlagen`);
|
||||||
|
logAudit(getSessionUser(request), 'alpha-keys verteilt', `${sent} verschickt`);
|
||||||
return { ok: true, sent, failed, ...keyStatus() };
|
return { ok: true, sent, failed, ...keyStatus() };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Gezielt an eine Person — häufiger gebraucht als „alle auf einmal"
|
||||||
|
app.post('/api/alphakeys/assign', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'community')) return;
|
||||||
|
const userId = String(request.body?.user_id ?? '').trim();
|
||||||
|
if (!userId) return reply.code(400).send({ error: 'user_id fehlt.' });
|
||||||
|
if (alphaKeyOf(userId)) return reply.code(400).send({ error: 'Hat schon einen Schlüssel.' });
|
||||||
|
const res = await keySenden(userId);
|
||||||
|
if (!res.ok) return reply.code(400).send({ error: res.grund });
|
||||||
|
logAudit(getSessionUser(request), 'alpha-key vergeben', userId);
|
||||||
|
return { ok: true, ...keyStatus() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zurückziehen: der Schlüssel landet wieder im Vorrat. Die Person hat ihn
|
||||||
|
// per DM natürlich weiterhin — das ist eine Buchhaltung, keine Sperre.
|
||||||
|
app.post('/api/alphakeys/revoke', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'community')) return;
|
||||||
|
const key = String(request.body?.key ?? '').trim();
|
||||||
|
if (!key) return reply.code(400).send({ error: 'key fehlt.' });
|
||||||
|
unreserveAlphaKey(key);
|
||||||
|
logAudit(getSessionUser(request), 'alpha-key zurückgezogen', key);
|
||||||
|
return { ok: true, ...keyStatus() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/alphakeys/:key', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'community')) return;
|
||||||
|
const key = decodeURIComponent(String(request.params.key));
|
||||||
|
if (!deleteFreeAlphaKey(key)) {
|
||||||
|
return reply.code(400).send({ error: 'Nur freie Schlüssel lassen sich löschen.' });
|
||||||
|
}
|
||||||
|
logAudit(getSessionUser(request), 'alpha-key gelöscht', key);
|
||||||
|
return { ok: true, ...keyStatus() };
|
||||||
|
});
|
||||||
|
|
||||||
// --- Bewerbungs-Formulare (Admin) ---
|
// --- Bewerbungs-Formulare (Admin) ---
|
||||||
|
|
||||||
const formToJson = (f) => ({ ...f, questions: JSON.parse(f.questions || '[]') });
|
const formToJson = (f) => ({ ...f, questions: JSON.parse(f.questions || '[]') });
|
||||||
|
|||||||
Reference in New Issue
Block a user