Files
d4rkbot/frontend/src/App.jsx
T
D4rkst3randClaude Opus 5 e32152b5ac
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Eigener Herzschlag: der Bot kann jetzt auch ueber sich selbst Auskunft geben
Der Waechter prueft alles ausser sich selbst — ist der Bot weg, prueft
niemand mehr, und hinterher sieht der Verlauf aus, als waere nie etwas
gewesen. Im Dashboard stand deshalb nur "seit 12d 4h", also wie lange es
diesmal gutgegangen ist. Was davor war, wusste niemand.

Jetzt schreibt der Bot alle zwei Minuten eine Zeile: laeuft der Prozess, und
steht die Verbindung zu Discord? Ein Bot, der laeuft aber nicht verbunden
ist, ist fuer alle draussen genauso weg — deshalb zaehlt beides.

Der Takt ist absichtlich nicht einstellbar. Ein spaeter geaenderter Abstand
wuerde den alten Verlauf falsch aussehen lassen: wie viele Proben eine
Stunde haette haben muessen, liesse sich rueckwirkend nicht mehr sagen.

Ausgewertet wird vor allem die Abwesenheit. Fuer einen fremden Dienst heisst
eine fehlende Stunde "niemand hat gemessen" und wird grau; beim Bot selbst
ist genau das die Aussage — fehlende Stunden werden rot. Grau bleibt nur,
was vor der allerersten Aufzeichnung liegt. Die angebrochene erste und die
laufende letzte Stunde rechnen anteilig, sonst waeren sie dauerhaft gelb.

Nebenbei mitgenommen:

- Statusseite und die frei angelegten Seiten stehen jetzt in der Fusszeile
  beider Seiten — dort sucht man Rechtliches. Die Navigationsleiste zeigt
  weiterhin nur, was "im Menü" gesetzt hat; wer die Nutzungsbedingungen dort
  raus nimmt, hat sie trotzdem noch in der Fusszeile.
- Die Config-Spalte hatte keine Obergrenze. min-width:0 allein reicht nicht,
  wenn ein Kind eine grosse Mindestbreite mitbringt — mit dem Balken wuchs
  sie auf 724 statt 335 px, und weil der Body waagerecht abschneidet, war
  der Ueberstand nicht scrollbar, sondern weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 07:10:53 +02:00

253 lines
12 KiB
React

