Feature 4: Webinterface — React-Frontend, Discord-OAuth2, REST-API

- Discord-OAuth2-Login (identify-Scope, CSRF-State, signierte Session-Cookies, keine Token-Speicherung)
- REST-API: /api/devlogs (öffentlich), /api/commits (nur ADMIN_DISCORD_ID), /api/me
- React + Vite Frontend: Devlog-Archiv mit Mini-Markdown-Renderer, Commit-Tabelle, dunkles EcoGame-Theme
- Fastify liefert frontend/dist mit SPA-Fallback aus; Vite-Dev-Proxy für lokale Entwicklung
- Multi-Stage-Dockerfile (Frontend-Build im Image), neue Env-Vars in Compose + .env.example
- README: OAuth2-Setup (Redirect-URLs, Client Secret) und Frontend-Workflow

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 00:31:39 +02:00
co-authored by Claude Opus 4.8
parent 2f9d548bc8
commit a70eac08d7
23 changed files with 2931 additions and 16 deletions
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
export default function Commits({ me }) {
const [data, setData] = useState(null);
const [page, setPage] = useState(1);
const [error, setError] = useState(null);
useEffect(() => {
if (!me.admin) return;
setError(null);
apiGet(`/api/commits?page=${page}`).then(setData).catch((e) => setError(e.status));
}, [page, me.admin]);
// Zugriffs-Zustände: nicht eingeloggt / eingeloggt aber kein Admin
if (me.loading) return <p className="notice">Lade </p>;
if (!me.user) {
return (
<div className="notice center">
<p>Der Commit-Feed ist privat.</p>
<a className="btn" href="/auth/login">Login mit Discord</a>
</div>
);
}
if (!me.admin) return <p className="notice">Kein Zugriff der Commit-Feed ist privat.</p>;
if (error) return <p className="notice">Commits konnten nicht geladen werden ({error}).</p>;
if (!data) return <p className="notice">Lade </p>;
if (data.total === 0) return <p className="notice">Noch keine Commits archiviert.</p>;
const pages = Math.ceil(data.total / data.pageSize);
return (
<>
<h1>Commits</h1>
<p className="subtitle">{data.total} archivierte Commits</p>
<div className="table-wrap">
<table className="commits">
<thead>
<tr>
<th>SHA</th>
<th>Message</th>
<th>Repo</th>
<th>Branch</th>
<th>Zeit</th>
</tr>
</thead>
<tbody>
{data.items.map((c) => (
<tr key={c.sha}>
<td>
{c.url ? (
<a href={c.url} target="_blank" rel="noreferrer">
<code>{c.sha.slice(0, 7)}</code>
</a>
) : (
<code>{c.sha.slice(0, 7)}</code>
)}
</td>
<td className="msg">{c.message.split('\n')[0]}</td>
<td>{c.repo}</td>
<td>{c.branch}</td>
<td className="time">{dateFmt.format(new Date(c.committed_at))}</td>
</tr>
))}
</tbody>
</table>
</div>
{pages > 1 && (
<div className="pager">
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
Neuere
</button>
<span>Seite {page} / {pages}</span>
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
Ältere
</button>
</div>
)}
</>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { Markdown } from '../markdown.jsx';
const dateFmt = new Intl.DateTimeFormat('de-DE', {
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
});
export default function Devlogs() {
const [data, setData] = useState(null);
const [page, setPage] = useState(1);
const [error, setError] = useState(false);
useEffect(() => {
setError(false);
apiGet(`/api/devlogs?page=${page}`).then(setData).catch(() => setError(true));
}, [page]);
if (error) return <p className="notice">Devlogs konnten nicht geladen werden.</p>;
if (!data) return <p className="notice">Lade </p>;
if (data.total === 0) return <p className="notice">Noch keine Devlogs archiviert.</p>;
const pages = Math.ceil(data.total / data.pageSize);
return (
<>
<h1>Devlogs</h1>
<p className="subtitle">{data.total} Einträge aus der Entwicklung von EcoGame</p>
{data.items.map((d) => (
<article className="card" key={d.message_id}>
<div className="card-date">{dateFmt.format(new Date(d.posted_at))}</div>
<div className="card-body">
<Markdown text={d.content} />
</div>
</article>
))}
{pages > 1 && (
<div className="pager">
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
Neuere
</button>
<span>Seite {page} / {pages}</span>
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
Ältere
</button>
</div>
)}
</>
);
}