Bestenliste, Devlog-Filter, Galerie und leere Bereiche
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 17:44:57 +02:00
co-authored by Claude Fable 5
parent c6977f9c4a
commit 2e58268f8d
11 changed files with 314 additions and 32 deletions
+28
View File
@@ -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 = ?'
);
+40 -4
View File
@@ -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),