From 570e45fe600f734674ae61801a3d531c0fcb0de5 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 31 Jul 2026 22:25:51 +0200 Subject: [PATCH] Team-Bereich aufgehuebscht: Avatare, Rechte-Chips, Protokoll mit relativer Zeit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/pages/Settings.jsx | 208 ++++++++++++++++++++++---------- frontend/src/style.css | 121 ++++++++++++++++++- src/web/api.js | 25 +++- 3 files changed, 286 insertions(+), 68 deletions(-) diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index f47fb62..d04fd57 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 + ? + : {(name ?? '?').slice(0, 1).toUpperCase()}); + 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 }) { ); + const auditSichtbar = auditAlle ? audit : audit.slice(0, 12); + const teamBearbeitet = team.some((t) => t.user_id === teamDraft.user_id); + const tabTeam = ( + <>

// Team

- 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.

- {team.length > 0 && ( -
- {team.map((t) => ( -
- {t.username ?? t.user_id} - {t.scopes} - - +
+ {owner && ( +
+ +
+ {owner.name ?? me.user?.username ?? 'Du'} + Alle Bereiche +
+ Owner +
+ )} + {team.map((t) => ( +
+ +
+ {t.name ?? t.username ?? t.user_id} + + {t.scopes.split(',').filter(Boolean).map((sc) => ( + {TEAM_SCOPE_LABELS[sc] ?? sc} + ))} + +
+ + +
+ ))} + {team.length === 0 && ( +
Außer dir hat noch niemand Zugriff.
+ )} +
+ +

+ {teamBearbeitet ? 'Rechte ändern' : 'Jemanden hinzufügen'} +

+
+ + setTeamDraft({ ...teamDraft, user_id: e.target.value })} + /> +
+
+ +
+ {Object.entries(TEAM_SCOPE_LABELS).map(([sid, label]) => { + const an = teamDraft.scopes.includes(sid); + return ( + + ); + })} +
+
+
+ + {(teamDraft.user_id || teamDraft.scopes.length > 0) && ( + + )} +
+
+ + {audit.length > 0 && ( +
+

// Protokoll

+

+ Wer hat was im Webinterface geändert. +

+
+ {auditSichtbar.map((a) => ( +
+ + {a.username} + {a.action} + {a.detail} + {vorWieLange(a.ts)}
))}
- )} - -
- -
- setTeamDraft({ ...teamDraft, user_id: e.target.value })} /> - -
-
-
- -
- {Object.entries(TEAM_SCOPE_LABELS).map(([id, label]) => ( - - ))} -
-
- - {audit.length > 0 && ( - <> -

// Audit-Log

-

- Die letzten Aktionen im Webinterface (max. 500 werden aufbewahrt). -

-
- {audit.map((a) => ( -
- {a.ts.slice(0, 16).replace('T', ' ')} - {a.username} - {a.action} - {a.detail} -
- ))} + {audit.length > 12 && ( +
+
- - )} -
+ )} +
+ )} + ); const content = { diff --git a/frontend/src/style.css b/frontend/src/style.css index d7dddd6..0a2c466 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; } diff --git a/src/web/api.js b/src/web/api.js index 2784b0b..aa32403 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -1688,7 +1688,21 @@ ${rssItems} app.get('/api/webadmins', async (request, reply) => { if (requireAdmin(request, reply)) return; - return { admins: listWebAdmins(), scopes: TEAM_SCOPES }; + // Avatare aus dem Member-Cache — kostet keinen Discord-Aufruf, + // dieselbe Quelle wie bei der Bestenliste + const bild = (id) => { + for (const guild of client.guilds.cache.values()) { + const m = guild.members?.cache?.get(id); + if (m) return { avatar: m.displayAvatarURL?.({ size: 64 }) ?? null, name: m.displayName }; + } + return { avatar: null, name: null }; + }; + const owner = bild(config.adminDiscordId); + return { + admins: listWebAdmins().map((a) => ({ ...a, ...bild(a.user_id) })), + scopes: TEAM_SCOPES, + owner: { user_id: config.adminDiscordId, ...owner }, + }; }); app.put('/api/webadmins', async (request, reply) => { @@ -1724,7 +1738,14 @@ ${rssItems} // Audit-Log: die letzten Team-/Owner-Aktionen im Webinterface (nur Owner) app.get('/api/auditlog', async (request, reply) => { if (requireAdmin(request, reply)) return; - return { entries: listAudit(100) }; + const bild = (id) => { + for (const guild of client.guilds.cache.values()) { + const m = guild.members?.cache?.get(id); + if (m) return m.displayAvatarURL?.({ size: 48 }) ?? null; + } + return null; + }; + return { entries: listAudit(100).map((e) => ({ ...e, avatar: bild(e.user_id) })) }; }); // --- SSO-Apps (nur Owner): Fremd-Dienste, die den Discord-Login mitnutzen ---