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:
@@ -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>
|
||||
|
||||
@@ -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 & Datenschutz</h2>
|
||||
<p className="field-hint" style={{ marginBottom: '.9rem' }}>
|
||||
|
||||
@@ -1511,3 +1511,4 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
||||
}
|
||||
.btn-danger { border-color: rgba(242, 63, 67, .5); color: #f23f43; }
|
||||
.btn-danger:hover { border-color: #f23f43; background: rgba(242, 63, 67, .1); }
|
||||
.home-tile-ext { font-size: .8rem; color: var(--muted2); }
|
||||
|
||||
Reference in New Issue
Block a user