Texte: 27 Bot-Nachrichten im Panel bearbeitbar
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Bisher standen Begrüßung, Level-Ansage, Ticket-Texte und alle
Bestätigungs-Nachrichten fest im Code. Wer den Ton ändern wollte, musste
die Quelldateien anfassen und neu deployen — für alle außer mir also gar
nicht.

src/templates.js hält jetzt alle nach außen gehenden Texte an einer
Stelle: Standardtext, Platzhalter mit Erklärung und die Zuordnung zum
Modul. Der neue Setup-Tab „Texte" zeigt sie als Karten mit Textfeld;
die Bausteine darunter fügen sich per Klick an der Cursor-Position ein.

Der Standard bleibt im Code, die Datenbank speichert nur echte
Abweichungen. Ein leeres Feld heißt deshalb „wieder Standard" — und
angepasste Vorlagen sind an einer Marke erkennbar. Vorlagen zu
ausgeschalteten Modulen sind als solche gekennzeichnet, damit niemand
einen Text feilt, den gerade nichts sendet.

Unbekannte Platzhalter bleiben stehen statt still zu verschwinden, damit
ein Tippfehler in der Nachricht sichtbar wird.

Die Route heißt /api/texts, nicht /api/templates — letzteres sind seit je
die gespeicherten Composer-Nachrichten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:06:27 +02:00
co-authored by Claude Fable 5
parent 59aad27534
commit dadd75d452
17 changed files with 560 additions and 60 deletions
+124 -1
View File
@@ -5,7 +5,7 @@ import {
IconStatus, IconBrand, IconFeeds, IconCommunity, IconRoles, IconSupport,
IconApplications, IconComposer, IconServer, IconSystem, IconApi, IconTeam,
IconLock, IconBot, IconUpload, IconChevronDown, IconKey, IconPages, IconX,
IconHome as IconLayers, IconWarning, IconCheck,
IconHome as IconLayers, IconWarning, IconCheck, IconMessage, IconRefresh,
} from '../icons.jsx';
import { Markdown } from '../markdown.jsx';
@@ -19,6 +19,7 @@ const API_SCOPES = [
const TABS = [
{ id: 'status', Icon: IconStatus, label: 'Status' },
{ id: 'module', Icon: IconLayers, label: 'Module' },
{ id: 'texte', Icon: IconMessage, label: 'Texte' },
{ id: 'brand', Icon: IconBrand, label: 'Brand' },
{ id: 'feeds', Icon: IconFeeds, label: 'Feeds' },
{ id: 'community', Icon: IconCommunity, label: 'Community' },
@@ -37,6 +38,7 @@ const TABS = [
const TAB_SCOPES = {
status: 'any',
module: 'settings',
texte: 'settings',
brand: null,
feeds: 'settings',
community: 'community',
@@ -175,6 +177,10 @@ export default function Settings({ me }) {
// Module (An/Aus für alle Funktionen)
const [modules, setModules] = useState([]);
const [moduleGroups, setModuleGroups] = useState([]);
// Text-Vorlagen (was der Bot nach außen schreibt)
const [templates, setTemplates] = useState([]);
const [templateGroups, setTemplateGroups] = useState([]);
const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text
// Eigene Seiten (Regeln, Über uns, …)
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
const [pages, setPages] = useState([]);
@@ -236,6 +242,10 @@ export default function Settings({ me }) {
setModules(d.modules);
setModuleGroups(d.groups);
}).catch(() => {});
apiGet('/api/texts').then((d) => {
setTemplates(d.templates);
setTemplateGroups(d.groups);
}).catch(() => {});
}, [me.admin, me.scopes?.length]);
useEffect(() => {
@@ -281,6 +291,34 @@ export default function Settings({ me }) {
}
}
/** Vorlage speichern. Leerer Text stellt den Standard wieder her. */
async function saveTemplate(id, text) {
try {
const res = await apiPut(`/api/texts/${encodeURIComponent(id)}`, { text });
setTemplates(res.templates);
setTplDraft(({ [id]: _drop, ...rest }) => rest);
flash(text.trim() ? '✓ Text gespeichert' : '✓ Auf den Standard zurückgesetzt');
} catch {
flash('✗ Speichern fehlgeschlagen');
}
}
/** Platzhalter an der Cursor-Position einfügen */
function insertPlaceholder(tpl, key) {
const field = document.getElementById(`tpl-${tpl.id}`);
const current = tplDraft[tpl.id] ?? tpl.text;
const at = field?.selectionStart ?? current.length;
const next = `${current.slice(0, at)}{${key}}${current.slice(field?.selectionEnd ?? at)}`;
setTplDraft((old) => ({ ...old, [tpl.id]: next }));
// Cursor hinter den eingefügten Platzhalter setzen
requestAnimationFrame(() => {
if (!field) return;
field.focus();
const pos = at + key.length + 2;
field.setSelectionRange(pos, pos);
});
}
/** Titel → Adress-Kürzel (Umlaute mit auflösen) */
function slugify(value) {
return String(value).toLowerCase()
@@ -1756,6 +1794,90 @@ export default function Settings({ me }) {
</div>
);
const tabTexte = (
<div className="settings-section">
<h2 className="settings-title">// Texte</h2>
<p className="section-intro">
Alles, was der Bot nach außen schreibt Begrüßungen, Bestätigungen,
Direktnachrichten. Die Bausteine in geschweiften Klammern setzt der Bot
beim Senden ein; klick einen an, um ihn an der Cursor-Position einzufügen.
Ein leeres Feld bedeutet: wieder der Standardtext.
</p>
{templateGroups.map((group) => {
const items = templates.filter((t) => t.group === group.id);
if (items.length === 0) return null;
return (
<div key={group.id} style={{ marginBottom: '1.8rem' }}>
<h3 className="settings-title" style={{ fontSize: '.66rem', marginBottom: '.8rem' }}>
{group.label}
</h3>
<div className="tpl-list">
{items.map((t) => {
const draft = tplDraft[t.id];
const value = draft ?? t.text;
const dirty = draft != null && draft !== t.text;
const off = t.module && modules.some((m) => m.id === t.module && !m.enabled);
return (
<div className={`card tpl-card${t.long ? ' long' : ''}${dirty ? ' dirty' : ''}`} key={t.id}>
<div className="tpl-head">
<span className="tpl-name">{t.name}</span>
{t.customized && <span className="tpl-badge">angepasst</span>}
{off && <span className="tpl-badge muted">Modul aus</span>}
</div>
<textarea
id={`tpl-${t.id}`}
className="tpl-input"
rows={t.long ? 4 : 2}
value={value}
spellCheck={false}
onChange={(e) => setTplDraft((old) => ({ ...old, [t.id]: e.target.value }))}
/>
{t.vars.length > 0 && (
<div className="tpl-vars">
{t.vars.map((v) => (
<button
key={v.key}
className="tpl-var"
title={v.desc}
onClick={() => insertPlaceholder(t, v.key)}
>
{`{${v.key}}`}
</button>
))}
</div>
)}
<div className="tpl-actions">
<button
className="btn"
disabled={!dirty}
onClick={() => saveTemplate(t.id, value)}
>
Speichern
</button>
{(t.customized || dirty) && (
<button
className="btn btn-ghost with-icon"
onClick={() => saveTemplate(t.id, '')}
title="Standardtext wiederherstellen"
>
<IconRefresh size={13} /> Standard
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
const tabSeiten = (
<div className="settings-section">
<h2 className="settings-title">// Eigene Seiten</h2>
@@ -1943,6 +2065,7 @@ export default function Settings({ me }) {
const content = {
status: tabStatus,
module: tabModule,
texte: tabTexte,
brand: tabBrand,
feeds: tabFeeds,
community: tabCommunity,
+45
View File
@@ -1650,3 +1650,48 @@ button.with-icon, a.with-icon { justify-content: center; }
}
.mod-switch.on .mod-knob { transform: translateX(18px); background: #0a0a0a; }
.mod-switch:hover { border-color: var(--neon); }
/* ── TEXT-VORLAGEN ─────────────────────────────────── */
/* Kurze Vorlagen teilen sich eine Zeile, mehrzeilige bekommen die volle Breite */
.tpl-list {
display: grid; gap: .9rem;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
}
.tpl-card.long { grid-column: 1 / -1; }
.tpl-card {
display: flex; flex-direction: column; gap: .6rem;
padding: 1.1rem 1.2rem;
transition: border-color .2s;
}
.tpl-card.dirty { border-color: var(--neon); }
.tpl-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.tpl-name {
font-family: var(--display); font-size: 1.05rem;
letter-spacing: .04em; color: var(--text);
}
.tpl-badge {
font-family: var(--mono); font-size: .55rem;
letter-spacing: .12em; text-transform: uppercase;
color: var(--neon); border: 1px solid var(--neon);
border-radius: 4px; padding: .1rem .4rem;
}
.tpl-badge.muted { color: var(--muted2); border-color: var(--border); }
.tpl-input {
width: 100%; resize: vertical;
font-family: var(--mono); font-size: .82rem; line-height: 1.55;
color: var(--text); background: var(--bg2);
border: 1px solid var(--border); border-radius: 5px;
padding: .6rem .7rem;
}
.tpl-input:focus { outline: none; border-color: var(--neon); }
.tpl-vars { display: flex; flex-wrap: wrap; gap: .35rem; }
.tpl-var {
font-family: var(--mono); font-size: .6rem; letter-spacing: .05em;
color: var(--muted); background: var(--bg3);
border: 1px solid var(--border); border-radius: 4px;
padding: .18rem .45rem; cursor: pointer;
transition: color .15s, border-color .15s;
}
.tpl-var:hover { color: var(--neon); border-color: var(--neon); }
.tpl-actions { display: flex; gap: .5rem; align-items: center; }
.tpl-actions .btn { font-size: .6rem; padding: .4rem .9rem; }