Playtester bekommen einen eigenen Bereich, Module ziehen um
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

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:
2026-08-01 09:49:27 +02:00
co-authored by Claude Opus 5
parent bb2aee7818
commit ddd7610d6c
8 changed files with 559 additions and 208 deletions
+38
View File
@@ -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
);
}
+338 -163
View File
@@ -6,11 +6,12 @@ import {
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh,
IconSearch, IconSystem as IconTune, IconStar,
IconSearch, IconSystem as IconTune, IconStar, IconGame, IconTrash,
} from '../icons.jsx';
import { moduleIcon } from '../module-icons.jsx';
import ModulePage from '../components/ModulePage.jsx';
import UptimeTrack from '../components/UptimeTrack.jsx';
import Dialog from '../components/Dialog.jsx';
import { findSettings, SETTING_INDEX } from '../setting-index.js';
import { Markdown } from '../markdown.jsx';
@@ -59,6 +60,8 @@ const TABS = [
find: 'rollen menü selbstbedienung buttons reaction roles' },
{ id: 'support', Icon: IconSupport, label: 'Support', section: 'community',
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',
find: 'formular fragen bewerbung prüfen annehmen ablehnen' },
@@ -88,6 +91,7 @@ const TAB_SCOPES = {
community: 'community',
rollen: 'rollen',
support: 'settings',
playtester: 'community',
bewerbungen: 'bewerbungen',
composer: 'content',
seiten: 'content',
@@ -298,7 +302,6 @@ export default function Settings({ me }) {
const [error, setError] = useState(null);
const [feedback, setFeedback] = useState(null);
// Community
const [playtesters, setPlaytesters] = useState([]);
// Rollen-Menüs
const emptyMenu = {
id: null, title: '', description: '', channel_id: '', exclusive: false,
@@ -312,6 +315,9 @@ export default function Settings({ me }) {
const [twitchCreds, setTwitchCreds] = useState({ id: '', secret: '' });
// Alpha-Keys
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('');
// Bewerbungs-Formulare
const emptyForm = {
@@ -459,7 +465,6 @@ export default function Settings({ me }) {
})
.catch((e) => setError(e.status));
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {});
apiGet('/api/rolemenus').then((d) => setMenus(d.menus)).catch(() => {});
apiGet('/api/tags').then((d) => setTags(d.tags)).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() {
if (!window.confirm(`Alpha-Keys jetzt per DM an ${alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen?`)) return;
try {
@@ -1400,6 +1447,106 @@ export default function Settings({ me }) {
);
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 = (
<>
<div className="settings-section bot-card">
@@ -1482,6 +1629,7 @@ export default function Settings({ me }) {
<div className="settings-actions">
<button className="btn btn-save" onClick={saveBrand}>Speichern &amp; anwenden</button>
</div>
{willkommensKarte}
</>
);
@@ -1500,6 +1648,32 @@ export default function Settings({ me }) {
/>
<button className="btn" onClick={() => sendTest('devlog')}>Test</button>
</div>
<div className="settings-section">
<h2 className="settings-title">// Twitch &amp; 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 className="field" data-setting="commit_channel_id">
<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 className="settings-section">
<h2 className="settings-title">// Playtester</h2>
<div className="field" data-setting="playtester_role_id">
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
<h2 className="settings-title">// Events</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={roleItems}
value={form.playtester_role_id ?? ''}
onChange={(v) => setForm({ ...form, playtester_role_id: v })}
items={channelItems}
value={form.events_announce_channel_id ?? ''}
onChange={(v) => setForm({ ...form, events_announce_channel_id: v })}
empty=" deaktiviert "
/>
</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 className="settings-section">
@@ -2046,15 +2177,6 @@ export default function Settings({ me }) {
empty="— deaktiviert —"
/>
</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">
<label>Mod-Log-Kanal <span className="field-hint">PRIVAT gelöschte/bearbeitete Nachrichten</span></label>
<PickSelect
@@ -2065,87 +2187,170 @@ export default function Settings({ me }) {
/>
</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 })}
const tabPlaytester = (
<>
<div className="settings-section">
<h2 className="settings-title">// Playtester</h2>
<p className="section-intro">
Wer sich über den Knopf aus <strong>/playtester-setup</strong> meldet, landet
hier mit Rolle. Schlüssel gehen per Direktnachricht raus; kommt sie nicht
an, bleibt der Schlüssel im Vorrat.
</p>
<div className="field" data-setting="playtester_role_id">
<label>Playtester-Rolle <span className="field-hint">Bewerbungs-Post via /playtester-setup; leer = aus</span></label>
<PickSelect
items={roleItems}
value={form.playtester_role_id ?? ''}
onChange={(v) => setForm({ ...form, playtester_role_id: v })}
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 && (
<>
<img className="welcome-preview" src={welcomePreviewUrl} alt="Vorschau der Willkommens-Karte" />
<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>
<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">
{[
['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>
{(alphaKeys?.playtesters?.length ?? 0) === 0 ? (
<p className="notice">Noch niemand dabei.</p>
) : (
<div className="key-list">
{alphaKeys.playtesters.map((p) => (
<div className="key-row" key={p.user_id}>
<span className="key-name">{p.username}</span>
<span className="key-scopes">
{p.key
? <code className="pt-key">{p.key}</code>
: <span className="pt-wartet">wartet auf Schlüssel</span>}
</span>
<span className="key-used">seit {p.applied_at?.slice(0, 10)}</span>
{p.key ? (
<button className="btn btn-mini" title="Schlüssel wieder freigeben" onClick={() => keyZurueck(p.key)}>
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 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 className="settings-section">
<h2 className="settings-title">// Schlüssel-Vorrat</h2>
<div className="field-row" style={{ marginBottom: '1rem' }}>
<button className="btn" onClick={() => setKeyDialog(true)}>
<IconKey size={14} /> Schlüssel nachlegen
</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>
{(alphaKeys?.freieKeys?.length ?? 0) === 0 ? (
<p className="notice">Der Vorrat ist leer.</p>
) : (
<div className="key-list">
{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_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>
)}
{alphaKeys?.assigned?.length > 0 && (
<>
<h3 className="tm-form-title" style={{ marginTop: '1.6rem' }}>Vergeben</h3>
<div className="key-list">
{alphaKeys.assigned.map((k) => (
<div className="key-row" key={k.key}>
<span className="key-name"><code className="pt-key">{k.key}</code></span>
<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>
{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>
)}
</div>
</>
);
@@ -2468,38 +2673,7 @@ export default function Settings({ me }) {
</div>
<div className="settings-section">
<h2 className="settings-title">// Events, Social &amp; Voice</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>
<h2 className="settings-title">// Sprachkanäle</h2>
<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>
<PickSelect
@@ -3404,6 +3578,7 @@ export default function Settings({ me }) {
community: tabCommunity,
rollen: tabRollen,
support: tabSupport,
playtester: tabPlaytester,
bewerbungen: tabBewerbungen,
composer: tabComposer,
seiten: tabSeiten,
+13 -13
View File
@@ -25,10 +25,10 @@ export const SETTING_INDEX = [
{ 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: '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: 'events_announce_channel_id', tab: 'system', label: 'Event-Kanal', find: 'events ankündigung' },
{ id: 'social_announce_channel_id', tab: 'system', label: 'Social-Kanal', find: 'twitch youtube live stream' },
{ id: 'events_announce_channel_id', tab: 'community', label: 'Event-Kanal', find: 'events ankündigung' },
{ 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: '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' },
@@ -36,9 +36,9 @@ export const SETTING_INDEX = [
/* ── Rollen ─────────────────────────────────────── */
{ 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: '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 ──────────── */
{ 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_announce', tab: 'community', label: 'Level-Up ankündigen', find: 'aufstieg nachricht' },
{ 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 ──────────────────────────── */
{ id: 'welcome_card_enabled', tab: 'support', 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_sub', tab: 'support', 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_watermark', tab: 'support', label: 'Willkommens-Karte: Markenname', find: 'wasserzeichen hintergrund' },
{ id: 'welcome_card_enabled', tab: 'brand', label: 'Willkommens-Karte rendern', find: 'bild karte begrüßung' },
{ id: 'welcome_card_kicker', tab: 'brand', label: 'Willkommens-Karte: kleine Zeile', find: 'überschrift text karte' },
{ id: 'welcome_card_sub', tab: 'brand', label: 'Willkommens-Karte: Zeile darunter', find: 'member nummer text' },
{ id: 'welcome_card_image', tab: 'brand', label: 'Willkommens-Karte: Hintergrundbild', find: 'grafik bild hintergrund' },
{ id: 'welcome_card_watermark', tab: 'brand', label: 'Willkommens-Karte: Markenname', find: 'wasserzeichen hintergrund' },
/* ── Marke ──────────────────────────────────────── */
{ 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: '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: 'twitch_channel', target: 'youtube_channel_id', tab: 'system', label: 'Twitch-Kanal', find: 'stream live' },
{ id: 'youtube_channel_id', tab: 'system', label: 'YouTube-Kanal', find: 'video rss' },
{ id: 'twitch_channel', target: 'youtube_channel_id', tab: 'feeds', label: 'Twitch-Kanal', find: 'stream live' },
{ 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: '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' },
+50
View File
@@ -1884,6 +1884,56 @@ label.toggle {
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,
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; }