import { useEffect, useMemo, useRef, useState } from 'react';
import { Routes, Route, NavLink, Navigate, useLocation } 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 Galerie from './pages/Galerie.jsx';
import Level from './pages/Level.jsx';
import Events from './pages/Events.jsx';
import Commits from './pages/Commits.jsx';
import Settings from './pages/Settings.jsx';
import Profil from './pages/Profil.jsx';
import Server from './pages/Server.jsx';
import Status from './pages/Status.jsx';
import Home from './pages/Home.jsx';
import DevlogDetail from './pages/DevlogDetail.jsx';
import Impressum from './pages/Impressum.jsx';
import Datenschutz from './pages/Datenschutz.jsx';
import Seite from './pages/Seite.jsx';
import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.jsx';
import { useT, LangSwitch } from './i18n.jsx';
// Beschriftung erst beim Rendern uebersetzen — der Pfad bleibt deutsch,
// damit geteilte Links weiter funktionieren.
const COMMUNITY_LINKS = [
['/status', 'nav.status'],
['/galerie', 'nav.gallery'],
['/level', 'nav.level'],
['/events', 'nav.events'],
['/changelog', 'nav.changelog'],
];
/** Dropdown in der Navbar (schließt bei Klick daneben und bei Navigation) */
function NavDropdown({ label, active, children }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, []);
return (
<div className={`nav-drop ${open ? 'open' : ''}`} ref={ref}>
<button className={`nav-drop-btn ${active ? 'active' : ''}`} onClick={() => setOpen((o) => !o)}>
{label} <IconChevronDown size={13} className="nav-caret" />
</button>
{open && <div className="nav-drop-menu" onClick={() => setOpen(false)}>{children}</div>}
</div>
);
}
function DiscordMark() {
return (
<svg className="discord-mark" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M20.32 4.37a19.8 19.8 0 0 0-4.93-1.51 13.78 13.78 0 0 0-.64 1.28 18.27 18.27 0 0 0-5.5 0 12.64 12.64 0 0 0-.64-1.28c-1.71.29-3.37.8-4.93 1.51A20.26 20.26 0 0 0 .1 18.06a19.9 19.9 0 0 0 6.07 3.03c.49-.66.93-1.37 1.3-2.1a12.9 12.9 0 0 1-2.05-.98c.17-.12.34-.25.5-.38a14.2 14.2 0 0 0 12.16 0c.16.13.33.26.5.38-.65.39-1.34.71-2.05.98.37.73.81 1.44 1.3 2.1a19.84 19.84 0 0 0 6.07-3.03 20.2 20.2 0 0 0-3.58-13.69ZM8.02 15.33c-1.18 0-2.16-1.08-2.16-2.42 0-1.33.95-2.42 2.16-2.42 1.21 0 2.18 1.09 2.16 2.42 0 1.34-.95 2.42-2.16 2.42Zm7.96 0c-1.18 0-2.15-1.08-2.15-2.42 0-1.33.95-2.42 2.15-2.42 1.22 0 2.18 1.09 2.16 2.42 0 1.34-.94 2.42-2.16 2.42Z"
/>
</svg>
);
}
/* Aufsteigende Neon-Partikel wie auf der Platform-Seite */
function Particles() {
const particles = useMemo(
() =>
Array.from({ length: 24 }, (_, i) => ({
id: i,
left: `${Math.random() * 100}%`,
dur: `${6 + Math.random() * 8}s`,
delay: `${Math.random() * 8}s`,
})),
[]
);
return (
<div className="particles-wrap" aria-hidden="true">
{particles.map((p) => (
<span
key={p.id}
className="particle"
style={{ left: p.left, '--dur': p.dur, '--delay': p.delay, bottom: 0 }}
/>
))}
</div>
);
}
export default function App() {
// Login-Status einmal beim Laden holen
const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true });
const [scrolled, setScrolled] = useState(false);
const [drawer, setDrawer] = useState(false);
const location = useLocation();
const { t } = useT();
const communityActive = COMMUNITY_LINKS.some(([to]) => location.pathname.startsWith(to));
// Drawer bei Seitenwechsel schließen
useEffect(() => { setDrawer(false); }, [location.pathname]);
// Member-Gate: OAuth war okay, aber User ist nicht auf dem Discord (?gate=1)
const [gated, setGated] = useState(() => new URLSearchParams(window.location.search).has('gate'));
const [invite, setInvite] = useState(null);
// Alle veroeffentlichten Seiten. Das Menue zeigt nur die mit „im Menü";
// im Fusszeilen-Bereich stehen sie alle — dort sucht man Rechtliches.
const [pages, setPages] = useState([]);
useEffect(() => {
apiGet('/api/me')
.then((data) => setMe({ scopes: [], ...data, loading: false }))
.catch(() => setMe({ user: null, admin: false, scopes: [], loading: false }));
apiGet('/api/pages').then((d) => setPages(d.pages)).catch(() => {});
}, []);
useEffect(() => {
if (gated) apiGet('/api/invite').then((d) => setInvite(d.invite)).catch(() => {});
}, [gated]);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div className="app">
<Particles />
<header className={`nav ${scrolled ? 'scrolled' : ''}`}>
<NavLink to="/" className="nav-logo">
D4RKST<span>3R</span>
</NavLink>
<nav className="nav-links">
<NavLink to="/devlogs">{t('nav.devlog')}</NavLink>
<NavLink to="/roadmap">{t('nav.roadmap')}</NavLink>
<NavLink to="/server">{t('nav.server')}</NavLink>
<NavDropdown label={t('nav.community')} active={communityActive}>
{COMMUNITY_LINKS.map(([to, key]) => (
<NavLink to={to} key={to}>{t(key)}</NavLink>
))}
{pages.filter((p) => p.in_menu).map((p) => (
<NavLink to={`/${p.slug}`} key={p.slug}>{p.title}</NavLink>
))}
</NavDropdown>
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">{t('nav.config')}</NavLink>}
</nav>
<div className="auth">
<LangSwitch />
{me.loading ? null : me.user ? (
<NavDropdown
label={
<span className="user-chip-inner">
{me.user.avatar && <img className="avatar" src={me.user.avatar} alt="" />}
<span className="username">{me.user.username}</span>
</span>
}
>
<NavLink to="/profil">{t('nav.profile')}</NavLink>
{me.admin && <NavLink to="/commits">{t('nav.commits')}</NavLink>}
<a href="/auth/logout">{t('nav.logout')}</a>
</NavDropdown>
) : (
<a className="btn-login" href="/auth/login">
<DiscordMark /> {t('nav.login')}
</a>
)}
</div>
<button className="burger" onClick={() => setDrawer((d) => !d)} aria-label={t('nav.menu')}>
{drawer ? <IconX size={20} /> : <IconMenu size={20} />}
</button>
</header>
{drawer && (
<nav className="drawer" onClick={() => setDrawer(false)}>
<NavLink to="/devlogs">{t('nav.devlog')}</NavLink>
<NavLink to="/roadmap">{t('nav.roadmap')}</NavLink>
<NavLink to="/server">{t('nav.server')}</NavLink>
{COMMUNITY_LINKS.map(([to, key]) => (
<NavLink to={to} key={to}>{t(key)}</NavLink>
))}
{pages.map((p) => (
<NavLink to={`/${p.slug}`} key={p.slug}>{p.title}</NavLink>
))}
{me.user && <NavLink to="/profil">{t('nav.profile')}</NavLink>}
{me.admin && <NavLink to="/commits">{t('nav.commits')}</NavLink>}
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">{t('nav.config')}</NavLink>}
{me.user
? <a href="/auth/logout">{t('nav.logout')}</a>
: <a href="/auth/login">{t('nav.loginDiscord')}</a>}
{/* Auf schmalen Schirmen ist .auth ausgeblendet — der
Sprachwechsel muss trotzdem erreichbar bleiben. */}
<div className="drawer-lang" onClick={(e) => e.stopPropagation()}>
<LangSwitch />
</div>
</nav>
)}
<main className="main">
{gated && (
<div className="gate-banner">
<span className="with-icon">
<IconLock />
{t('gate.text')}
</span>
{invite && (
<a className="btn btn-save" href={invite} target="_blank" rel="noreferrer">
{t('gate.join')}
</a>
)}
<button className="card-delete" onClick={() => setGated(false)} title={t('gate.hide')}><IconX size={15} /></button>
</div>
)}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/devlogs" element={<Devlogs me={me} />} />
<Route path="/devlogs/:id" element={<DevlogDetail />} />
<Route path="/roadmap" element={<Roadmap />} />
<Route path="/server" element={<Server />} />
<Route path="/status" element={<Status />} />
<Route path="/galerie" element={<Galerie />} />
<Route path="/level" element={<Level />} />
<Route path="/events" element={<Events />} />
<Route path="/changelog" element={<Changelog />} />
<Route path="/profil" element={<Profil me={me} />} />
<Route path="/impressum" element={<Impressum />} />
<Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/commits" element={<Commits me={me} />} />
<Route path="/settings" element={<Settings me={me} />} />
{/* Frei angelegte Seiten — muss hinter allen festen Routen stehen */}
<Route path="/:slug" element={<Seite />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<footer className="footer">
<span className="footer-logo">D4RKST<span>3R</span></span>
<span className="footer-sep">//</span>
<a href="https://git.d4rkst3r.de/D4rkst3r" target="_blank" rel="noreferrer">
git.d4rkst3r.de
</a>
<span className="footer-sep">//</span>
<NavLink to="/status">{t('footer.status')}</NavLink>
<NavLink to="/impressum">{t('footer.imprint')}</NavLink>
<NavLink to="/datenschutz">{t('footer.privacy')}</NavLink>
{pages.map((p) => (
<NavLink to={`/${p.slug}`} key={p.slug}>{p.title}</NavLink>
))}
</footer>
</div>
);
}