From 2e58268f8d34942ad028026f32ead425a9d44867 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 31 Jul 2026 17:44:57 +0200 Subject: [PATCH] Bestenliste, Devlog-Filter, Galerie und leere Bereiche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Stellen, die beim Durchsehen der Seiten aufgefallen sind. Die Bestenliste zeigte nur Namen — und für Platz 1 bis 3 denselben Pokal ohne Nummer, wodurch die Reihenfolge nur an der Position erkennbar war. Jetzt: Rangnummer, Avatar und die ersten drei Plätze farblich abgesetzt. Die Avatare kommen aus dem Member-Cache, der durch den GuildMembers-Intent ohnehin gefüllt ist — das kostet keinen einzigen Discord-Aufruf. Wer nicht im Cache liegt, bekommt seinen Anfangsbuchstaben statt eines leeren Kreises. Die Projekt-Chips im Devlog-Archiv waren bloß Beschriftung. Jetzt filtern Chips mit Anzahl nach Projekt (ECOGAME / D4RKBOT) — man will meist nur eins von beiden lesen. Serverseitig über LIKE auf die erste Zeile, wo das Projekt ohnehin steht; das spart eine Spalte und eine Migration. Eine aktive Suche schlägt den Filter, sonst müsste die Volltextsuche beides verbinden, ohne dass jemand danach fragt. Galerie: Vorschaubilder von 240 auf 340 Pixel, und Name plus Datum stehen dauerhaft am Bild statt nur beim Überfahren — am Handy gab es kein Hover und damit nie eine Zuordnung. Events, Changelog, Galerie und Level endeten bei wenig Inhalt mit einem einzelnen Satz in einer sonst leeren Seite; das sah nach Fehler aus statt nach „noch nichts da". Jetzt steht dort ein Block mit Symbol, Erklärung was hier erscheinen wird, und Verweisen auf belebte Bereiche. Dazu ein Sprung-nach-oben-Knopf im Devlog-Archiv und im Changelog, die beide sehr lang werden. Beim Testen gefunden: ESCAPE '\' in einem JS-String kommt als leeres Zeichen in SQLite an ("ESCAPE expression must be a single character") — der Backslash müsste doppelt entwertet werden. Jetzt ist das Escape-Zeichen ein Ausrufezeichen, das die Falle gar nicht erst aufmacht. Co-Authored-By: Claude Fable 5 --- frontend/src/components/EmptyState.jsx | 33 ++++++++ frontend/src/components/ToTop.jsx | 27 +++++++ frontend/src/icons.jsx | 3 +- frontend/src/pages/Changelog.jsx | 14 +++- frontend/src/pages/Devlogs.jsx | 43 +++++++++- frontend/src/pages/Events.jsx | 12 ++- frontend/src/pages/Galerie.jsx | 12 ++- frontend/src/pages/Level.jsx | 26 +++++-- frontend/src/style.css | 104 ++++++++++++++++++++++--- src/db.js | 28 +++++++ src/web/api.js | 44 ++++++++++- 11 files changed, 314 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/EmptyState.jsx create mode 100644 frontend/src/components/ToTop.jsx diff --git a/frontend/src/components/EmptyState.jsx b/frontend/src/components/EmptyState.jsx new file mode 100644 index 0000000..cb234be --- /dev/null +++ b/frontend/src/components/EmptyState.jsx @@ -0,0 +1,33 @@ +// Leerer Bereich mit Substanz: Symbol, Überschrift, ein Satz was hier erscheint +// und wohin es stattdessen geht. +// +// Vorher stand auf Events, Changelog, Galerie und Level je ein einzelner Satz +// in einer sonst leeren Seite — das sah nach Fehler aus statt nach „noch nichts da". +import { Link } from 'react-router-dom'; + +/** + * @param {{ + * Icon: (props: object) => JSX.Element, + * title: string, + * text: string, + * actions?: Array<{ to?: string, href?: string, label: string }>, + * }} props + */ +export default function EmptyState({ Icon, title, text, actions = [] }) { + return ( +
+ +

{title}

+

{text}

+ {actions.length > 0 && ( +
+ {actions.filter((a) => a.to || a.href).map((a) => ( + a.to + ? {a.label} + : {a.label} + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ToTop.jsx b/frontend/src/components/ToTop.jsx new file mode 100644 index 0000000..627efed --- /dev/null +++ b/frontend/src/components/ToTop.jsx @@ -0,0 +1,27 @@ +// Sprung nach oben — erscheint erst, wenn man wirklich weit unten ist. +// Das Devlog-Archiv wird sehr lang; ohne das scrollt man ewig zurück. +import { useEffect, useState } from 'react'; +import { IconChevronUp } from '../icons.jsx'; + +export default function ToTop({ after = 900 }) { + const [show, setShow] = useState(false); + + useEffect(() => { + const onScroll = () => setShow(window.scrollY > after); + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, [after]); + + if (!show) return null; + return ( + + ); +} diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx index 6662865..888719d 100644 --- a/frontend/src/icons.jsx +++ b/frontend/src/icons.jsx @@ -5,7 +5,7 @@ // Discord kennt keine SVGs — dort sind Emojis die richtige Wahl. import { Activity, AlertTriangle, Archive, ArrowLeft, ArrowRight, Award, Bell, Bot, - Cake, Calendar, Camera, Check, ChevronDown, ChevronLeft, ChevronRight, + Cake, Calendar, Camera, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock, Copy, Database, Download, Ticket, FileText, Gamepad2, Gauge, Gift, Globe, Hash, Heart, Image, Key, Layers, Link2, List, Lock, LogOut, Mail, MapPin, Megaphone, MessageSquare, Mic, Monitor, Moon, Package, Palette, UserCog, @@ -72,6 +72,7 @@ export const IconLock = make(Lock); export const IconArrowLeft = make(ArrowLeft); export const IconArrowRight = make(ArrowRight); export const IconChevronDown = make(ChevronDown); +export const IconChevronUp = make(ChevronUp); export const IconChevronLeft = make(ChevronLeft); export const IconChevronRight = make(ChevronRight); diff --git a/frontend/src/pages/Changelog.jsx b/frontend/src/pages/Changelog.jsx index c629382..a441a20 100644 --- a/frontend/src/pages/Changelog.jsx +++ b/frontend/src/pages/Changelog.jsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; import { apiGet } from '../api.js'; -import { IconChevronLeft, IconChevronRight } from '../icons.jsx'; +import { IconChevronLeft, IconChevronRight, IconChangelog } from '../icons.jsx'; +import EmptyState from '../components/EmptyState.jsx'; import { SkeletonCards } from '../components/Skeleton.jsx'; import { Markdown } from '../markdown.jsx'; +import ToTop from '../components/ToTop.jsx'; const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric', @@ -36,7 +38,14 @@ export default function Changelog() {
{error &&

Changelog konnte nicht geladen werden.

} {!error && !data && } - {data?.total === 0 &&

Noch keine Releases veröffentlicht.

} + {data?.total === 0 && ( + + )} {data?.total > 0 && data.items.map((r) => ( @@ -73,6 +82,7 @@ export default function Changelog() { )}
+ ); } diff --git a/frontend/src/pages/Devlogs.jsx b/frontend/src/pages/Devlogs.jsx index 8272ecb..18a75b9 100644 --- a/frontend/src/pages/Devlogs.jsx +++ b/frontend/src/pages/Devlogs.jsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { apiGet, apiDelete } from '../api.js'; import { IconChevronLeft, IconChevronRight, IconSearch, IconLink, IconX } from '../icons.jsx'; import { Markdown } from '../markdown.jsx'; +import ToTop from '../components/ToTop.jsx'; import Lightbox from '../components/Lightbox.jsx'; import { SkeletonCards } from '../components/Skeleton.jsx'; @@ -49,6 +50,8 @@ export default function Devlogs({ me }) { const [error, setError] = useState(false); const [qInput, setQInput] = useState(''); const [q, setQ] = useState(''); + const [projects, setProjects] = useState([]); + const [project, setProject] = useState(''); // '' = alle const [lightbox, setLightbox] = useState(null); // { images, start } // Sucheingabe entprellen (300 ms), bei neuer Suche zurück auf Seite 1 @@ -63,8 +66,15 @@ export default function Devlogs({ me }) { const load = useCallback(() => { setError(false); const search = q ? `&q=${encodeURIComponent(q)}` : ''; - apiGet(`/api/devlogs?page=${page}${search}`).then(setData).catch(() => setError(true)); - }, [page, q]); + // Bei aktiver Suche zählt der Filter nicht — der Server durchsucht immer alles + const filter = !q && project ? `&project=${encodeURIComponent(project)}` : ''; + apiGet(`/api/devlogs?page=${page}${search}${filter}`).then(setData).catch(() => setError(true)); + }, [page, q, project]); + + // Projektliste einmal laden — steuert die Filter-Chips + useEffect(() => { + apiGet('/api/devlog-projects').then((d) => setProjects(d.projects)).catch(() => {}); + }, []); useEffect(() => { load(); @@ -102,6 +112,26 @@ export default function Devlogs({ me }) {
+ {projects.length > 1 && ( +
+ + {projects.map((p) => ( + + ))} +
+ )} +
Devlogs konnten nicht geladen werden.

} {!error && !data && } {data?.total === 0 && ( -

{q ? `Keine Treffer für „${q}".` : 'Noch keine Devlogs archiviert.'}

+

+ {q + ? `Keine Treffer für „${q}".` + : project + ? `Noch keine Devlogs für ${project}.` + : 'Noch keine Devlogs archiviert.'} +

)} {data?.total > 0 && ( @@ -199,6 +235,7 @@ export default function Devlogs({ me }) {
)}
+ ); } diff --git a/frontend/src/pages/Events.jsx b/frontend/src/pages/Events.jsx index 58526f2..09c7a74 100644 --- a/frontend/src/pages/Events.jsx +++ b/frontend/src/pages/Events.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { apiGet } from '../api.js'; -import { IconStar, IconChevronRight } from '../icons.jsx'; +import { IconStar, IconChevronRight, IconEvents } from '../icons.jsx'; +import EmptyState from '../components/EmptyState.jsx'; import { Markdown } from '../markdown.jsx'; const dateFmt = new Intl.DateTimeFormat('de-DE', { @@ -31,7 +32,14 @@ export default function Events() {
{error &&

Events konnten nicht geladen werden.

} {!error && !data &&

Lade …

} - {data?.events.length === 0 &&

Gerade keine geplanten Events — schau bald wieder rein!

} + {data?.events.length === 0 && ( + + )} {data?.events.map((e) => (
diff --git a/frontend/src/pages/Galerie.jsx b/frontend/src/pages/Galerie.jsx index 068f6c4..63154e9 100644 --- a/frontend/src/pages/Galerie.jsx +++ b/frontend/src/pages/Galerie.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { apiGet } from '../api.js'; -import { IconChevronLeft, IconChevronRight } from '../icons.jsx'; +import { IconChevronLeft, IconChevronRight, IconGallery } from '../icons.jsx'; +import EmptyState from '../components/EmptyState.jsx'; import Lightbox from '../components/Lightbox.jsx'; import { SkeletonGrid } from '../components/Skeleton.jsx'; @@ -38,7 +39,14 @@ export default function Galerie() {
{error &&

Galerie konnte nicht geladen werden.

} {!error && !data && } - {data?.total === 0 &&

Noch keine Screenshots — poste welche im Discord!

} + {data?.total === 0 && ( + + )}
{shots.map((s, i) => ( diff --git a/frontend/src/pages/Level.jsx b/frontend/src/pages/Level.jsx index 0f77911..58fcd13 100644 --- a/frontend/src/pages/Level.jsx +++ b/frontend/src/pages/Level.jsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { apiGet } from '../api.js'; import { SkeletonRows } from '../components/Skeleton.jsx'; -import { IconTrophy } from '../icons.jsx'; +import { IconTrophy, IconLevel } from '../icons.jsx'; +import EmptyState from '../components/EmptyState.jsx'; /** Muss der Formel in src/bot/levels.js entsprechen */ function xpForLevel(level) { @@ -68,7 +69,14 @@ export default function Level() {
{error &&

Bestenliste konnte nicht geladen werden.

} {!error && !rows && } - {rows?.length === 0 &&

Noch keine XP vergeben — schreibt was im Discord!

} + {rows?.length === 0 && ( + + )} {rows?.length > 0 && (
@@ -76,14 +84,18 @@ export default function Level() { const base = xpForLevel(r.level); const next = xpForLevel(r.level + 1); const pct = Math.floor(((r.xp - base) / (next - base)) * 100); + const name = r.display_name || r.username || r.user_id; return ( -
+
- {i < 3 - ? - : `#${i + 1}`} + {i < 3 && } + {i + 1} - {r.username ?? r.user_id} + {/* Ohne Bild bleibt der Anfangsbuchstabe — kein leeres Loch */} + {r.avatar + ? + : {name.slice(0, 1).toUpperCase()}} + {name}
diff --git a/frontend/src/style.css b/frontend/src/style.css index 5cc3261..0d946cb 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -472,8 +472,8 @@ body::after { /* ── GALERIE ───────────────────────────────────────── */ .gallery-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: .9rem; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 1rem; } .shot { position: relative; display: block; overflow: hidden; @@ -501,16 +501,18 @@ body::after { filter: brightness(.92); } .shot:hover img { transform: scale(1.06); filter: brightness(1.05); } +/* Wer das Bild gemacht hat, steht dauerhaft dran — beim Überfahren nur + deutlicher. Vorher war es nur im Hover sichtbar und damit am Handy nie. */ .shot-meta { position: absolute; inset: auto 0 0 0; display: flex; justify-content: space-between; align-items: baseline; - padding: .55rem .7rem .45rem; - background: linear-gradient(to top, rgba(0, 0, 0, .9), transparent); - font-family: var(--mono); font-size: .62rem; letter-spacing: .1em; - opacity: 0; transform: translateY(4px); - transition: opacity .25s, transform .25s; + padding: 1.4rem .7rem .5rem; + background: linear-gradient(to top, rgba(0, 0, 0, .92), transparent); + font-family: var(--mono); font-size: .64rem; letter-spacing: .1em; + opacity: .8; + transition: opacity .25s; } -.shot:hover .shot-meta { opacity: 1; transform: translateY(0); } +.shot:hover .shot-meta { opacity: 1; } .shot-author { color: var(--neon); } .shot-date { color: var(--muted); } @@ -1079,8 +1081,29 @@ label.toggle { } .lvl-row:last-child { border-bottom: none; } .lvl-row:first-child { border-radius: 12px 12px 0 0; } -.lvl-rank { font-family: var(--mono); font-size: .85rem; color: var(--muted); min-width: 2.6rem; } -.lvl-name { font-weight: 600; min-width: 10rem; } +.lvl-rank { + display: inline-flex; align-items: center; gap: .35rem; + font-family: var(--mono); font-size: .85rem; color: var(--muted); + min-width: 3.4rem; +} +.lvl-rank-n { min-width: 1.2rem; text-align: right; } +/* Die ersten drei Plätze dürfen sich abheben — sonst sieht die Liste flach aus */ +.lvl-row.top .lvl-rank-n { color: var(--text); font-size: .95rem; } +.lvl-row.top-1 { background: linear-gradient(90deg, rgba(245, 197, 24, .07), transparent 55%); } +.lvl-row.top-2 { background: linear-gradient(90deg, rgba(192, 196, 204, .05), transparent 55%); } +.lvl-row.top-3 { background: linear-gradient(90deg, rgba(192, 122, 62, .05), transparent 55%); } + +.lvl-avatar { + flex: none; width: 2.1rem; height: 2.1rem; border-radius: 50%; + object-fit: cover; + background: var(--bg3); border: 1px solid var(--border); +} +.lvl-row.top-1 .lvl-avatar { border-color: rgba(245, 197, 24, .55); } +.lvl-avatar-empty { + display: inline-flex; align-items: center; justify-content: center; + font-family: var(--display); font-size: 1rem; color: var(--muted); +} +.lvl-name { font-weight: 600; min-width: 9rem; } .lvl-progress { flex: 1; } .lvl-level { font-family: var(--mono); font-size: .72rem; letter-spacing: .1em; @@ -1670,7 +1693,6 @@ button.with-icon, a.with-icon { justify-content: center; } .srv-map, .srv-ping { display: inline-flex; align-items: center; gap: .3rem; } /* Bestenliste: Pokal statt Medaillen-Emoji */ -.lvl-rank { display: inline-flex; align-items: center; justify-content: center; } /* Bot-Avatar-Platzhalter */ .bot-avatar-empty { display: flex; align-items: center; justify-content: center; color: var(--muted); } @@ -1926,6 +1948,66 @@ button.with-icon, a.with-icon { justify-content: center; } .tpl-peek { display: none; } } +/* ── DEVLOG-FILTER ─────────────────────────────────── */ +/* Gleiche Form wie die Modul-Filter — beides sind Chips zum Einschränken */ +.dl-filters { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: .9rem; } +.dl-filter { + display: inline-flex; align-items: center; gap: .45rem; + font-family: var(--mono); font-size: .62rem; + letter-spacing: .12em; text-transform: uppercase; + color: var(--muted); background: var(--bg2); + border: 1px solid var(--border); border-radius: 99px; + padding: .35rem .8rem; cursor: pointer; + transition: color .15s, border-color .15s, background .15s; +} +.dl-filter:hover { color: var(--text); border-color: var(--neon); } +.dl-filter.active { color: #0a0a0a; background: var(--neon); border-color: var(--neon); } +.dl-filter-n { + font-size: .58rem; opacity: .7; + padding-left: .35rem; border-left: 1px solid currentColor; +} + +/* ── LEERE BEREICHE ────────────────────────────────── */ +/* Vorher stand hier ein einzelner Satz in einer sonst leeren Seite — + das sah nach Fehler aus statt nach „noch nichts da". */ +.empty-state { + display: flex; flex-direction: column; align-items: center; text-align: center; + gap: .6rem; + padding: 3.5rem 1.5rem; + border: 1px dashed var(--border); border-radius: 14px; + background: rgba(255, 255, 255, .012); +} +.empty-icon { + display: inline-flex; align-items: center; justify-content: center; + width: 3.6rem; height: 3.6rem; border-radius: 50%; + background: var(--bg3); border: 1px solid var(--border); + color: var(--muted2); + margin-bottom: .3rem; +} +.empty-title { + font-family: var(--display); font-size: 1.5rem; + letter-spacing: .04em; color: var(--text); margin: 0; +} +.empty-text { + color: var(--muted); font-weight: 300; + line-height: 1.6; max-width: 34rem; margin: 0; +} +.empty-actions { display: flex; flex-wrap: wrap; gap: .6rem; justify-content: center; margin-top: .8rem; } + +/* ── NACH OBEN ─────────────────────────────────────── */ +.to-top { + position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 400; + display: inline-flex; align-items: center; justify-content: center; + width: 2.8rem; height: 2.8rem; border-radius: 50%; + background: var(--bg2); border: 1px solid var(--border); + color: var(--muted); cursor: pointer; + transition: color .2s, border-color .2s, opacity .25s, transform .25s; +} +.to-top:hover { color: var(--neon); border-color: var(--neon); } +@media (max-width: 640px) { + .to-top { right: 1rem; bottom: 1rem; } +} + /* ── STELLWERTE ────────────────────────────────────── */ .tune-list { display: flex; flex-direction: column; diff --git a/src/db.js b/src/db.js index 11d85c6..505703a 100644 --- a/src/db.js +++ b/src/db.js @@ -997,6 +997,34 @@ export function listDevlogs(limit, offset) { return { items: selectDevlogs.all(limit, offset), total: countDevlogsStmt.get().n }; } +// Nach Projekt filtern. Das Projekt steht in der ersten Zeile („Devlog — X"), +// es gibt dafür keine eigene Spalte — LIKE auf den Anfang reicht und kommt +// ohne Migration aus. +const selectDevlogsByProject = db.prepare(` + SELECT message_id, content, author_name, posted_at, images + FROM devlogs WHERE content LIKE ? ESCAPE '!' ORDER BY posted_at DESC LIMIT ? OFFSET ? +`); +const countDevlogsByProject = db.prepare( + "SELECT COUNT(*) AS n FROM devlogs WHERE content LIKE ? ESCAPE '!'" +); + +export function listDevlogsByProject(project, limit, offset) { + // Escape-Zeichen ist `!` und nicht der übliche Backslash: der müsste hier + // zweimal entwertet werden (einmal für JavaScript, einmal für SQL) und + // landet dadurch leicht als leere Zeichenkette in der Abfrage. + const escaped = String(project).replace(/[!%_]/g, (c) => `!${c}`); + const pattern = `Devlog — ${escaped}%`; + return { + items: selectDevlogsByProject.all(pattern, limit, offset), + total: countDevlogsByProject.get(pattern).n, + }; +} + +/** Erste Zeile aller Devlogs — daraus leitet die API die Projektliste ab */ +const selectDevlogHeadsStmt = db.prepare('SELECT content FROM devlogs'); +export const devlogHeads = () => + selectDevlogHeadsStmt.all().map((r) => String(r.content).split('\n', 1)[0]); + const getDevlogStmt = db.prepare( 'SELECT message_id, content, author_name, posted_at, images FROM devlogs WHERE message_id = ?' ); diff --git a/src/web/api.js b/src/web/api.js index 21b5d07..7b5cc46 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -1,7 +1,8 @@ // REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin import { EmbedBuilder } from 'discord.js'; import { - listDevlogs, searchDevlogs, getDevlog, listCommits, listReleases, archiveStats, + listDevlogs, listDevlogsByProject, devlogHeads, + searchDevlogs, getDevlog, listCommits, listReleases, archiveStats, getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey, listGallery, listPlaytesters, topWishes, commitHeatmap, createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu, @@ -84,10 +85,28 @@ export function registerApiRoutes(app, client) { }); // Devlog-Archiv — öffentlich (wie der Discord-Kanal); ?q= für Volltextsuche + // Projekte im Archiv (D4RKBOT, ECOGAME …) samt Anzahl — für die Filter-Chips. + // Das Projekt steht in der ersten Zeile, dieselbe Regel wie im Frontend. + app.get('/api/devlog-projects', async () => { + const counts = new Map(); + for (const head of devlogHeads()) { + const name = head.match(/^Devlog\s*[—–-]\s*(.+)$/i)?.[1]?.trim(); + if (name) counts.set(name, (counts.get(name) ?? 0) + 1); + } + return { + projects: [...counts].map(([name, n]) => ({ name, n })).sort((a, b) => b.n - a.n), + }; + }); + app.get('/api/devlogs', async (request) => { const { limit, offset, page } = paging(request); const q = (request.query.q ?? '').trim(); - const { items, total } = q ? searchDevlogs(q, limit, offset) : listDevlogs(limit, offset); + const project = (request.query.project ?? '').trim(); + // Suche schlägt Filter — beides gleichzeitig würde die FTS-Abfrage verkomplizieren, + // ohne dass jemand danach fragt. + const { items, total } = q + ? searchDevlogs(q, limit, offset) + : (project ? listDevlogsByProject(project, limit, offset) : listDevlogs(limit, offset)); // images: JSON-Spalte → fertige URLs fürs Frontend const mapped = items.map(({ images, ...rest }) => ({ ...rest, @@ -382,8 +401,25 @@ export function registerApiRoutes(app, client) { return { deleted: deleteService(Number(request.params.id)), services: listServices() }; }); - // Level-Bestenliste + Server-Aktivität — öffentlich - app.get('/api/levels', async () => ({ levels: topLevels(50) })); + // Level-Bestenliste + Server-Aktivität — öffentlich. + // Avatare kommen aus dem Member-Cache: der ist durch den GuildMembers-Intent + // ohnehin gefüllt, kostet also keinen API-Aufruf. Wer nicht drin ist, + // bekommt kein Bild — die Liste soll nicht 50 Abrufe an Discord auslösen. + app.get('/api/levels', async () => { + const members = new Map(); + for (const guild of client.guilds.cache.values()) { + for (const [id, member] of guild.members?.cache ?? []) { + if (!members.has(id)) members.set(id, member); + } + } + return { + levels: topLevels(50).map((row) => ({ + ...row, + avatar: members.get(row.user_id)?.displayAvatarURL?.({ size: 64 }) ?? null, + display_name: members.get(row.user_id)?.displayName ?? row.username, + })), + }; + }); app.get('/api/serverstats', async () => ({ days: activityRange(30), members: [...client.guilds.cache.values()].reduce((sum, g) => sum + (g.memberCount ?? 0), 0),