Team-Bereich aufgehuebscht: Avatare, Rechte-Chips, Protokoll mit relativer Zeit
Der Team-Reiter war funktional, aber roh: Rechte standen als "content,community"
da, es gab keine Gesichter, und das Protokoll war eine Wand aus Zeitstempeln.
- /api/webadmins und /api/auditlog liefern jetzt Avatar und Anzeigename aus dem
Member-Cache — kostet keinen zusaetzlichen Discord-Aufruf
- Owner-Zeile mit eigenem Chip "Alle Bereiche", damit klar ist, wer alles darf
- Bereiche als lesbare Chips statt roher IDs; die Auswahl beim Anlegen sind
klickbare Karten mit Beschreibung
- Protokoll zeigt relative Zeit ("vor 37 Min"), erst 12 Zeilen, Rest auf Klick
- Toenungen ueber color-mix aus --neon: stimmt im Hub (gold) wie auf der
Bot-Seite (orange)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+146
-62
@@ -96,6 +96,26 @@ const TAB_SCOPES = {
|
||||
team: null,
|
||||
};
|
||||
|
||||
/** „vor 3 Minuten" statt rohem Zeitstempel */
|
||||
function vorWieLange(iso) {
|
||||
// Die Datenbank speichert UTC ohne Kennzeichnung — sonst rechnet der
|
||||
// Browser sie als Ortszeit und alles sieht Stunden alt aus.
|
||||
const min = Math.floor((Date.now() - new Date(iso.replace(' ', 'T') + 'Z').getTime()) / 60000);
|
||||
if (min < 1) return 'gerade eben';
|
||||
if (min < 60) return `vor ${min} Min`;
|
||||
const std = Math.floor(min / 60);
|
||||
if (std < 24) return `vor ${std} Std`;
|
||||
const tage = Math.floor(std / 24);
|
||||
return tage === 1 ? 'gestern' : `vor ${tage} Tagen`;
|
||||
}
|
||||
|
||||
/** Avatar mit Anfangsbuchstaben als Rückfallebene.
|
||||
* Bewusst außerhalb der Komponente: als Inline-Komponente wäre der Typ bei
|
||||
* jedem Rendern neu und React würde die Bilder jedes Mal neu einhängen. */
|
||||
const Avatar = ({ src, name, klein }) => (src
|
||||
? <img className={`tm-avatar${klein ? ' klein' : ''}`} src={src} alt="" loading="lazy" />
|
||||
: <span className={`tm-avatar${klein ? ' klein' : ''} ohne`}>{(name ?? '?').slice(0, 1).toUpperCase()}</span>);
|
||||
|
||||
const TEAM_SCOPE_LABELS = {
|
||||
content: 'Composer, Tags, Triggers & geplante Posts',
|
||||
community: 'Playtester & Alpha-Keys',
|
||||
@@ -302,6 +322,8 @@ export default function Settings({ me }) {
|
||||
const [botDesc, setBotDesc] = useState('');
|
||||
const [team, setTeam] = useState([]);
|
||||
const [audit, setAudit] = useState([]);
|
||||
const [owner, setOwner] = useState(null);
|
||||
const [auditAlle, setAuditAlle] = useState(false);
|
||||
// Module (An/Aus für alle Funktionen)
|
||||
const [modules, setModules] = useState([]);
|
||||
const [moduleGroups, setModuleGroups] = useState([]);
|
||||
@@ -420,7 +442,9 @@ export default function Settings({ me }) {
|
||||
apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {});
|
||||
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
|
||||
apiGet('/api/gameservers').then((d) => setServers(d.servers)).catch(() => {});
|
||||
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
|
||||
apiGet('/api/webadmins')
|
||||
.then((d) => { setTeam(d.admins); setOwner(d.owner); })
|
||||
.catch(() => {});
|
||||
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
|
||||
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
||||
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
|
||||
@@ -2791,77 +2815,137 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const auditSichtbar = auditAlle ? audit : audit.slice(0, 12);
|
||||
const teamBearbeitet = team.some((t) => t.user_id === teamDraft.user_id);
|
||||
|
||||
const tabTeam = (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Team</h2>
|
||||
<p className="section-intro">
|
||||
Gib Team-Mitgliedern gezielten Zugriff aufs Webinterface — sie loggen sich ganz
|
||||
normal mit Discord ein und sehen nur ihre Bereiche. Brand, System, API-Keys
|
||||
und diese Team-Verwaltung bleiben immer dir vorbehalten.
|
||||
Team-Mitglieder loggen sich ganz normal mit Discord ein und sehen nur ihre
|
||||
Bereiche. Brand, System, API-Keys und diese Verwaltung bleiben immer dir
|
||||
vorbehalten.
|
||||
</p>
|
||||
|
||||
{team.length > 0 && (
|
||||
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||
{team.map((t) => (
|
||||
<div className="key-row" key={t.user_id}>
|
||||
<span className="key-name">{t.username ?? t.user_id}</span>
|
||||
<span className="key-scopes">{t.scopes}</span>
|
||||
<button className="btn btn-mini" onClick={() => setTeamDraft({ user_id: t.user_id, scopes: t.scopes.split(',').filter(Boolean) })}>Bearbeiten</button>
|
||||
<button className="card-delete" title="Zugriff entziehen" onClick={() => removeTeam(t.user_id)}>✕</button>
|
||||
<div className="tm-list">
|
||||
{owner && (
|
||||
<div className="tm-row owner">
|
||||
<Avatar src={owner.avatar} name={owner.name ?? me.user?.username} />
|
||||
<div className="tm-main">
|
||||
<span className="tm-name">{owner.name ?? me.user?.username ?? 'Du'}</span>
|
||||
<span className="tm-scopes"><span className="tm-chip alle">Alle Bereiche</span></span>
|
||||
</div>
|
||||
<span className="tm-role">Owner</span>
|
||||
</div>
|
||||
)}
|
||||
{team.map((t) => (
|
||||
<div className={`tm-row${t.user_id === teamDraft.user_id ? ' aktiv' : ''}`} key={t.user_id}>
|
||||
<Avatar src={t.avatar} name={t.name ?? t.username} />
|
||||
<div className="tm-main">
|
||||
<span className="tm-name">{t.name ?? t.username ?? t.user_id}</span>
|
||||
<span className="tm-scopes">
|
||||
{t.scopes.split(',').filter(Boolean).map((sc) => (
|
||||
<span className="tm-chip" key={sc}>{TEAM_SCOPE_LABELS[sc] ?? sc}</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-mini"
|
||||
onClick={() => setTeamDraft({
|
||||
user_id: t.user_id,
|
||||
scopes: t.scopes.split(',').filter(Boolean),
|
||||
})}
|
||||
>
|
||||
Rechte
|
||||
</button>
|
||||
<button className="card-delete" title="Zugriff entziehen" onClick={() => removeTeam(t.user_id)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
{team.length === 0 && (
|
||||
<div className="tm-row leer">Außer dir hat noch niemand Zugriff.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="tm-form-title">
|
||||
{teamBearbeitet ? 'Rechte ändern' : 'Jemanden hinzufügen'}
|
||||
</h3>
|
||||
<div className="field">
|
||||
<label>
|
||||
Discord-User-ID
|
||||
<span className="field-hint">Rechtsklick auf den User → „Benutzer-ID kopieren"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text" placeholder="z. B. 123456789012345678"
|
||||
value={teamDraft.user_id}
|
||||
onChange={(e) => setTeamDraft({ ...teamDraft, user_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Bereiche <span className="field-hint">mindestens einer</span></label>
|
||||
<div className="tm-scopes-pick">
|
||||
{Object.entries(TEAM_SCOPE_LABELS).map(([sid, label]) => {
|
||||
const an = teamDraft.scopes.includes(sid);
|
||||
return (
|
||||
<button
|
||||
type="button" key={sid}
|
||||
className={`tm-scope${an ? ' on' : ''}`}
|
||||
onClick={() => setTeamDraft({
|
||||
...teamDraft,
|
||||
scopes: an
|
||||
? teamDraft.scopes.filter((x) => x !== sid)
|
||||
: [...teamDraft.scopes, sid],
|
||||
})}
|
||||
>
|
||||
<span className="tm-scope-mark">{an ? '✓' : ''}</span>
|
||||
<span className="tm-scope-text">
|
||||
<span className="tm-scope-id">{sid}</span>
|
||||
<span className="tm-scope-desc">{label}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-actions">
|
||||
<button className="btn btn-save" onClick={saveTeam}>
|
||||
{teamBearbeitet ? 'Rechte speichern' : 'Hinzufügen'}
|
||||
</button>
|
||||
{(teamDraft.user_id || teamDraft.scopes.length > 0) && (
|
||||
<button className="btn btn-ghost" onClick={() => setTeamDraft({ user_id: '', scopes: [] })}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{audit.length > 0 && (
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-title">// Protokoll</h2>
|
||||
<p className="section-intro">
|
||||
Wer hat was im Webinterface geändert.
|
||||
</p>
|
||||
<div className="tm-list">
|
||||
{auditSichtbar.map((a) => (
|
||||
<div className="tm-row audit" key={a.id}>
|
||||
<Avatar src={a.avatar} name={a.username} klein />
|
||||
<span className="tm-audit-name">{a.username}</span>
|
||||
<span className="tm-audit-action">{a.action}</span>
|
||||
<span className="tm-audit-detail" title={a.detail}>{a.detail}</span>
|
||||
<span className="tm-audit-time" title={a.ts.replace('T', ' ')}>{vorWieLange(a.ts)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Discord-User-ID <span className="field-hint">Rechtsklick auf den User → „Benutzer-ID kopieren"</span></label>
|
||||
<div className="field-row">
|
||||
<input type="text" placeholder="z. B. 123456789012345678" value={teamDraft.user_id} onChange={(e) => setTeamDraft({ ...teamDraft, user_id: e.target.value })} />
|
||||
<button className="btn btn-save" onClick={saveTeam}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Bereiche</label>
|
||||
<div className="scope-row" style={{ flexDirection: 'column', gap: '.5rem', alignItems: 'flex-start' }}>
|
||||
{Object.entries(TEAM_SCOPE_LABELS).map(([id, label]) => (
|
||||
<label className="scope-pick" key={id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={teamDraft.scopes.includes(id)}
|
||||
onChange={(e) =>
|
||||
setTeamDraft({
|
||||
...teamDraft,
|
||||
scopes: e.target.checked
|
||||
? [...teamDraft.scopes, id]
|
||||
: teamDraft.scopes.filter((s) => s !== id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{id} — {label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{audit.length > 0 && (
|
||||
<>
|
||||
<h2 className="settings-title" style={{ marginTop: '2rem' }}>// Audit-Log</h2>
|
||||
<p className="field-hint" style={{ marginBottom: '.8rem' }}>
|
||||
Die letzten Aktionen im Webinterface (max. 500 werden aufbewahrt).
|
||||
</p>
|
||||
<div className="key-list audit-list">
|
||||
{audit.map((a) => (
|
||||
<div className="key-row" key={a.id}>
|
||||
<span className="audit-ts">{a.ts.slice(0, 16).replace('T', ' ')}</span>
|
||||
<span className="key-name">{a.username}</span>
|
||||
<span className="audit-action">{a.action}</span>
|
||||
<span className="key-used audit-detail" title={a.detail}>{a.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
{audit.length > 12 && (
|
||||
<div className="settings-actions" style={{ marginTop: '.8rem' }}>
|
||||
<button className="btn btn-ghost" onClick={() => setAuditAlle(!auditAlle)}>
|
||||
{auditAlle ? 'Weniger zeigen' : `Alle ${audit.length} zeigen`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const content = {
|
||||
|
||||
+117
-4
@@ -1736,10 +1736,123 @@ label.toggle {
|
||||
.srv-checked { font-family: var(--mono); font-size: .62rem; color: var(--muted2); margin-top: .8rem; }
|
||||
|
||||
/* ── AUDIT-LOG ─────────────────────────────────────── */
|
||||
.audit-list { max-height: 24rem; overflow-y: auto; }
|
||||
.audit-ts { font-family: var(--mono); font-size: .65rem; color: var(--muted2); white-space: nowrap; }
|
||||
.audit-action { font-family: var(--mono); font-size: .7rem; color: var(--neon); white-space: nowrap; }
|
||||
.audit-detail { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 18rem; }
|
||||
/* ── TEAM & PROTOKOLL ──────────────────────────────── */
|
||||
.tm-list {
|
||||
display: flex; flex-direction: column;
|
||||
border: 1px solid rgba(255, 255, 255, .07);
|
||||
border-radius: 12px; overflow: hidden;
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
.tm-row {
|
||||
display: flex; align-items: center; gap: .9rem;
|
||||
padding: .7rem .9rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
||||
font-size: .9rem;
|
||||
transition: background .15s ease;
|
||||
}
|
||||
.tm-row:last-child { border-bottom: none; }
|
||||
.tm-row:hover { background: rgba(255, 255, 255, .02); }
|
||||
/* Tönungen aus der Leitfarbe mischen — die ist auf der Bot-Seite orange,
|
||||
im Hub gold, und beides soll stimmen. */
|
||||
.tm-row.owner { background: color-mix(in srgb, var(--neon) 5%, transparent); }
|
||||
.tm-row.aktiv {
|
||||
background: color-mix(in srgb, var(--neon) 9%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--neon);
|
||||
}
|
||||
.tm-row.leer {
|
||||
justify-content: center; color: var(--muted2);
|
||||
font-family: var(--mono); font-size: .72rem; letter-spacing: .1em;
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.tm-avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
flex: 0 0 auto; object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, .12);
|
||||
}
|
||||
.tm-avatar.klein { width: 24px; height: 24px; }
|
||||
.tm-avatar.ohne {
|
||||
display: grid; place-items: center;
|
||||
background: rgba(255, 255, 255, .05);
|
||||
color: var(--muted); font-weight: 700; font-size: .85rem;
|
||||
}
|
||||
.tm-avatar.klein.ohne { font-size: .65rem; }
|
||||
|
||||
.tm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .3rem; }
|
||||
.tm-name { font-weight: 600; }
|
||||
.tm-scopes { display: flex; flex-wrap: wrap; gap: .3rem; }
|
||||
.tm-chip {
|
||||
font-family: var(--mono); font-size: .6rem; letter-spacing: .08em;
|
||||
padding: .18rem .45rem; border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, .12);
|
||||
color: var(--muted); white-space: nowrap;
|
||||
}
|
||||
.tm-chip.alle {
|
||||
border-color: color-mix(in srgb, var(--neon) 40%, transparent);
|
||||
color: var(--neon);
|
||||
}
|
||||
.tm-role {
|
||||
font-family: var(--mono); font-size: .62rem; letter-spacing: .14em;
|
||||
color: var(--neon); text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tm-form-title {
|
||||
font-family: var(--mono); font-size: .75rem; letter-spacing: .16em;
|
||||
text-transform: uppercase; color: var(--muted);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.tm-scopes-pick {
|
||||
display: grid; gap: .5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
}
|
||||
.tm-scope {
|
||||
display: flex; align-items: center; gap: .6rem; text-align: left;
|
||||
padding: .6rem .7rem;
|
||||
background: rgba(0, 0, 0, .25);
|
||||
border: 1px solid rgba(255, 255, 255, .09);
|
||||
border-radius: 10px; color: var(--muted);
|
||||
cursor: pointer; transition: all .15s ease;
|
||||
}
|
||||
.tm-scope:hover { border-color: rgba(255, 255, 255, .22); color: var(--text); }
|
||||
.tm-scope.on {
|
||||
border-color: var(--neon);
|
||||
background: color-mix(in srgb, var(--neon) 8%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
.tm-scope-mark {
|
||||
width: 18px; height: 18px; flex: 0 0 auto;
|
||||
display: grid; place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, .2); border-radius: 5px;
|
||||
font-size: .7rem; color: #0a0a0a;
|
||||
}
|
||||
.tm-scope.on .tm-scope-mark { background: var(--neon); border-color: var(--neon); }
|
||||
.tm-scope-text { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
|
||||
.tm-scope-id {
|
||||
font-family: var(--mono); font-size: .68rem; letter-spacing: .1em;
|
||||
color: inherit;
|
||||
}
|
||||
.tm-scope-desc { font-size: .72rem; color: var(--muted2); }
|
||||
|
||||
.tm-row.audit { font-size: .82rem; padding: .5rem .9rem; }
|
||||
.tm-audit-name { font-weight: 600; min-width: 7rem; }
|
||||
.tm-audit-action {
|
||||
font-family: var(--mono); font-size: .68rem; letter-spacing: .06em;
|
||||
color: var(--neon); white-space: nowrap; min-width: 9rem;
|
||||
}
|
||||
.tm-audit-detail {
|
||||
flex: 1; min-width: 0; color: var(--muted);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tm-audit-time {
|
||||
font-family: var(--mono); font-size: .62rem;
|
||||
color: var(--muted2); white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.tm-audit-action { min-width: 0; }
|
||||
.tm-audit-detail { display: none; }
|
||||
}
|
||||
|
||||
/* ── STARTSEITE ────────────────────────────────────── */
|
||||
.home-hero { min-height: 420px; }
|
||||
|
||||
Reference in New Issue
Block a user