Vorbild windrose.support: Klick auf eine Idee, und man sieht sie ganz — Stand, Begruendung, wer sie eingereicht hat, und darunter, was dazu geschrieben wurde. Der Unterschied bleibt, wo geschrieben wird. Die Beitraege sind aus dem Discord-Thread gespiegelt, nicht hier getippt. Deshalb fuehrt der einzige Knopf am Ende in den Thread und nicht in ein Formular. Ein zweiter Ort fuer dieselbe Unterhaltung waere genau das, was ich beim Konzept als das Falsche bezeichnet habe. Bisher wurde nur mitgezaehlt. Jetzt landen die Beitraege in einer eigenen Tabelle — mit Bearbeiten und Loeschen, sonst stuende auf der Webseite fuer immer, was in Discord laengst zurueckgenommen wurde. Reine Bild-Posts ohne Text bleiben draussen, die haetten hier nichts zu sagen. Der Kommentar-Zaehler wird jetzt aus der Tabelle gezaehlt statt in einer Spalte mitgefuehrt. Eine Wahrheit statt zwei, die auseinanderlaufen koennen — dieselbe Ueberlegung wie beim Zuspruch. Nebenbei geprueft, weil es haette schiefgehen koennen: /api/wishes/suche und /api/wishes/:id liegen auf derselben Ebene. Der Router bevorzugt die feste Route vor der mit Platzhalter — nachgestellt mit find-my-way, nicht aus dem Gedaechtnis behauptet. Geprueft: im Browser die Seite mit drei Beitraegen und die ohne (kein Thread, keine Begruendung, Hinweis statt Liste), der Weg von der Roadmap dorthin ohne Neuladen, Migration erneut durchgespielt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
255 lines
12 KiB
React
255 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 WunschDetail from './pages/WunschDetail.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="/wunsch/:id" element={<WunschDetail />} />
|
|
<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>
|
|
);
|
|
}
|