- {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),