Ö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:
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate } from 'react-router-dom';
|
|||||||
import { apiGet } from './api.js';
|
import { apiGet } from './api.js';
|
||||||
import Devlogs from './pages/Devlogs.jsx';
|
import Devlogs from './pages/Devlogs.jsx';
|
||||||
import Changelog from './pages/Changelog.jsx';
|
import Changelog from './pages/Changelog.jsx';
|
||||||
|
import Roadmap from './pages/Roadmap.jsx';
|
||||||
import Commits from './pages/Commits.jsx';
|
import Commits from './pages/Commits.jsx';
|
||||||
import Settings from './pages/Settings.jsx';
|
import Settings from './pages/Settings.jsx';
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ export default function App() {
|
|||||||
|
|
||||||
<nav className="nav-links">
|
<nav className="nav-links">
|
||||||
<NavLink to="/devlogs">Devlog</NavLink>
|
<NavLink to="/devlogs">Devlog</NavLink>
|
||||||
|
<NavLink to="/roadmap">Roadmap</NavLink>
|
||||||
<NavLink to="/changelog">Changelog</NavLink>
|
<NavLink to="/changelog">Changelog</NavLink>
|
||||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||||
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
||||||
@@ -94,6 +96,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
||||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||||
|
<Route path="/roadmap" element={<Roadmap />} />
|
||||||
<Route path="/changelog" element={<Changelog />} />
|
<Route path="/changelog" element={<Changelog />} />
|
||||||
<Route path="/commits" element={<Commits me={me} />} />
|
<Route path="/commits" element={<Commits me={me} />} />
|
||||||
<Route path="/settings" element={<Settings me={me} />} />
|
<Route path="/settings" element={<Settings me={me} />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -270,6 +270,15 @@ export default function Settings({ me }) {
|
|||||||
onChange={(e) => setForm({ ...form, bug_report_repo: e.target.value })}
|
onChange={(e) => setForm({ ...form, bug_report_repo: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Watchdog */}
|
{/* Watchdog */}
|
||||||
|
|||||||
@@ -398,6 +398,35 @@ body::after {
|
|||||||
.card-footer a { color: var(--muted); border-bottom: 1px solid transparent; }
|
.card-footer a { color: var(--muted); border-bottom: 1px solid transparent; }
|
||||||
.card-footer a:hover { color: var(--neon); }
|
.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 ─────────────────────────────────────── */
|
/* ── CHANGELOG ─────────────────────────────────────── */
|
||||||
.release-card { margin-bottom: 1.5rem; }
|
.release-card { margin-bottom: 1.5rem; }
|
||||||
.release-tag {
|
.release-tag {
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ export async function createIssue(repo, title, body) {
|
|||||||
return { number: issue.number, html_url: issue.html_url };
|
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 */
|
/** Datei (z. B. Screenshot) als Issue-Attachment hochladen */
|
||||||
export async function uploadIssueAsset(repo, issueNumber, filename, buffer) {
|
export async function uploadIssueAsset(repo, issueNumber, filename, buffer) {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export function bugReportRepo() {
|
|||||||
return getSetting('bug_report_repo') || 'D4rkst3r/EcoGame';
|
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 */
|
/** Rolle, die bei Devlog-Posts gepingt wird — leer = kein Ping */
|
||||||
export function devlogPingRoleId() {
|
export function devlogPingRoleId() {
|
||||||
return getSetting('devlog_ping_role_id') || null;
|
return getSetting('devlog_ping_role_id') || null;
|
||||||
|
|||||||
+34
-2
@@ -6,7 +6,8 @@ import {
|
|||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { removeDevlog } from '../bot/devlog-archive.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';
|
import { getSessionUser, isAdmin } from './auth.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -52,6 +53,36 @@ export function registerApiRoutes(app, client) {
|
|||||||
return { items, total, page, pageSize: PAGE_SIZE };
|
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
|
// RSS-Feed der Devlogs — öffentlich abonnierbar
|
||||||
app.get('/feed.xml', async (request, reply) => {
|
app.get('/feed.xml', async (request, reply) => {
|
||||||
const { items } = listDevlogs(20, 0);
|
const { items } = listDevlogs(20, 0);
|
||||||
@@ -144,6 +175,7 @@ ${rssItems}
|
|||||||
weekly_recap_enabled: getSetting('weekly_recap_enabled') !== '0',
|
weekly_recap_enabled: getSetting('weekly_recap_enabled') !== '0',
|
||||||
devlog_threads_enabled: getSetting('devlog_threads_enabled') !== '0',
|
devlog_threads_enabled: getSetting('devlog_threads_enabled') !== '0',
|
||||||
bug_report_repo: getSetting('bug_report_repo') ?? 'D4rkst3r/EcoGame',
|
bug_report_repo: getSetting('bug_report_repo') ?? 'D4rkst3r/EcoGame',
|
||||||
|
roadmap_repo: getSetting('roadmap_repo') ?? 'D4rkst3r/EcoGame',
|
||||||
watchdog_urls: getSetting('watchdog_urls') ?? '',
|
watchdog_urls: getSetting('watchdog_urls') ?? '',
|
||||||
public_url: publicUrl(),
|
public_url: publicUrl(),
|
||||||
gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
|
gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
|
||||||
@@ -215,7 +247,7 @@ ${rssItems}
|
|||||||
if (body.backup_enabled !== undefined) {
|
if (body.backup_enabled !== undefined) {
|
||||||
setSetting('backup_enabled', body.backup_enabled ? '1' : '0');
|
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) {
|
if (body[key] !== undefined) {
|
||||||
setSetting(key, String(body[key]).trim());
|
setSetting(key, String(body[key]).trim());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user