Öffentliche Roadmap-Seite aus Gitea-Milestones

- GET /api/roadmap (public): Milestones des Setting roadmap_repo, 5-min-Cache,
  letzter guter Stand als Fallback bei Gitea-Ausfall
- /roadmap: Gruppen In Arbeit / Geplant / Fertig, Fortschrittsbalken im Brand-Look,
  Ziel-Monat, Markdown-Beschreibungen; Nav-Link öffentlich
- Setup-Seite: Roadmap-Repo konfigurierbar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:24:31 +02:00
co-authored by Claude Opus 4.8
parent 39f9fd4f67
commit 6d8f383aed
7 changed files with 183 additions and 2 deletions
+3
View File
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate } from 'react-router-dom';
import { apiGet } from './api.js';
import Devlogs from './pages/Devlogs.jsx';
import Changelog from './pages/Changelog.jsx';
import Roadmap from './pages/Roadmap.jsx';
import Commits from './pages/Commits.jsx';
import Settings from './pages/Settings.jsx';
@@ -70,6 +71,7 @@ export default function App() {
<nav className="nav-links">
<NavLink to="/devlogs">Devlog</NavLink>
<NavLink to="/roadmap">Roadmap</NavLink>
<NavLink to="/changelog">Changelog</NavLink>
{me.admin && <NavLink to="/commits">Commits</NavLink>}
{me.admin && <NavLink to="/settings">Setup</NavLink>}
@@ -94,6 +96,7 @@ export default function App() {
<Routes>
<Route path="/" element={<Navigate to="/devlogs" replace />} />
<Route path="/devlogs" element={<Devlogs me={me} />} />
<Route path="/roadmap" element={<Roadmap />} />
<Route path="/changelog" element={<Changelog />} />
<Route path="/commits" element={<Commits me={me} />} />
<Route path="/settings" element={<Settings me={me} />} />
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { apiGet } from '../api.js';
import { Markdown } from '../markdown.jsx';
const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
/** Milestone-Gruppe bestimmen: Fertig / In Arbeit / Geplant */
function groupOf(m) {
if (m.state === 'closed') return 'done';
return m.closed > 0 ? 'active' : 'planned';
}
const GROUPS = [
{ id: 'active', tag: '// In Arbeit', empty: 'Gerade nichts in Arbeit.' },
{ id: 'planned', tag: '// Geplant', empty: null },
{ id: 'done', tag: '// Fertig', empty: null },
];
function Milestone({ m }) {
const total = m.open + m.closed;
const pct = total > 0 ? Math.round((m.closed / total) * 100) : m.state === 'closed' ? 100 : 0;
return (
<article className="card milestone">
<div className="card-meta">
<span className="milestone-title">{m.title}</span>
{m.due_on && m.state !== 'closed' && (
<span className="card-rel">Ziel: {dueFmt.format(new Date(m.due_on))}</span>
)}
</div>
{m.description && (
<div className="card-body">
<Markdown text={m.description} />
</div>
)}
<div className="progress">
<div className="progress-bar" style={{ width: `${pct}%` }} />
</div>
<div className="progress-info">
<span>{pct}%</span>
{total > 0 && <span>{m.closed} / {total} Aufgaben</span>}
</div>
</article>
);
}
export default function Roadmap() {
const [data, setData] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
window.scrollTo(0, 0);
}, []);
return (
<>
<section className="page-header">
<div className="page-bg-text">ROADMAP</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Wohin die Reise geht</div>
<h1 className="page-title">Roadmap</h1>
<p className="page-subtitle">
Meilensteine direkt aus der Entwicklung live aus dem Issue-Tracker.
</p>
</div>
</section>
<section className="content">
{error && <p className="notice">Roadmap konnte nicht geladen werden.</p>}
{!error && !data && <p className="notice">Lade </p>}
{data?.milestones.length === 0 && (
<p className="notice">Noch keine Meilensteine angelegt.</p>
)}
{GROUPS.map(({ id, tag, empty }) => {
const items = (data?.milestones ?? []).filter((m) => groupOf(m) === id);
if (items.length === 0 && !empty) return null;
return (
<div className="roadmap-group" key={id}>
<h2 className="settings-title">{tag}</h2>
{items.length === 0 && <p className="notice">{empty}</p>}
{items.map((m) => (
<Milestone m={m} key={m.title} />
))}
</div>
);
})}
</section>
</>
);
}
+9
View File
@@ -270,6 +270,15 @@ export default function Settings({ me }) {
onChange={(e) => setForm({ ...form, bug_report_repo: e.target.value })}
/>
</div>
<div className="field">
<label>Roadmap-Repo <span className="field-hint">Milestones dieses Repos speisen die öffentliche /roadmap-Seite</span></label>
<input
type="text"
placeholder="D4rkst3r/EcoGame"
value={form.roadmap_repo}
onChange={(e) => setForm({ ...form, roadmap_repo: e.target.value })}
/>
</div>
</div>
{/* Watchdog */}
+29
View File
@@ -398,6 +398,35 @@ body::after {
.card-footer a { color: var(--muted); border-bottom: 1px solid transparent; }
.card-footer a:hover { color: var(--neon); }
/* ── ROADMAP ───────────────────────────────────────── */
.roadmap-group { margin-bottom: 2.25rem; }
.roadmap-group .settings-title { margin-bottom: 1rem; }
.milestone { margin-bottom: 1.1rem; }
.milestone-title {
font-family: var(--display);
font-size: 1.3rem; letter-spacing: .04em;
color: var(--text);
}
.progress {
height: 6px;
background: rgba(255, 255, 255, .06);
margin-top: .8rem;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--neon2), var(--neon));
box-shadow: 0 0 10px rgba(245, 197, 24, .4);
transition: width .6s ease;
}
.progress-info {
display: flex; justify-content: space-between;
font-family: var(--mono);
font-size: .65rem; letter-spacing: .1em;
color: var(--muted2);
margin-top: .35rem;
}
/* ── CHANGELOG ─────────────────────────────────────── */
.release-card { margin-bottom: 1.5rem; }
.release-tag {
+11
View File
@@ -24,6 +24,17 @@ export async function createIssue(repo, title, body) {
return { number: issue.number, html_url: issue.html_url };
}
/** Milestones eines Repos (für die Roadmap-Seite); Token optional bei öffentlichen Repos */
export async function getMilestones(repo) {
const res = await fetch(`${giteaUrl()}/api/v1/repos/${repo}/milestones?state=all&per_page=50`, {
headers: config.giteaApiToken ? headers() : { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`Gitea getMilestones ${res.status}`);
}
return res.json();
}
/** Datei (z. B. Screenshot) als Issue-Attachment hochladen */
export async function uploadIssueAsset(repo, issueNumber, filename, buffer) {
const form = new FormData();
+5
View File
@@ -36,6 +36,11 @@ export function bugReportRepo() {
return getSetting('bug_report_repo') || 'D4rkst3r/EcoGame';
}
/** Repo, dessen Milestones die öffentliche Roadmap speisen */
export function roadmapRepo() {
return getSetting('roadmap_repo') || 'D4rkst3r/EcoGame';
}
/** Rolle, die bei Devlog-Posts gepingt wird — leer = kein Ping */
export function devlogPingRoleId() {
return getSetting('devlog_ping_role_id') || null;
+34 -2
View File
@@ -6,7 +6,8 @@ import {
} from '../db.js';
import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl } from '../runtime-settings.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo } from '../runtime-settings.js';
import { getMilestones } from '../gitea-api.js';
import { getSessionUser, isAdmin } from './auth.js';
const PAGE_SIZE = 20;
@@ -52,6 +53,36 @@ export function registerApiRoutes(app, client) {
return { items, total, page, pageSize: PAGE_SIZE };
});
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
let roadmapCache = { at: 0, repo: null, data: null };
app.get('/api/roadmap', async (request, reply) => {
const repo = roadmapRepo();
if (roadmapCache.data && roadmapCache.repo === repo && Date.now() - roadmapCache.at < 5 * 60_000) {
return roadmapCache.data;
}
try {
const milestones = await getMilestones(repo);
const data = {
repo,
milestones: milestones.map((m) => ({
title: m.title,
description: m.description ?? '',
state: m.state, // 'open' | 'closed'
open: m.open_issues ?? 0,
closed: m.closed_issues ?? 0,
due_on: m.due_on ?? null,
})),
};
roadmapCache = { at: Date.now(), repo, data };
return data;
} catch (error) {
request.log.error({ err: error }, 'Roadmap-Abruf fehlgeschlagen');
// Letzten guten Stand weiterreichen, falls vorhanden
if (roadmapCache.data && roadmapCache.repo === repo) return roadmapCache.data;
return reply.code(502).send({ error: 'Roadmap nicht abrufbar' });
}
});
// RSS-Feed der Devlogs — öffentlich abonnierbar
app.get('/feed.xml', async (request, reply) => {
const { items } = listDevlogs(20, 0);
@@ -144,6 +175,7 @@ ${rssItems}
weekly_recap_enabled: getSetting('weekly_recap_enabled') !== '0',
devlog_threads_enabled: getSetting('devlog_threads_enabled') !== '0',
bug_report_repo: getSetting('bug_report_repo') ?? 'D4rkst3r/EcoGame',
roadmap_repo: getSetting('roadmap_repo') ?? 'D4rkst3r/EcoGame',
watchdog_urls: getSetting('watchdog_urls') ?? '',
public_url: publicUrl(),
gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
@@ -215,7 +247,7 @@ ${rssItems}
if (body.backup_enabled !== undefined) {
setSetting('backup_enabled', body.backup_enabled ? '1' : '0');
}
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls']) {
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo']) {
if (body[key] !== undefined) {
setSetting(key, String(body[key]).trim());
}