Config-Layout: breiter, zweispaltig, Schalter lesbar, Texte aufklappbar
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Drei Sachen, die die Seite erschlagend gemacht haben.

Die Schalter klebten am Text, weil `.field > label` spezifischer ist als
`.toggle` — die Regel hat den Schalter zu einem Block in Versalien gemacht
und Beschriftung plus Hinweis in eine Zeile gequetscht. Jetzt steht der
Schalter links, Beschriftung und Hinweis darüber/darunter, in normaler
Schrift. Der Schalter ist außerdem so groß wie der auf den Modul-Karten.

Der Inhalt war auf 1060px begrenzt, obwohl hier Formulare stehen und keine
Fließtexte. Jetzt 1440px, und die reinen Formular-Tabs (Feeds, Community,
Support, System) stellen ihre Blöcke ab 1180px nebeneinander. Feeds ist
damit 1448 statt 2453 Pixel hoch. Tabs mit Editor oder Vorschau —
Composer, Seiten, Server, Brand — bleiben einspaltig.

Der Texte-Tab zeigte 27 offene Textfelder gleichzeitig. Jetzt ist es eine
Liste zum Aufklappen: pro Zeile Name und erste Zeile des Texts, geöffnet
wird eine nach der anderen. Ungespeicherte Änderungen sind an der Marke
und einem Balken am Rand erkennbar.

