Portal-Dienste und geteiltes Design-System für die anderen Apps

- Dienste (Gitea, Kanban, Cloud …) werden im System-Tab gepflegt und
  erscheinen als Kacheln auf der Startseite; GET /api/services ist
  öffentlich, Pflege braucht den settings-Scope
- /brand.css liefert die Design-Tokens (Farben live aus dem Brand-Tab,
  ändern sich damit überall mit) plus Basis-Klassen d4rk-card,
  d4rk-btn, d4rk-title, d4rk-tag
- /brand-nav.js baut die D4RKST3R-Leiste in jede fremde App ein, mit
  Links zum Hub und zu allen gepflegten Diensten; die Links stecken
  fertig im Skript, dadurch kein zweiter Request und kein CORS nötig
- Beide Dateien mit offenem CORS-Header und 5 Minuten Cache
- Doku für Anbindung + Schriften in docs/sso.md ergänzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 13:28:50 +02:00
co-authored by Claude Fable 5
parent 70cae85064
commit c73b4e1074
9 changed files with 350 additions and 1 deletions
+17
View File
@@ -28,12 +28,14 @@ export default function Home() {
const [servers, setServers] = useState(null);
const [latest, setLatest] = useState(null);
const [invite, setInvite] = useState(null);
const [services, setServices] = useState([]);
useEffect(() => {
apiGet('/api/serverstats').then(setStats).catch(() => {});
apiGet('/api/servers').then((d) => setServers(d.servers)).catch(() => {});
apiGet('/api/devlogs?page=1').then((d) => setLatest(d.items?.[0] ?? null)).catch(() => {});
apiGet('/api/invite').then((d) => setInvite(d.invite)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
window.scrollTo(0, 0);
}, []);
@@ -95,6 +97,21 @@ export default function Home() {
))}
</div>
{services.length > 0 && (
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
<h2 className="settings-title">// Dienste</h2>
<div className="home-tiles">
{services.map((s) => (
<a className="card home-tile" href={s.url} target="_blank" rel="noreferrer" key={s.id}>
<span className="home-tile-icon">{s.icon}</span>
<span className="home-tile-title">{s.name} <span className="home-tile-ext"></span></span>
{s.description && <span className="home-tile-desc">{s.description}</span>}
</a>
))}
</div>
</div>
)}
{latest && (
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
<h2 className="settings-title">// Neuestes Devlog</h2>
+63
View File
@@ -161,6 +161,10 @@ export default function Settings({ me }) {
const [botDesc, setBotDesc] = useState('');
const [team, setTeam] = useState([]);
const [audit, setAudit] = useState([]);
// Portal-Dienste (Kacheln auf der Startseite)
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
const [services, setServices] = useState([]);
const [serviceDraft, setServiceDraft] = useState(emptyService);
// SSO-Apps (Fremd-Dienste mit Discord-Login)
const [ssoApps, setSsoApps] = useState([]);
const [ssoDraft, setSsoDraft] = useState({ slug: '', name: '', redirect_prefix: '' });
@@ -208,6 +212,7 @@ export default function Settings({ me }) {
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
}, [me.admin, me.scopes?.length]);
useEffect(() => {
@@ -241,6 +246,28 @@ export default function Settings({ me }) {
if (res) setTeam(res.admins);
}
async function saveService() {
if (!serviceDraft.name.trim() || !serviceDraft.url.trim()) {
flash('✗ Name und URL nötig');
return;
}
try {
const res = serviceDraft.id
? await apiPut(`/api/services/${serviceDraft.id}`, serviceDraft)
: await apiPost('/api/services', serviceDraft);
setServices(res.services);
setServiceDraft(emptyService);
flash('✓ Dienst gespeichert');
} catch {
flash('✗ Speichern fehlgeschlagen (URL prüfen)');
}
}
async function removeService(id) {
const res = await apiDelete(`/api/services/${id}`).catch(() => null);
if (res) setServices(res.services);
}
async function createSsoApp() {
if (!ssoDraft.slug.trim() || !ssoDraft.name.trim() || !ssoDraft.redirect_prefix.trim()) {
flash('✗ Kürzel, Name und Rücksprung-URL nötig');
@@ -1246,6 +1273,42 @@ export default function Settings({ me }) {
</div>
<div className="settings-section">
<h2 className="settings-title">// Dienste (Portal)</h2>
<p className="section-intro">
Erscheinen als Kacheln auf der Startseite z. B. Gitea, Kanban oder die Cloud.
</p>
{services.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{services.map((s) => (
<div className="key-row" key={s.id}>
<span className="key-name">{s.icon} {s.name}</span>
<span className="key-used" title={s.url}>{s.url}</span>
<button className="btn btn-mini" onClick={() => setServiceDraft({
id: s.id, name: s.name, url: s.url, icon: s.icon,
description: s.description ?? '', sort: s.sort ?? 0,
})}>Bearbeiten</button>
<button className="card-delete" title="Dienst entfernen" onClick={() => removeService(s.id)}></button>
</div>
))}
</div>
)}
<div className="field">
<label>{serviceDraft.id ? `Dienst #${serviceDraft.id} bearbeiten` : 'Neuer Dienst'}</label>
<div className="field-row">
<input type="text" style={{ flex: 'none', width: '4rem' }} placeholder="🗂️" value={serviceDraft.icon} onChange={(e) => setServiceDraft({ ...serviceDraft, icon: e.target.value })} />
<input type="text" style={{ flex: 'none', width: '10rem' }} placeholder="Kanban" value={serviceDraft.name} onChange={(e) => setServiceDraft({ ...serviceDraft, name: e.target.value })} />
<input type="text" placeholder="https://kanban.d4rkst3r.de" value={serviceDraft.url} onChange={(e) => setServiceDraft({ ...serviceDraft, url: e.target.value })} />
<input type="number" style={{ flex: 'none', width: '5rem' }} placeholder="Pos." value={serviceDraft.sort} onChange={(e) => setServiceDraft({ ...serviceDraft, sort: e.target.value })} />
</div>
<div className="field-row" style={{ marginTop: '.6rem' }}>
<input type="text" placeholder="Kurzbeschreibung für die Kachel" value={serviceDraft.description} onChange={(e) => setServiceDraft({ ...serviceDraft, description: e.target.value })} />
<button className="btn btn-save" onClick={saveService}>{serviceDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
{serviceDraft.id && <button className="btn btn-ghost" onClick={() => setServiceDraft(emptyService)}>Abbrechen</button>}
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Impressum &amp; Datenschutz</h2>
<p className="field-hint" style={{ marginBottom: '.9rem' }}>