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
+12 -2
View File
@@ -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() {
<section className="content">
{error && <p className="notice">Changelog konnte nicht geladen werden.</p>}
{!error && !data && <SkeletonCards n={3} />}
{data?.total === 0 && <p className="notice">Noch keine Releases veröffentlicht.</p>}
{data?.total === 0 && (
<EmptyState
Icon={IconChangelog}
title="Noch keine Releases"
text="Sobald eine Version veröffentlicht wird, steht sie hier mit allen Änderungen. Bis dahin steht der Fortschritt im Devlog."
actions={[{ to: '/devlogs', label: 'Devlogs lesen' }, { to: '/roadmap', label: 'Zur Roadmap' }]}
/>
)}
{data?.total > 0 &&
data.items.map((r) => (
@@ -73,6 +82,7 @@ export default function Changelog() {
</div>
)}
</section>
<ToTop />
</>
);
}
+40 -3
View File
@@ -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 }) {
</section>
<section className="content">
{projects.length > 1 && (
<div className="dl-filters">
<button
className={`dl-filter${project === '' ? ' active' : ''}`}
onClick={() => { setProject(''); setPage(1); }}
>
Alle <span className="dl-filter-n">{projects.reduce((s, p) => s + p.n, 0)}</span>
</button>
{projects.map((p) => (
<button
key={p.name}
className={`dl-filter${project === p.name ? ' active' : ''}`}
onClick={() => { setProject(p.name); setPage(1); }}
>
{p.name} <span className="dl-filter-n">{p.n}</span>
</button>
))}
</div>
)}
<div className="search">
<span className="search-icon"><IconSearch size={16} /></span>
<input
@@ -116,7 +146,13 @@ export default function Devlogs({ me }) {
{error && <p className="notice">Devlogs konnten nicht geladen werden.</p>}
{!error && !data && <SkeletonCards n={3} />}
{data?.total === 0 && (
<p className="notice">{q ? `Keine Treffer für „${q}".` : 'Noch keine Devlogs archiviert.'}</p>
<p className="notice">
{q
? `Keine Treffer für „${q}".`
: project
? `Noch keine Devlogs für ${project}.`
: 'Noch keine Devlogs archiviert.'}
</p>
)}
{data?.total > 0 && (
@@ -199,6 +235,7 @@ export default function Devlogs({ me }) {
</div>
)}
</section>
<ToTop />
</>
);
}
+10 -2
View File
@@ -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() {
<section className="content">
{error && <p className="notice">Events konnten nicht geladen werden.</p>}
{!error && !data && <p className="notice">Lade </p>}
{data?.events.length === 0 && <p className="notice">Gerade keine geplanten Events schau bald wieder rein!</p>}
{data?.events.length === 0 && (
<EmptyState
Icon={IconEvents}
title="Noch keine Events geplant"
text="Playtests, Streams und Community-Runden kündigen wir hier an — und im Discord bekommst du eine Erinnerung."
actions={[{ to: '/devlogs', label: 'Devlogs lesen' }, { to: '/roadmap', label: 'Zur Roadmap' }]}
/>
)}
{data?.events.map((e) => (
<article className="card event-card" key={e.url}>
+10 -2
View File
@@ -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() {
<section className="content">
{error && <p className="notice">Galerie konnte nicht geladen werden.</p>}
{!error && !data && <SkeletonGrid n={9} />}
{data?.total === 0 && <p className="notice">Noch keine Screenshots poste welche im Discord!</p>}
{data?.total === 0 && (
<EmptyState
Icon={IconGallery}
title="Noch keine Screenshots"
text="Alles, was im Screenshot-Kanal landet, erscheint automatisch hier — mitsamt Name und Datum."
actions={[{ to: '/devlogs', label: 'Devlogs lesen' }]}
/>
)}
<div className="gallery-grid">
{shots.map((s, i) => (
+19 -7
View File
@@ -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() {
<section className="content">
{error && <p className="notice">Bestenliste konnte nicht geladen werden.</p>}
{!error && !rows && <SkeletonRows n={10} />}
{rows?.length === 0 && <p className="notice">Noch keine XP vergeben — schreibt was im Discord!</p>}
{rows?.length === 0 && (
<EmptyState
Icon={IconLevel}
title="Noch keine XP vergeben"
text="XP gibt es fürs Mitreden im Discord. Sobald geschrieben wird, füllt sich die Bestenliste von selbst."
actions={[{ to: '/devlogs', label: 'Devlogs lesen' }]}
/>
)}
{rows?.length > 0 && (
<div className="lvl-list">
@@ -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 (
<div className="lvl-row" key={r.user_id}>
<div className={`lvl-row${i < 3 ? ` top top-${i + 1}` : ''}`} key={r.user_id}>
<span className="lvl-rank">
{i < 3
? <IconTrophy size={18} style={{ color: MEDAL_COLORS[i] }} />
: `#${i + 1}`}
{i < 3 && <IconTrophy size={15} style={{ color: MEDAL_COLORS[i] }} />}
<span className="lvl-rank-n">{i + 1}</span>
</span>
<span className="lvl-name">{r.username ?? r.user_id}</span>
{/* Ohne Bild bleibt der Anfangsbuchstabe — kein leeres Loch */}
{r.avatar
? <img className="lvl-avatar" src={r.avatar} alt="" loading="lazy" />
: <span className="lvl-avatar lvl-avatar-empty">{name.slice(0, 1).toUpperCase()}</span>}
<span className="lvl-name">{name}</span>
<div className="lvl-progress">
<div className="progress"><div className="progress-bar" style={{ width: `${pct}%` }} /></div>
</div>