Nebenbei: eine geänderte Adresse (#feeds) wechselte den Bereich nicht,
weil niemand auf hashchange gehört hat. Aufgefallen ist das erst, weil ein
Test durch reine Hash-Navigation stillschweigend auf dem alten Tab blieb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
D4rkst3randClaude Fable 5 committed 2026-07-31 16:33:09 +02:00
1 parent a32c267dda
commit 90361fa55f
2 files changed
+150 -35

No files matched your search

+45 -11
View File
@@ -67,6 +67,10 @@ const TABS = [
find: 'mitarbeiter rechte bereiche protokoll audit zugriff' },
];
// Tabs, die nur aus Formular-Blöcken bestehen — die dürfen zweispaltig laufen.
// Alles mit Editor, Vorschau oder Liste bleibt einspaltig.
const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']);
// Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
const TAB_SCOPES = {
status: 'any',
@@ -222,6 +226,7 @@ export default function Settings({ me }) {
const [templates, setTemplates] = useState([]);
const [templateGroups, setTemplateGroups] = useState([]);
const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text
const [openTpl, setOpenTpl] = useState(null); // aufgeklappte Vorlage
// Eigene Seiten (Regeln, Über uns, …)
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
const [pages, setPages] = useState([]);
@@ -300,6 +305,16 @@ export default function Settings({ me }) {
if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription);
}, [data]);
// Zurück-Taste und von Hand geänderte Adressen sollen den Bereich mitwechseln
useEffect(() => {
const onHash = () => {
const id = TABS.find((t) => t.id === window.location.hash.slice(1))?.id;
if (id) setTabState(id);
};
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
// Falls der aktive Tab nicht (mehr) erlaubt ist → auf den ersten erlaubten wechseln
useEffect(() => {
if (!canTab(tab) && visibleTabs.length > 0) setTab(visibleTabs[0].id);
@@ -1890,10 +1905,10 @@ export default function Settings({ me }) {
<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.
Alles, was der Bot nach außen schreibt. Zeile anklicken zum Bearbeiten —
die Bausteine in geschweiften Klammern setzt der Bot beim Senden ein,
ein Klick darauf fügt sie an der Cursor-Position ein. Ein leeres Feld
bedeutet: wieder der Standardtext.
</p>
{templateGroups.map((group) => {
@@ -1910,18 +1925,34 @@ export default function Settings({ me }) {
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);
const open = openTpl === t.id;
return (
<div className={`card tpl-card${t.long ? ' long' : ''}${dirty ? ' dirty' : ''}`} key={t.id}>
<div className="tpl-head">
<div
className={`tpl-row${open ? ' open' : ''}${dirty ? ' dirty' : ''}`}
key={t.id}
>
<button
className="tpl-head"
aria-expanded={open}
onClick={() => setOpenTpl(open ? null : t.id)}
>
<span className="tpl-head-main">
<span className="tpl-name">{t.name}</span>
{t.customized && <span className="tpl-badge">angepasst</span>}
{/* Zusammengeklappt reicht die erste Zeile als Erkennungszeichen */}
<span className="tpl-peek">{value.split('\n')[0]}</span>
</span>
{dirty && <span className="tpl-badge">ungespeichert</span>}
{!dirty && t.customized && <span className="tpl-badge">angepasst</span>}
{off && <span className="tpl-badge muted">Modul aus</span>}
</div>
<IconChevronDown className={`tpl-caret${open ? ' up' : ''}`} size={15} />
</button>
{open && (
<div className="tpl-body">
<textarea
id={`tpl-${t.id}`}
className="tpl-input"
rows={t.long ? 4 : 2}
rows={t.long ? 5 : 2}
value={value}
spellCheck={false}
onChange={(e) => setTplDraft((old) => ({ ...old, [t.id]: e.target.value }))}
@@ -1929,11 +1960,12 @@ export default function Settings({ me }) {
{t.vars.length > 0 && (
<div className="tpl-vars">
<span className="tpl-vars-label">Bausteine:</span>
{t.vars.map((v) => (
<button
key={v.key}
className="tpl-var"
title={v.desc}
title={`${v.desc} — einfügen`}
onClick={() => insertPlaceholder(t, v.key)}
>
{`{${v.key}}`}
@@ -1961,6 +1993,8 @@ export default function Settings({ me }) {
)}
</div>
</div>
)}
</div>
);
})}
</div>
@@ -2229,7 +2263,7 @@ export default function Settings({ me }) {
})}
</nav>
<div className="settings-content">
<div className={`settings-content${DENSE_TABS.has(tab) ? ' dense' : ''}`}>
{content}
{showSave && (
<div className="settings-actions">
+105 -24
View File
@@ -622,7 +622,9 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
}
/* ── SETTINGS-LAYOUT (Sidebar + Content) ───────────── */
.content-wide { max-width: 1060px; }
/* Config-Seite: deutlich breiter als Lesetexte — hier stehen Formulare und
Karten nebeneinander, schmal erzwingt sonst unnötiges Scrollen. */
.content-wide { max-width: 1440px; padding-left: 2rem; padding-right: 2rem; }
.settings-layout {
display: flex;
@@ -710,6 +712,17 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
.settings-content { flex: 1; min-width: 0; animation: riseIn .3s ease both; }
/* Tabs, die nur aus Formular-Blöcken bestehen, stellen ihre Blöcke ab genug
Breite nebeneinander — halbiert das Scrollen, ohne das Innenleben eines
Blocks anzufassen. Tabs mit Editoren und Vorschauen bleiben einspaltig. */
@media (min-width: 1180px) {
.settings-content.dense { columns: 2; column-gap: 1.25rem; }
.settings-content.dense > .settings-section {
break-inside: avoid;
margin-top: 0;
}
}
.section-intro {
color: var(--muted);
font-weight: 300;
@@ -861,26 +874,55 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
transition: border-color .2s;
}
.field select:focus, .field input[type="text"]:focus { border-color: var(--neon); }
/* Auf breiten Schirmen sollen Eingabefelder nicht über die halbe Seite laufen */
.field select, .field input[type="text"], .field input[type="number"] { max-width: 44rem; }
.field select option { background: #0d0d0d; }
.toggle { display: flex; align-items: center; gap: .6rem; cursor: pointer; }
/* Schalter links, Beschriftung und Hinweis rechts gestapelt — vorher klebten
alle drei in einer Zeile aneinander. Fehlt der Hinweis, bleibt Zeile 2 leer
und kostet keine Höhe.
Der Typ-Selektor muss sein: `.field > label` weiter oben ist spezifischer
als eine einzelne Klasse und hat die Schalter sonst wieder zu Blöcken in
Versalien gemacht. */
label.toggle {
display: grid;
grid-template-columns: auto 1fr;
column-gap: 1rem; row-gap: .2rem;
align-items: center;
cursor: pointer;
padding: .15rem 0;
margin-bottom: 0;
/* Beschriftung des Schalters ist Fließtext, nicht die Versalien-Feldzeile */
font-family: inherit; font-size: inherit;
letter-spacing: normal; text-transform: none;
color: inherit;
}
.toggle input {
appearance: none;
width: 34px; height: 18px; flex-shrink: 0;
background: #222; border: 1px solid rgba(255, 255, 255, .15);
grid-column: 1; grid-row: 1;
width: 42px; height: 24px; flex-shrink: 0;
background: var(--bg3); border: 1px solid var(--border);
position: relative; cursor: pointer;
transition: background .2s, border-color .2s;
}
.toggle input::after {
content: '';
position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px;
background: var(--muted);
width: 18px; height: 18px;
background: var(--muted2);
transition: transform .2s, background .2s;
}
.toggle input:checked { background: rgba(245, 197, 24, .2); border-color: var(--neon); }
.toggle input:checked::after { transform: translateX(16px); background: var(--neon); }
.toggle > span:first-of-type { color: var(--text); font-size: .95rem; }
.toggle:hover input { border-color: var(--neon); }
.toggle input:checked { background: var(--neon); border-color: var(--neon); }
.toggle input:checked::after { transform: translateX(18px); background: #0a0a0a; }
.toggle > span:first-of-type {
grid-column: 2; grid-row: 1;
color: var(--text); font-size: .95rem;
}
.toggle .field-hint {
grid-column: 2; grid-row: 2;
margin-left: 0; font-size: .82rem; line-height: 1.4;
}
/* API-Keys */
.key-list { margin-bottom: 1.2rem; border: 1px solid rgba(255, 255, 255, .06); }
@@ -1744,22 +1786,56 @@ button.with-icon, a.with-icon { justify-content: center; }
.mod-switch:hover { border-color: var(--neon); }
/* ── TEXT-VORLAGEN ─────────────────────────────────── */
/* Kurze Vorlagen teilen sich eine Zeile, mehrzeilige bekommen die volle Breite */
/* Vorlagen als Liste zum Aufklappen — 27 offene Felder auf einmal erschlagen.
Zugeklappt sieht man Namen und erste Zeile, das reicht zum Wiederfinden. */
.tpl-list {
display: grid; gap: .9rem;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
display: flex; flex-direction: column;
border: 1px solid rgba(255, 255, 255, .06);
border-radius: 10px; overflow: hidden;
}
.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-row { border-bottom: 1px solid rgba(255, 255, 255, .06); }
.tpl-row:last-child { border-bottom: 0; }
.tpl-row.open { background: rgba(255, 255, 255, .015); }
.tpl-row.dirty { box-shadow: inset 2px 0 0 var(--neon); }
.tpl-head {
display: flex; align-items: center; gap: .7rem;
width: 100%; text-align: left;
background: none; border: 0;
padding: .7rem 1rem;
cursor: pointer;
transition: background .15s;
}
.tpl-head:hover { background: rgba(255, 255, 255, .03); }
.tpl-head-main {
flex: 1; min-width: 0;
display: flex; align-items: baseline; gap: .8rem;
}
.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;
font-family: var(--display); font-size: 1.02rem;
letter-spacing: .04em; color: var(--text);
white-space: nowrap;
}
/* Erste Zeile des aktuellen Texts als Vorschau */
.tpl-peek {
flex: 1; min-width: 0;
font-family: var(--mono); font-size: .74rem;
color: var(--muted2);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tpl-row.open .tpl-peek { opacity: 0; }
.tpl-caret { color: var(--muted2); flex: none; transition: transform .2s; }
.tpl-caret.up { transform: rotate(180deg); color: var(--neon); }
.tpl-body {
display: flex; flex-direction: column; gap: .7rem;
padding: 0 1rem 1rem;
animation: riseIn .2s ease both;
}
.tpl-vars-label {
font-family: var(--mono); font-size: .6rem;
letter-spacing: .15em; text-transform: uppercase;
color: var(--muted2); align-self: center; margin-right: .2rem;
}
.tpl-badge {
font-family: var(--mono); font-size: .55rem;
@@ -1769,11 +1845,11 @@ button.with-icon, a.with-icon { justify-content: center; }
}
.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);
width: 100%; max-width: 62rem; resize: vertical;
font-family: var(--mono); font-size: .84rem; line-height: 1.6;
color: var(--text); background: #0d0d0d;
border: 1px solid var(--border); border-radius: 5px;
padding: .6rem .7rem;
padding: .7rem .8rem;
}
.tpl-input:focus { outline: none; border-color: var(--neon); }
.tpl-vars { display: flex; flex-wrap: wrap; gap: .35rem; }
@@ -1787,3 +1863,8 @@ button.with-icon, a.with-icon { justify-content: center; }
.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; }
@media (max-width: 720px) {
/* Schmal ist für die Vorschau kein Platz mehr — Name und Marken reichen */
.tpl-peek { display: none; }
}