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:
2026-07-31 16:33:09 +02:00
co-authored by Claude Fable 5
parent a32c267dda
commit 90361fa55f
2 changed files with 185 additions and 70 deletions
+80 -46
View File
@@ -67,6 +67,10 @@ const TABS = [
find: 'mitarbeiter rechte bereiche protokoll audit zugriff' }, 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) // Welcher Team-Scope schaltet welchen Tab frei (null = nur Owner)
const TAB_SCOPES = { const TAB_SCOPES = {
status: 'any', status: 'any',
@@ -222,6 +226,7 @@ export default function Settings({ me }) {
const [templates, setTemplates] = useState([]); const [templates, setTemplates] = useState([]);
const [templateGroups, setTemplateGroups] = useState([]); const [templateGroups, setTemplateGroups] = useState([]);
const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text const [tplDraft, setTplDraft] = useState({}); // id → bearbeiteter Text
const [openTpl, setOpenTpl] = useState(null); // aufgeklappte Vorlage
// Eigene Seiten (Regeln, Über uns, …) // Eigene Seiten (Regeln, Über uns, …)
const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true }; const emptyPage = { slug: '', title: '', content: '', published: false, in_menu: true, sort: 0, isNew: true };
const [pages, setPages] = useState([]); const [pages, setPages] = useState([]);
@@ -300,6 +305,16 @@ export default function Settings({ me }) {
if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription); if (data?.status?.botDescription != null) setBotDesc(data.status.botDescription);
}, [data]); }, [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 // Falls der aktive Tab nicht (mehr) erlaubt ist → auf den ersten erlaubten wechseln
useEffect(() => { useEffect(() => {
if (!canTab(tab) && visibleTabs.length > 0) setTab(visibleTabs[0].id); if (!canTab(tab) && visibleTabs.length > 0) setTab(visibleTabs[0].id);
@@ -1890,10 +1905,10 @@ export default function Settings({ me }) {
<div className="settings-section"> <div className="settings-section">
<h2 className="settings-title">// Texte</h2> <h2 className="settings-title">// Texte</h2>
<p className="section-intro"> <p className="section-intro">
Alles, was der Bot nach außen schreibt Begrüßungen, Bestätigungen, Alles, was der Bot nach außen schreibt. Zeile anklicken zum Bearbeiten
Direktnachrichten. Die Bausteine in geschweiften Klammern setzt der Bot die Bausteine in geschweiften Klammern setzt der Bot beim Senden ein,
beim Senden ein; klick einen an, um ihn an der Cursor-Position einzufügen. ein Klick darauf fügt sie an der Cursor-Position ein. Ein leeres Feld
Ein leeres Feld bedeutet: wieder der Standardtext. bedeutet: wieder der Standardtext.
</p> </p>
{templateGroups.map((group) => { {templateGroups.map((group) => {
@@ -1910,56 +1925,75 @@ export default function Settings({ me }) {
const value = draft ?? t.text; const value = draft ?? t.text;
const dirty = draft != null && draft !== t.text; const dirty = draft != null && draft !== t.text;
const off = t.module && modules.some((m) => m.id === t.module && !m.enabled); const off = t.module && modules.some((m) => m.id === t.module && !m.enabled);
const open = openTpl === t.id;
return ( return (
<div className={`card tpl-card${t.long ? ' long' : ''}${dirty ? ' dirty' : ''}`} key={t.id}> <div
<div className="tpl-head"> className={`tpl-row${open ? ' open' : ''}${dirty ? ' dirty' : ''}`}
<span className="tpl-name">{t.name}</span> key={t.id}
{t.customized && <span className="tpl-badge">angepasst</span>} >
<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>
{/* 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>} {off && <span className="tpl-badge muted">Modul aus</span>}
</div> <IconChevronDown className={`tpl-caret${open ? ' up' : ''}`} size={15} />
</button>
<textarea {open && (
id={`tpl-${t.id}`} <div className="tpl-body">
className="tpl-input" <textarea
rows={t.long ? 4 : 2} id={`tpl-${t.id}`}
value={value} className="tpl-input"
spellCheck={false} rows={t.long ? 5 : 2}
onChange={(e) => setTplDraft((old) => ({ ...old, [t.id]: e.target.value }))} value={value}
/> spellCheck={false}
onChange={(e) => setTplDraft((old) => ({ ...old, [t.id]: e.target.value }))}
/>
{t.vars.length > 0 && ( {t.vars.length > 0 && (
<div className="tpl-vars"> <div className="tpl-vars">
{t.vars.map((v) => ( <span className="tpl-vars-label">Bausteine:</span>
{t.vars.map((v) => (
<button
key={v.key}
className="tpl-var"
title={`${v.desc} — einfügen`}
onClick={() => insertPlaceholder(t, v.key)}
>
{`{${v.key}}`}
</button>
))}
</div>
)}
<div className="tpl-actions">
<button <button
key={v.key} className="btn"
className="tpl-var" disabled={!dirty}
title={v.desc} onClick={() => saveTemplate(t.id, value)}
onClick={() => insertPlaceholder(t, v.key)}
> >
{`{${v.key}}`} Speichern
</button> </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 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>
); );
})} })}
@@ -2229,7 +2263,7 @@ export default function Settings({ me }) {
})} })}
</nav> </nav>
<div className="settings-content"> <div className={`settings-content${DENSE_TABS.has(tab) ? ' dense' : ''}`}>
{content} {content}
{showSave && ( {showSave && (
<div className="settings-actions"> <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) ───────────── */ /* ── 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 { .settings-layout {
display: flex; 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; } .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 { .section-intro {
color: var(--muted); color: var(--muted);
font-weight: 300; font-weight: 300;
@@ -861,26 +874,55 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
transition: border-color .2s; transition: border-color .2s;
} }
.field select:focus, .field input[type="text"]:focus { border-color: var(--neon); } .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; } .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 { .toggle input {
appearance: none; appearance: none;
width: 34px; height: 18px; flex-shrink: 0; grid-column: 1; grid-row: 1;
background: #222; border: 1px solid rgba(255, 255, 255, .15); width: 42px; height: 24px; flex-shrink: 0;
background: var(--bg3); border: 1px solid var(--border);
position: relative; cursor: pointer; position: relative; cursor: pointer;
transition: background .2s, border-color .2s; transition: background .2s, border-color .2s;
} }
.toggle input::after { .toggle input::after {
content: ''; content: '';
position: absolute; top: 2px; left: 2px; position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px; width: 18px; height: 18px;
background: var(--muted); background: var(--muted2);
transition: transform .2s, background .2s; transition: transform .2s, background .2s;
} }
.toggle input:checked { background: rgba(245, 197, 24, .2); border-color: var(--neon); } .toggle:hover input { border-color: var(--neon); }
.toggle input:checked::after { transform: translateX(16px); background: var(--neon); } .toggle input:checked { background: var(--neon); border-color: var(--neon); }
.toggle > span:first-of-type { color: var(--text); font-size: .95rem; } .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 */ /* API-Keys */
.key-list { margin-bottom: 1.2rem; border: 1px solid rgba(255, 255, 255, .06); } .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); } .mod-switch:hover { border-color: var(--neon); }
/* ── TEXT-VORLAGEN ─────────────────────────────────── */ /* ── 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 { .tpl-list {
display: grid; gap: .9rem; display: flex; flex-direction: column;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); border: 1px solid rgba(255, 255, 255, .06);
border-radius: 10px; overflow: hidden;
} }
.tpl-card.long { grid-column: 1 / -1; } .tpl-row { border-bottom: 1px solid rgba(255, 255, 255, .06); }
.tpl-card { .tpl-row:last-child { border-bottom: 0; }
display: flex; flex-direction: column; gap: .6rem; .tpl-row.open { background: rgba(255, 255, 255, .015); }
padding: 1.1rem 1.2rem; .tpl-row.dirty { box-shadow: inset 2px 0 0 var(--neon); }
transition: border-color .2s;
.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 { .tpl-name {
font-family: var(--display); font-size: 1.05rem; font-family: var(--display); font-size: 1.02rem;
letter-spacing: .04em; color: var(--text); 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 { .tpl-badge {
font-family: var(--mono); font-size: .55rem; 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-badge.muted { color: var(--muted2); border-color: var(--border); }
.tpl-input { .tpl-input {
width: 100%; resize: vertical; width: 100%; max-width: 62rem; resize: vertical;
font-family: var(--mono); font-size: .82rem; line-height: 1.55; font-family: var(--mono); font-size: .84rem; line-height: 1.6;
color: var(--text); background: var(--bg2); color: var(--text); background: #0d0d0d;
border: 1px solid var(--border); border-radius: 5px; border: 1px solid var(--border); border-radius: 5px;
padding: .6rem .7rem; padding: .7rem .8rem;
} }
.tpl-input:focus { outline: none; border-color: var(--neon); } .tpl-input:focus { outline: none; border-color: var(--neon); }
.tpl-vars { display: flex; flex-wrap: wrap; gap: .35rem; } .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-var:hover { color: var(--neon); border-color: var(--neon); }
.tpl-actions { display: flex; gap: .5rem; align-items: center; } .tpl-actions { display: flex; gap: .5rem; align-items: center; }
.tpl-actions .btn { font-size: .6rem; padding: .4rem .9rem; } .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; }
}