Englisch als zweite Sprache fuer die oeffentlichen Seiten
Hub und Bot-Produktseite lassen sich jetzt auf Englisch umstellen. Die Wahl haengt am Browser (Deutsch nur, wenn der Browser es will) und bleibt im localStorage stehen. - src/i18n.jsx: Context mit t(), tOr(), Datums- und Zahlenformat der Sprache. Keine Bibliothek, die Woerterbuecher werden mitgebaut — kein Nachlade-Blitzer. - Platzhalter im %s-Stil, dieselbe Schreibweise wie in den Bot-Texten. - Umschalter in der Navigation, zusaetzlich im Schubladen-Menue: .auth ist auf schmalen Schirmen ausgeblendet, sonst waere der Wechsel dort unerreichbar. - Datums- und Zahlenformat folgen mit (31.07.2026 / 31/07/2026, 45.231 / 45,231). - Die Funktionsliste kommt weiter deutsch vom Server; englische Modulnamen liegen unter feat.<id> im Woerterbuch. Fehlt einer, bleibt der Servertext stehen — ein neues Modul verschwindet nie von der Seite. Das Config-Panel und die Discord-Texte bleiben deutsch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+41
-32
@@ -17,12 +17,15 @@ 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 = [
|
||||
['/galerie', 'Galerie'],
|
||||
['/level', 'Level'],
|
||||
['/events', 'Events'],
|
||||
['/changelog', 'Changelog'],
|
||||
['/galerie', 'nav.gallery'],
|
||||
['/level', 'nav.level'],
|
||||
['/events', 'nav.events'],
|
||||
['/changelog', 'nav.changelog'],
|
||||
];
|
||||
|
||||
/** Dropdown in der Navbar (schließt bei Klick daneben und bei Navigation) */
|
||||
@@ -86,6 +89,7 @@ export default function App() {
|
||||
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
|
||||
@@ -125,21 +129,22 @@ export default function App() {
|
||||
</NavLink>
|
||||
|
||||
<nav className="nav-links">
|
||||
<NavLink to="/devlogs">Devlog</NavLink>
|
||||
<NavLink to="/roadmap">Roadmap</NavLink>
|
||||
<NavLink to="/server">Server</NavLink>
|
||||
<NavDropdown label="Community" active={communityActive}>
|
||||
{COMMUNITY_LINKS.map(([to, label]) => (
|
||||
<NavLink to={to} key={to}>{label}</NavLink>
|
||||
<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.map((p) => (
|
||||
<NavLink to={`/${p.slug}`} key={p.slug}>{p.title}</NavLink>
|
||||
))}
|
||||
</NavDropdown>
|
||||
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Config</NavLink>}
|
||||
{(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={
|
||||
@@ -149,39 +154,44 @@ export default function App() {
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<NavLink to="/profil">Profil</NavLink>
|
||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||
<a href="/auth/logout">Logout</a>
|
||||
<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 /> Login
|
||||
<DiscordMark /> {t('nav.login')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="burger" onClick={() => setDrawer((d) => !d)} aria-label="Menü">
|
||||
<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">Devlog</NavLink>
|
||||
<NavLink to="/roadmap">Roadmap</NavLink>
|
||||
<NavLink to="/server">Server</NavLink>
|
||||
{COMMUNITY_LINKS.map(([to, label]) => (
|
||||
<NavLink to={to} key={to}>{label}</NavLink>
|
||||
<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">Profil</NavLink>}
|
||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||
{(me.admin || me.scopes?.length > 0) && <NavLink to="/settings">Config</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">Logout</a>
|
||||
: <a href="/auth/login">Login mit Discord</a>}
|
||||
? <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>
|
||||
)}
|
||||
|
||||
@@ -190,15 +200,14 @@ export default function App() {
|
||||
<div className="gate-banner">
|
||||
<span className="with-icon">
|
||||
<IconLock />
|
||||
Der Login ist Mitgliedern des Discord-Servers vorbehalten —
|
||||
tritt erst dem Server bei und logg dich dann nochmal ein.
|
||||
{t('gate.text')}
|
||||
</span>
|
||||
{invite && (
|
||||
<a className="btn btn-save" href={invite} target="_blank" rel="noreferrer">
|
||||
Zum Discord
|
||||
{t('gate.join')}
|
||||
</a>
|
||||
)}
|
||||
<button className="card-delete" onClick={() => setGated(false)} title="Ausblenden"><IconX size={15} /></button>
|
||||
<button className="card-delete" onClick={() => setGated(false)} title={t('gate.hide')}><IconX size={15} /></button>
|
||||
</div>
|
||||
)}
|
||||
<Routes>
|
||||
@@ -229,8 +238,8 @@ export default function App() {
|
||||
git.d4rkst3r.de
|
||||
</a>
|
||||
<span className="footer-sep">//</span>
|
||||
<NavLink to="/impressum">Impressum</NavLink>
|
||||
<NavLink to="/datenschutz">Datenschutz</NavLink>
|
||||
<NavLink to="/impressum">{t('footer.imprint')}</NavLink>
|
||||
<NavLink to="/datenschutz">{t('footer.privacy')}</NavLink>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
+24
-16
@@ -10,6 +10,7 @@ import Settings from './pages/Settings.jsx';
|
||||
import Impressum from './pages/Impressum.jsx';
|
||||
import Datenschutz from './pages/Datenschutz.jsx';
|
||||
import { IconMenu, IconX } from './icons.jsx';
|
||||
import { useT, LangSwitch } from './i18n.jsx';
|
||||
|
||||
export default function BotApp() {
|
||||
const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true });
|
||||
@@ -17,6 +18,7 @@ export default function BotApp() {
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [hub, setHub] = useState(null);
|
||||
const location = useLocation();
|
||||
const { t } = useT();
|
||||
|
||||
useEffect(() => { setDrawer(false); }, [location.pathname]);
|
||||
|
||||
@@ -43,40 +45,46 @@ export default function BotApp() {
|
||||
</NavLink>
|
||||
|
||||
<nav className="nav-links">
|
||||
<NavLink to="/features">Funktionen</NavLink>
|
||||
<NavLink to="/commands">Befehle</NavLink>
|
||||
{canDashboard && <NavLink to="/dashboard">Dashboard</NavLink>}
|
||||
{hub && <a href={hub}>Community</a>}
|
||||
<NavLink to="/features">{t('nav.features')}</NavLink>
|
||||
<NavLink to="/commands">{t('nav.commands')}</NavLink>
|
||||
{canDashboard && <NavLink to="/dashboard">{t('nav.dashboard')}</NavLink>}
|
||||
{hub && <a href={hub}>{t('nav.community')}</a>}
|
||||
</nav>
|
||||
|
||||
<div className="auth">
|
||||
<LangSwitch />
|
||||
{me.loading ? null : me.user ? (
|
||||
<span className="user-chip">
|
||||
{me.user.avatar && <img className="avatar" src={me.user.avatar} alt="" />}
|
||||
<span className="username">{me.user.username}</span>
|
||||
<a className="logout" href="/auth/logout" title="Abmelden">
|
||||
<a className="logout" href="/auth/logout" title={t('nav.signOut')}>
|
||||
<IconX size={13} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<a className="btn-login" href="/auth/login">Anmelden</a>
|
||||
<a className="btn-login" href="/auth/login">{t('nav.signIn')}</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="burger" onClick={() => setDrawer((d) => !d)} aria-label="Menü">
|
||||
<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="/features">Funktionen</NavLink>
|
||||
<NavLink to="/commands">Befehle</NavLink>
|
||||
{canDashboard && <NavLink to="/dashboard">Dashboard</NavLink>}
|
||||
{hub && <a href={hub}>Community</a>}
|
||||
<NavLink to="/features">{t('nav.features')}</NavLink>
|
||||
<NavLink to="/commands">{t('nav.commands')}</NavLink>
|
||||
{canDashboard && <NavLink to="/dashboard">{t('nav.dashboard')}</NavLink>}
|
||||
{hub && <a href={hub}>{t('nav.community')}</a>}
|
||||
{me.user
|
||||
? <a href="/auth/logout">Abmelden</a>
|
||||
: <a href="/auth/login">Anmelden</a>}
|
||||
? <a href="/auth/logout">{t('nav.signOut')}</a>
|
||||
: <a href="/auth/login">{t('nav.signIn')}</a>}
|
||||
{/* Auf schmalen Schirmen ist .auth ausgeblendet — der
|
||||
Sprachwechsel muss trotzdem erreichbar bleiben. */}
|
||||
<div className="drawer-lang" onClick={(e) => e.stopPropagation()}>
|
||||
<LangSwitch />
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
@@ -96,11 +104,11 @@ export default function BotApp() {
|
||||
<span className="footer-logo">D4RKBOT<span>.</span></span>
|
||||
<span className="footer-sep">//</span>
|
||||
<a href="https://git.d4rkst3r.de/D4rkst3r/d4rkbot" target="_blank" rel="noreferrer">
|
||||
Quellcode
|
||||
{t('footer.source')}
|
||||
</a>
|
||||
<span className="footer-sep">//</span>
|
||||
<NavLink to="/impressum">Impressum</NavLink>
|
||||
<NavLink to="/datenschutz">Datenschutz</NavLink>
|
||||
<NavLink to="/impressum">{t('footer.imprint')}</NavLink>
|
||||
<NavLink to="/datenschutz">{t('footer.privacy')}</NavLink>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Zweisprachigkeit für die öffentlichen Seiten (Hub + Bot-Produktseite).
|
||||
//
|
||||
// Bewusst ohne Bibliothek: ein Wörterbuch pro Sprache, ein Context, fertig.
|
||||
// Die Wörterbücher werden mitgebaut, es gibt also keinen Nachlade-Blitzer beim
|
||||
// ersten Rendern. Das Config-Panel bleibt deutsch — dort steht der Owner davor.
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import de from './locales/de.js';
|
||||
import en from './locales/en.js';
|
||||
|
||||
const WOERTERBUCH = { de, en };
|
||||
export const SPRACHEN = [['de', 'DE'], ['en', 'EN']];
|
||||
const SPEICHER_KEY = 'd4rk_lang';
|
||||
|
||||
/** Gespeicherte Wahl schlägt Browsersprache. Deutsch nur, wenn der Browser
|
||||
* es auch will — sonst Englisch, das versteht der Rest der Welt eher. */
|
||||
function ermitteln() {
|
||||
try {
|
||||
const gewaehlt = localStorage.getItem(SPEICHER_KEY);
|
||||
if (WOERTERBUCH[gewaehlt]) return gewaehlt;
|
||||
} catch {
|
||||
// Privater Modus ohne localStorage — dann eben jedes Mal neu erkennen
|
||||
}
|
||||
const browser = (navigator.languages?.[0] ?? navigator.language ?? '').toLowerCase();
|
||||
return browser.startsWith('de') ? 'de' : 'en';
|
||||
}
|
||||
|
||||
const LangContext = createContext({
|
||||
lang: 'de', setLang: () => {}, t: (k) => k, tOr: (k, f) => f,
|
||||
});
|
||||
|
||||
export function LangProvider({ children }) {
|
||||
const [lang, setLangState] = useState(ermitteln);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang;
|
||||
}, [lang]);
|
||||
|
||||
const setLang = (neu) => {
|
||||
if (!WOERTERBUCH[neu]) return;
|
||||
setLangState(neu);
|
||||
try {
|
||||
localStorage.setItem(SPEICHER_KEY, neu);
|
||||
} catch {
|
||||
// nicht schlimm, dann gilt die Wahl nur für diesen Besuch
|
||||
}
|
||||
};
|
||||
|
||||
// %s-Platzhalter der Reihe nach ersetzen — dasselbe Format wie in den
|
||||
// Bot-Texten, damit man nicht zwei Schreibweisen im Kopf haben muss.
|
||||
const t = (key, ...args) => {
|
||||
const roh = WOERTERBUCH[lang]?.[key] ?? WOERTERBUCH.de[key];
|
||||
if (roh == null) return key;
|
||||
if (typeof roh !== 'string' || args.length === 0) return roh;
|
||||
let i = 0;
|
||||
return roh.replace(/%s/g, () => String(args[i++] ?? ''));
|
||||
};
|
||||
|
||||
// Für Inhalte, die der Server deutsch liefert (Modul-Namen etwa): gibt es
|
||||
// keine Übersetzung, bleibt der Servertext stehen statt eines nackten
|
||||
// Schlüssels. So verschwindet eine neue Funktion nicht von der Seite.
|
||||
const tOr = (key, fallback) => {
|
||||
const roh = WOERTERBUCH[lang]?.[key];
|
||||
return roh == null ? fallback : roh;
|
||||
};
|
||||
|
||||
return (
|
||||
<LangContext.Provider value={{ lang, setLang, t, tOr }}>
|
||||
{children}
|
||||
</LangContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useT() {
|
||||
return useContext(LangContext);
|
||||
}
|
||||
|
||||
// Intl-Formatter sind teuer zu bauen und werden in Listen oft gebraucht —
|
||||
// einmal pro Sprache und Optionen reicht.
|
||||
const formatCache = new Map();
|
||||
function formatter(tag, opts) {
|
||||
const key = tag + JSON.stringify(opts);
|
||||
let f = formatCache.get(key);
|
||||
if (!f) {
|
||||
f = new Intl.DateTimeFormat(tag, opts);
|
||||
formatCache.set(key, f);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Datums- und Zahlenformat der aktiven Sprache */
|
||||
export function useFmt() {
|
||||
const { lang } = useT();
|
||||
// en-GB statt en-US: Tag vor Monat, wie es der Rest der Seite auch macht
|
||||
const tag = lang === 'de' ? 'de-DE' : 'en-GB';
|
||||
return {
|
||||
tag,
|
||||
datum: (wert, opts) => formatter(tag, opts).format(new Date(wert)),
|
||||
zahl: (n) => Number(n ?? 0).toLocaleString(tag),
|
||||
};
|
||||
}
|
||||
|
||||
/** Umschalter für die Navigation */
|
||||
export function LangSwitch() {
|
||||
const { lang, setLang } = useT();
|
||||
return (
|
||||
<div className="lang-switch" role="group" aria-label="Language">
|
||||
{SPRACHEN.map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
className={`lang-btn${lang === id ? ' on' : ''}`}
|
||||
onClick={() => setLang(id)}
|
||||
aria-pressed={lang === id}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Deutsche Texte der öffentlichen Seiten. Diese Datei ist die Referenz:
|
||||
// Was hier steht, muss es auch in en.js geben — fehlt ein Schlüssel dort,
|
||||
// fällt die Anzeige auf Deutsch zurück statt auf den nackten Schlüssel.
|
||||
export default {
|
||||
// ── Navigation & Rahmen ───────────────────────────
|
||||
'nav.devlog': 'Devlog',
|
||||
'nav.roadmap': 'Roadmap',
|
||||
'nav.server': 'Server',
|
||||
'nav.community': 'Community',
|
||||
'nav.gallery': 'Galerie',
|
||||
'nav.level': 'Level',
|
||||
'nav.events': 'Events',
|
||||
'nav.changelog': 'Changelog',
|
||||
'nav.config': 'Config',
|
||||
'nav.profile': 'Profil',
|
||||
'nav.commits': 'Commits',
|
||||
'nav.login': 'Login',
|
||||
'nav.loginDiscord': 'Login mit Discord',
|
||||
'nav.logout': 'Logout',
|
||||
'nav.features': 'Funktionen',
|
||||
'nav.commands': 'Befehle',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.signIn': 'Anmelden',
|
||||
'nav.signOut': 'Abmelden',
|
||||
'nav.menu': 'Menü',
|
||||
'footer.source': 'Quellcode',
|
||||
'footer.imprint': 'Impressum',
|
||||
'footer.privacy': 'Datenschutz',
|
||||
'gate.text': 'Der Login ist Mitgliedern des Discord-Servers vorbehalten — tritt erst dem Server bei und logg dich dann nochmal ein.',
|
||||
'gate.join': 'Zum Discord',
|
||||
'gate.hide': 'Ausblenden',
|
||||
|
||||
// ── Allgemein ─────────────────────────────────────
|
||||
'common.loading': 'Lade …',
|
||||
'common.newer': 'Neuere',
|
||||
'common.older': 'Ältere',
|
||||
'common.readDevlogs': 'Devlogs lesen',
|
||||
'common.toRoadmap': 'Zur Roadmap',
|
||||
'common.today': 'heute',
|
||||
'common.yesterday': 'gestern',
|
||||
'common.daysAgo': 'vor %s Tagen',
|
||||
'common.monthAgo': 'vor 1 Monat',
|
||||
'common.monthsAgo': 'vor %s Monaten',
|
||||
|
||||
// ── Startseite (Hub) ──────────────────────────────
|
||||
'home.tag': '// Community-Hub',
|
||||
'home.welcome': 'Willkommen',
|
||||
'home.at': 'bei',
|
||||
'home.subtitle': 'Devlogs, Roadmap, Server-Status und alles aus der Community — an einem Ort, direkt aus dem Discord.',
|
||||
'home.discord': 'Zum Discord',
|
||||
'home.readDevlogs': 'Devlogs lesen',
|
||||
'home.members': 'Member im Discord',
|
||||
'home.serversOnline': 'Server online',
|
||||
'home.playersNow': 'Spieler gerade drauf',
|
||||
'home.services': '// Dienste',
|
||||
'home.latestDevlog': '// Neuestes Devlog',
|
||||
'home.more': 'Weiterlesen',
|
||||
'home.tile.devlog': 'Entwicklungs-Updates, frisch aus dem Editor',
|
||||
'home.tile.roadmap': 'Meilensteine + Community-Wünsche zum Abstimmen',
|
||||
'home.tile.server': 'Live-Status aller Game-Server mit Verlauf',
|
||||
'home.tile.gallery': 'Screenshots aus der Community',
|
||||
'home.tile.level': 'XP-Bestenliste — wer redet am meisten mit?',
|
||||
'home.tile.events': 'Playtests, Streams und was sonst ansteht',
|
||||
|
||||
// ── Devlogs ───────────────────────────────────────
|
||||
'devlogs.tag': '// Archiv',
|
||||
'devlogs.subtitle': 'Entwicklungs-Updates, frisch aus dem Editor — automatisch archiviert.',
|
||||
'devlogs.all': 'Alle',
|
||||
'devlogs.search': 'Devlogs durchsuchen …',
|
||||
'devlogs.hits': '%s Treffer',
|
||||
'devlogs.error': 'Devlogs konnten nicht geladen werden.',
|
||||
'devlogs.noHits': 'Keine Treffer für „%s".',
|
||||
'devlogs.noneForProject': 'Noch keine Devlogs für %s.',
|
||||
'devlogs.none': 'Noch keine Devlogs archiviert.',
|
||||
'devlogs.permalink': 'Direktlink zu diesem Devlog',
|
||||
'devlogs.delete': 'Aus dem Archiv löschen',
|
||||
'devlogs.confirmDelete': 'Dieses Devlog aus dem Archiv löschen?',
|
||||
'devlogs.deleteFailed': 'Löschen fehlgeschlagen.',
|
||||
'devlogs.screenshot': 'Devlog-Screenshot',
|
||||
|
||||
// ── Devlog-Permalink ──────────────────────────────
|
||||
'devlog.tag': '// Devlog',
|
||||
'devlog.project': 'Projekt: %s',
|
||||
'devlog.notFound': 'Devlog nicht gefunden —',
|
||||
'devlog.backToArchive': 'zurück zum Archiv',
|
||||
'devlog.allDevlogs': 'Alle Devlogs',
|
||||
'devlog.copyLink': 'Link kopieren',
|
||||
'devlog.linkCopied': 'Link kopiert',
|
||||
|
||||
// ── Roadmap ───────────────────────────────────────
|
||||
'roadmap.tag': '// Wohin die Reise geht',
|
||||
'roadmap.subtitle': 'Meilensteine direkt aus der Entwicklung — live aus dem Issue-Tracker.',
|
||||
'roadmap.error': 'Roadmap konnte nicht geladen werden.',
|
||||
'roadmap.noMilestones': 'Noch keine Meilensteine angelegt.',
|
||||
'roadmap.active': '// In Arbeit',
|
||||
'roadmap.planned': '// Geplant',
|
||||
'roadmap.done': '// Fertig',
|
||||
'roadmap.nothingActive': 'Gerade nichts in Arbeit.',
|
||||
'roadmap.due': 'Ziel: %s',
|
||||
'roadmap.tasks': '%s / %s Aufgaben',
|
||||
'roadmap.activity': '// Aktivität — %s Commits in 12 Monaten',
|
||||
'roadmap.commitsOn': '%s: %s Commits',
|
||||
'roadmap.commitOn': '%s: 1 Commit',
|
||||
'roadmap.wishes': '// Community-Wünsche',
|
||||
'roadmap.unvote': 'Vote zurücknehmen',
|
||||
'roadmap.vote': 'Dafür stimmen',
|
||||
'roadmap.ideaPlaceholder': 'Deine Idee für den Server …',
|
||||
'roadmap.submit': 'Einreichen',
|
||||
'roadmap.hint': 'Eigene Idee? Im Discord %s benutzen — oder hier einloggen und direkt abstimmen.',
|
||||
'roadmap.voteMembersOnly': '✗ Voten dürfen nur Discord-Member.',
|
||||
'roadmap.tooShort': '✗ Etwas mehr Text bitte.',
|
||||
'roadmap.submitted': '✓ Wunsch eingereicht — er steht jetzt auch im Discord zur Abstimmung!',
|
||||
'roadmap.submitMembersOnly': '✗ Einreichen dürfen nur Discord-Member.',
|
||||
'roadmap.submitFailed': '✗ Konnte den Wunsch nicht einreichen.',
|
||||
|
||||
// ── Server ────────────────────────────────────────
|
||||
'server.tag': '// Live-Status',
|
||||
'server.subtitle': 'Alle Game-Server auf einen Blick — aktualisiert alle 2 Minuten.',
|
||||
'server.error': 'Server-Status konnte nicht geladen werden.',
|
||||
'server.none': 'Noch keine Server eingetragen.',
|
||||
'server.online': 'Online',
|
||||
'server.offline': 'Offline',
|
||||
'server.waiting': 'Warte auf Check',
|
||||
'server.players24h': 'Spielerzahl · letzte 24h',
|
||||
'server.peak': 'Peak',
|
||||
'server.uptime': 'Uptime',
|
||||
'server.connect': 'Connect',
|
||||
'server.copyAddress': 'Adresse kopieren',
|
||||
'server.copied': 'Kopiert!',
|
||||
'server.checkedAt': 'Zuletzt geprüft: %s Uhr',
|
||||
'server.serversOnline': 'Server online',
|
||||
'server.playersNow': 'Spieler gerade drauf',
|
||||
|
||||
// ── Galerie ───────────────────────────────────────
|
||||
'gallery.tag': '// Community',
|
||||
'gallery.subtitle': 'Screenshots aus der Community — direkt aus dem Discord.',
|
||||
'gallery.error': 'Galerie konnte nicht geladen werden.',
|
||||
'gallery.emptyTitle': 'Noch keine Screenshots',
|
||||
'gallery.emptyText': 'Alles, was im Screenshot-Kanal landet, erscheint automatisch hier — mitsamt Name und Datum.',
|
||||
'gallery.shotBy': 'Screenshot von %s',
|
||||
|
||||
// ── Level ─────────────────────────────────────────
|
||||
'level.tag': '// Bestenliste',
|
||||
'level.subtitle': 'XP gibt’s fürs Mitreden im Discord — hier stehen die Aktivsten. %s zeigt deinen Platz.',
|
||||
'level.error': 'Bestenliste konnte nicht geladen werden.',
|
||||
'level.emptyTitle': 'Noch keine XP vergeben',
|
||||
'level.emptyText': 'XP gibt es fürs Mitreden im Discord. Sobald geschrieben wird, füllt sich die Bestenliste von selbst.',
|
||||
'level.lv': 'Lv. %s',
|
||||
'level.activity': '// Server-Aktivität — %s Nachrichten in 30 Tagen · %s Member',
|
||||
'level.barTitle': '%s: %s Nachrichten · +%s/−%s Member',
|
||||
|
||||
// ── Events ────────────────────────────────────────
|
||||
'events.tag': '// Termine',
|
||||
'events.subtitle': 'Playtests, Streams & Community-Abende — direkt aus dem Discord.',
|
||||
'events.error': 'Events konnten nicht geladen werden.',
|
||||
'events.emptyTitle': 'Noch keine Events geplant',
|
||||
'events.emptyText': 'Playtests, Streams und Community-Runden kündigen wir hier an — und im Discord bekommst du eine Erinnerung.',
|
||||
'events.tbd': 'Termin folgt',
|
||||
'events.interested': '%s interessiert',
|
||||
'events.viewInDiscord': 'Im Discord ansehen',
|
||||
|
||||
// ── Changelog ─────────────────────────────────────
|
||||
'changelog.tag': '// Releases',
|
||||
'changelog.subtitle': 'Alle Versionen und Release-Notes.',
|
||||
'changelog.error': 'Changelog konnte nicht geladen werden.',
|
||||
'changelog.emptyTitle': 'Noch keine Releases',
|
||||
'changelog.emptyText': 'Sobald eine Version veröffentlicht wird, steht sie hier mit allen Änderungen. Bis dahin steht der Fortschritt im Devlog.',
|
||||
'changelog.prerelease': 'Pre-Release',
|
||||
'changelog.noNotes': '*Keine Release-Notes.*',
|
||||
'changelog.viewInGitea': 'Release in Gitea ansehen',
|
||||
|
||||
// ── Bot-Produktseite: Start ───────────────────────
|
||||
'bot.tag': '// Selbst gebaut, keine Paywall',
|
||||
'bot.title1': 'Ein Discord-Bot,',
|
||||
'bot.title2': 'der alles macht',
|
||||
'bot.subtitle': 'Devlogs, Level, Moderation, Server-Monitoring, Verlosungen, Tickets — was sonst drei Bots mit Abo-Modell erledigen, läuft hier in einem, mit eigenem Webinterface.',
|
||||
'bot.allFeatures': 'Alle Funktionen',
|
||||
'bot.viewCommands': 'Befehle ansehen',
|
||||
'bot.statFeatures': 'Funktionen, einzeln schaltbar',
|
||||
'bot.statCommands': 'Slash-Befehle',
|
||||
'bot.statGames': 'Spiele im Monitor',
|
||||
'bot.statCost': 'Kosten, keine Premium-Stufen',
|
||||
'bot.whatItDoes': '// Was er kann',
|
||||
'bot.forDevs': '// Für Entwickler',
|
||||
'bot.runsFor': 'Der Bot läuft für die D4RKST3R-Community',
|
||||
'bot.runsForWith': 'Der Bot läuft für die D4RKST3R-Community mit %s Mitgliedern',
|
||||
'bot.toHub': 'zum Community-Hub',
|
||||
|
||||
'bot.hl.devlogs': 'Devlogs & Feeds',
|
||||
'bot.hl.devlogs.text': 'Entwicklungs-Berichte landen als gebrandetes Embed im Discord und werden gleichzeitig durchsuchbar archiviert. Commits und Releases aus Gitea genauso.',
|
||||
'bot.hl.monitor': 'Server-Monitor',
|
||||
'bot.hl.monitor.text': 'Über 300 Spiele per gamedig, dazu FiveM und einfache Erreichbarkeits-Checks. Ein Live-Embed pro Server, Alarm bei Ausfall, Spielerzahl in der Bot-Präsenz.',
|
||||
'bot.hl.level': 'Level & Aktivität',
|
||||
'bot.hl.level.text': 'XP fürs Mitreden mit Rollen-Belohnungen, öffentliche Bestenliste und Aktivitäts-Statistik des Servers.',
|
||||
'bot.hl.roles': 'Rollen-Menüs',
|
||||
'bot.hl.roles.text': 'Selbstbedienung per Knopfdruck — im Discord und im Browser. Farben pro Knopf, wahlweise exklusiv.',
|
||||
'bot.hl.support': 'Support & Kontakt',
|
||||
'bot.hl.support.text': 'Tickets als private Threads mit Gesprächsprotokoll, dazu Modmail: eine Direktnachricht an den Bot landet beim Team.',
|
||||
'bot.hl.mod': 'Moderation',
|
||||
'bot.hl.mod.text': 'Verwarnungen mit Verlauf, Auszeiten, Aufräumen — alles im Mod-Log, dazu Protokoll für Beitritte, Namens- und Rollenwechsel.',
|
||||
'bot.hl.giveaway': 'Verlosungen',
|
||||
'bot.hl.giveaway.text': 'Teilnahme per Knopf, automatische Ziehung, Neuauslosung wenn sich niemand meldet.',
|
||||
'bot.hl.birthday': 'Geburtstage',
|
||||
'bot.hl.birthday.text': 'Wer mag, trägt seinen Tag ein — der Bot gratuliert morgens und vergibt eine Rolle für den Tag.',
|
||||
'bot.hl.welcome': 'Willkommens-Karten',
|
||||
'bot.hl.welcome.text': 'Neue Mitglieder bekommen ein gerendertes Bild mit Avatar und Mitglieds-Nummer.',
|
||||
'bot.dev.api': 'API & Single Sign-On',
|
||||
'bot.dev.api.text': 'Eigene Skripte posten über die API v1 mit Bearer-Schlüsseln und Bereichs-Rechten. Andere Dienste nutzen den Discord-Login des Bots mit und bekommen die Rollen gleich mitgeliefert.',
|
||||
'bot.dev.host': 'Läuft überall',
|
||||
'bot.dev.host.text': 'Node und SQLite in einem Container, ein Volume für die Daten. Nächtliche Sicherungen inklusive — von der Datenbank und allen Repositories.',
|
||||
|
||||
// ── Bot-Produktseite: Funktionen ──────────────────
|
||||
'features.tag': '// Alles drin, nichts hinter einer Paywall',
|
||||
'features.title': 'Funktionen',
|
||||
'features.subtitleN': '%s Funktionen — jede einzeln ein- und ausschaltbar, jede im Browser einstellbar.',
|
||||
'features.subtitle': 'Jede Funktion einzeln ein- und ausschaltbar, jede im Browser einstellbar.',
|
||||
'features.extras': '// Drumherum',
|
||||
'features.outro': 'Gebaut, weil drei Bots mit je eigenem Abo für eine Community weder übersichtlich noch günstig sind. Der Quelltext liegt offen, der Betrieb kostet einen Container.',
|
||||
'features.overview': 'Zur Übersicht',
|
||||
'features.x.brand': 'Webinterface im eigenen Look',
|
||||
'features.x.brand.text': 'Jede Funktion wird im Browser eingestellt — keine Konfigurationsdatei, kein Neustart. Farben, Bot-Avatar und Banner gehören dazu, und andere Dienste können denselben Look mitbenutzen.',
|
||||
'features.x.texts': 'Texte selbst schreiben',
|
||||
'features.x.texts.text': 'Begrüßung, Level-Ansage, Ticket-Texte — alles, was der Bot nach außen schreibt, lässt sich im Panel ändern, mit Platzhaltern für Name, Zahl und Link.',
|
||||
'features.x.api': 'API & Single Sign-On',
|
||||
'features.x.api.text': 'Eigene Skripte posten über die API v1 mit Bearer-Schlüsseln und Bereichs-Rechten. Andere Dienste nutzen den Discord-Login mit und bekommen die Rollen gleich mitgeliefert.',
|
||||
'features.x.pages': 'Eigene Seiten',
|
||||
'features.x.pages.text': 'Regeln, Über uns, FAQ — in Markdown geschrieben, mit einem Klick veröffentlicht und automatisch im Menü.',
|
||||
'features.x.team': 'Rechte fürs Team',
|
||||
'features.x.team.text': 'Team-Mitglieder bekommen gezielten Zugriff auf einzelne Bereiche. Wer was geändert hat, steht im Protokoll.',
|
||||
'features.x.host': 'Läuft überall',
|
||||
'features.x.host.text': 'Node und SQLite in einem Container, ein Volume für die Daten. Nächtliche Sicherungen inklusive — von der Datenbank und allen Repositories.',
|
||||
|
||||
// ── Bot-Produktseite: Befehle ─────────────────────
|
||||
'commands.tag': '// Referenz',
|
||||
'commands.title': 'Befehle',
|
||||
'commands.subtitle': 'Alle Slash-Befehle im Überblick. Vieles läuft zusätzlich über Knöpfe direkt in den Nachrichten.',
|
||||
'commands.note': 'Moderations- und Einrichtungs-Befehle sind Mitgliedern mit den passenden Discord-Rechten vorbehalten.',
|
||||
'commands.g.everyone': 'Für alle',
|
||||
'commands.g.mod': 'Moderation',
|
||||
'commands.g.setup': 'Einrichtung',
|
||||
'commands.rank': 'Zeigt deinen Rang, deine XP und den Fortschritt zum nächsten Level.',
|
||||
'commands.wunsch': 'Reicht einen Feature-Wunsch ein — landet im Voting-Kanal und auf der Roadmap.',
|
||||
'commands.bug': 'Meldet einen Fehler als Issue im Repository. Wird er geschlossen, bekommst du eine Nachricht.',
|
||||
'commands.bdaySet': 'Trägt deinen Geburtstag ein. Der Bot gratuliert morgens und vergibt eine Rolle für den Tag.',
|
||||
'commands.bdayDel': 'Trägt ihn wieder aus.',
|
||||
'commands.remind': 'Erinnert dich per Direktnachricht — etwa „2h" oder „30m".',
|
||||
'commands.tag': 'Ruft einen gespeicherten Textbaustein ab, mit Vorschlägen beim Tippen.',
|
||||
'commands.ping': 'Lebenszeichen des Bots.',
|
||||
'commands.warn': 'Verwarnt jemanden, schickt eine Nachricht und schreibt es ins Protokoll.',
|
||||
'commands.warns': 'Zeigt die Verwarnungs-Historie.',
|
||||
'commands.timeout': 'Setzt eine Auszeit.',
|
||||
'commands.purge': 'Löscht mehrere Nachrichten auf einmal.',
|
||||
'commands.giveaway': 'Startet eine Verlosung mit Teilnahme-Knopf und automatischer Ziehung.',
|
||||
'commands.ticketSetup': 'Postet den Knopf, über den Support-Tickets als private Threads entstehen.',
|
||||
'commands.playtesterSetup': 'Postet den Bewerbungs-Knopf für das Playtester-Programm.',
|
||||
'commands.devlogBackfill': 'Holt ältere Devlog-Nachrichten nachträglich ins Archiv.',
|
||||
'commands.galerieBackfill': 'Dasselbe für den Screenshot-Kanal.',
|
||||
};
|
||||
@@ -0,0 +1,339 @@
|
||||
// English texts for the public pages. Mirrors de.js key for key — a missing
|
||||
// key here falls back to the German string, never to a bare key.
|
||||
export default {
|
||||
// ── Navigation & chrome ───────────────────────────
|
||||
'nav.devlog': 'Devlog',
|
||||
'nav.roadmap': 'Roadmap',
|
||||
'nav.server': 'Servers',
|
||||
'nav.community': 'Community',
|
||||
'nav.gallery': 'Gallery',
|
||||
'nav.level': 'Levels',
|
||||
'nav.events': 'Events',
|
||||
'nav.changelog': 'Changelog',
|
||||
'nav.config': 'Config',
|
||||
'nav.profile': 'Profile',
|
||||
'nav.commits': 'Commits',
|
||||
'nav.login': 'Log in',
|
||||
'nav.loginDiscord': 'Log in with Discord',
|
||||
'nav.logout': 'Log out',
|
||||
'nav.features': 'Features',
|
||||
'nav.commands': 'Commands',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.signIn': 'Sign in',
|
||||
'nav.signOut': 'Sign out',
|
||||
'nav.menu': 'Menu',
|
||||
'footer.source': 'Source',
|
||||
'footer.imprint': 'Imprint',
|
||||
'footer.privacy': 'Privacy',
|
||||
'gate.text': 'Logging in is for members of the Discord server — join the server first, then log in again.',
|
||||
'gate.join': 'Join Discord',
|
||||
'gate.hide': 'Hide',
|
||||
|
||||
// ── Shared ────────────────────────────────────────
|
||||
'common.loading': 'Loading …',
|
||||
'common.newer': 'Newer',
|
||||
'common.older': 'Older',
|
||||
'common.readDevlogs': 'Read devlogs',
|
||||
'common.toRoadmap': 'To the roadmap',
|
||||
'common.today': 'today',
|
||||
'common.yesterday': 'yesterday',
|
||||
'common.daysAgo': '%s days ago',
|
||||
'common.monthAgo': '1 month ago',
|
||||
'common.monthsAgo': '%s months ago',
|
||||
|
||||
// ── Home (hub) ────────────────────────────────────
|
||||
'home.tag': '// Community hub',
|
||||
'home.welcome': 'Welcome',
|
||||
'home.at': 'to',
|
||||
'home.subtitle': 'Devlogs, roadmap, server status and everything from the community — in one place, straight out of Discord.',
|
||||
'home.discord': 'Join Discord',
|
||||
'home.readDevlogs': 'Read devlogs',
|
||||
'home.members': 'members on Discord',
|
||||
'home.serversOnline': 'servers online',
|
||||
'home.playersNow': 'players right now',
|
||||
'home.services': '// Services',
|
||||
'home.latestDevlog': '// Latest devlog',
|
||||
'home.more': 'Read more',
|
||||
'home.tile.devlog': 'Development updates, fresh from the editor',
|
||||
'home.tile.roadmap': 'Milestones plus community wishes to vote on',
|
||||
'home.tile.server': 'Live status of every game server, with history',
|
||||
'home.tile.gallery': 'Screenshots from the community',
|
||||
'home.tile.level': 'XP leaderboard — who talks the most?',
|
||||
'home.tile.events': 'Playtests, streams and whatever else is coming up',
|
||||
|
||||
// ── Devlogs ───────────────────────────────────────
|
||||
'devlogs.tag': '// Archive',
|
||||
'devlogs.subtitle': 'Development updates, fresh from the editor — archived automatically.',
|
||||
'devlogs.all': 'All',
|
||||
'devlogs.search': 'Search devlogs …',
|
||||
'devlogs.hits': '%s results',
|
||||
'devlogs.error': 'Devlogs could not be loaded.',
|
||||
'devlogs.noHits': 'No results for “%s”.',
|
||||
'devlogs.noneForProject': 'No devlogs for %s yet.',
|
||||
'devlogs.none': 'No devlogs archived yet.',
|
||||
'devlogs.permalink': 'Direct link to this devlog',
|
||||
'devlogs.delete': 'Remove from the archive',
|
||||
'devlogs.confirmDelete': 'Remove this devlog from the archive?',
|
||||
'devlogs.deleteFailed': 'Deleting failed.',
|
||||
'devlogs.screenshot': 'Devlog screenshot',
|
||||
|
||||
// ── Devlog permalink ──────────────────────────────
|
||||
'devlog.tag': '// Devlog',
|
||||
'devlog.project': 'Project: %s',
|
||||
'devlog.notFound': 'Devlog not found —',
|
||||
'devlog.backToArchive': 'back to the archive',
|
||||
'devlog.allDevlogs': 'All devlogs',
|
||||
'devlog.copyLink': 'Copy link',
|
||||
'devlog.linkCopied': 'Link copied',
|
||||
|
||||
// ── Roadmap ───────────────────────────────────────
|
||||
'roadmap.tag': '// Where this is heading',
|
||||
'roadmap.subtitle': 'Milestones straight from development — live from the issue tracker.',
|
||||
'roadmap.error': 'The roadmap could not be loaded.',
|
||||
'roadmap.noMilestones': 'No milestones yet.',
|
||||
'roadmap.active': '// In progress',
|
||||
'roadmap.planned': '// Planned',
|
||||
'roadmap.done': '// Done',
|
||||
'roadmap.nothingActive': 'Nothing in progress right now.',
|
||||
'roadmap.due': 'Due: %s',
|
||||
'roadmap.tasks': '%s / %s tasks',
|
||||
'roadmap.activity': '// Activity — %s commits in 12 months',
|
||||
'roadmap.commitsOn': '%s: %s commits',
|
||||
'roadmap.commitOn': '%s: 1 commit',
|
||||
'roadmap.wishes': '// Community wishes',
|
||||
'roadmap.unvote': 'Take back your vote',
|
||||
'roadmap.vote': 'Vote for this',
|
||||
'roadmap.ideaPlaceholder': 'Your idea for the server …',
|
||||
'roadmap.submit': 'Submit',
|
||||
'roadmap.hint': 'Got an idea? Use %s on Discord — or log in here and vote right away.',
|
||||
'roadmap.voteMembersOnly': '✗ Only Discord members can vote.',
|
||||
'roadmap.tooShort': '✗ A little more text, please.',
|
||||
'roadmap.submitted': '✓ Wish submitted — it is up for a vote on Discord too!',
|
||||
'roadmap.submitMembersOnly': '✗ Only Discord members can submit.',
|
||||
'roadmap.submitFailed': '✗ Could not submit the wish.',
|
||||
|
||||
// ── Servers ───────────────────────────────────────
|
||||
'server.tag': '// Live status',
|
||||
'server.subtitle': 'Every game server at a glance — refreshed every 2 minutes.',
|
||||
'server.error': 'The server status could not be loaded.',
|
||||
'server.none': 'No servers added yet.',
|
||||
'server.online': 'Online',
|
||||
'server.offline': 'Offline',
|
||||
'server.waiting': 'Waiting for check',
|
||||
'server.players24h': 'Player count · last 24h',
|
||||
'server.peak': 'Peak',
|
||||
'server.uptime': 'Uptime',
|
||||
'server.connect': 'Connect',
|
||||
'server.copyAddress': 'Copy address',
|
||||
'server.copied': 'Copied!',
|
||||
'server.checkedAt': 'Last checked: %s',
|
||||
'server.serversOnline': 'servers online',
|
||||
'server.playersNow': 'players right now',
|
||||
|
||||
// ── Gallery ───────────────────────────────────────
|
||||
'gallery.tag': '// Community',
|
||||
'gallery.subtitle': 'Screenshots from the community — straight out of Discord.',
|
||||
'gallery.error': 'The gallery could not be loaded.',
|
||||
'gallery.emptyTitle': 'No screenshots yet',
|
||||
'gallery.emptyText': 'Anything posted in the screenshot channel shows up here automatically — name and date included.',
|
||||
'gallery.shotBy': 'Screenshot by %s',
|
||||
|
||||
// ── Levels ────────────────────────────────────────
|
||||
'level.tag': '// Leaderboard',
|
||||
'level.subtitle': 'XP comes from joining the conversation on Discord — here are the most active. %s shows your rank.',
|
||||
'level.error': 'The leaderboard could not be loaded.',
|
||||
'level.emptyTitle': 'No XP awarded yet',
|
||||
'level.emptyText': 'XP comes from joining the conversation on Discord. As soon as people start writing, the leaderboard fills itself.',
|
||||
'level.lv': 'Lv. %s',
|
||||
'level.activity': '// Server activity — %s messages in 30 days · %s members',
|
||||
'level.barTitle': '%s: %s messages · +%s/−%s members',
|
||||
|
||||
// ── Events ────────────────────────────────────────
|
||||
'events.tag': '// Dates',
|
||||
'events.subtitle': 'Playtests, streams & community nights — straight out of Discord.',
|
||||
'events.error': 'Events could not be loaded.',
|
||||
'events.emptyTitle': 'No events planned yet',
|
||||
'events.emptyText': 'We announce playtests, streams and community rounds here — and Discord will remind you.',
|
||||
'events.tbd': 'Date to be announced',
|
||||
'events.interested': '%s interested',
|
||||
'events.viewInDiscord': 'View on Discord',
|
||||
|
||||
// ── Changelog ─────────────────────────────────────
|
||||
'changelog.tag': '// Releases',
|
||||
'changelog.subtitle': 'Every version and its release notes.',
|
||||
'changelog.error': 'The changelog could not be loaded.',
|
||||
'changelog.emptyTitle': 'No releases yet',
|
||||
'changelog.emptyText': 'As soon as a version ships, it shows up here with all its changes. Until then, the devlog has the progress.',
|
||||
'changelog.prerelease': 'Pre-release',
|
||||
'changelog.noNotes': '*No release notes.*',
|
||||
'changelog.viewInGitea': 'View release on Gitea',
|
||||
|
||||
// ── Bot product page: landing ─────────────────────
|
||||
'bot.tag': '// Self-built, no paywall',
|
||||
'bot.title1': 'A Discord bot',
|
||||
'bot.title2': 'that does it all',
|
||||
'bot.subtitle': 'Devlogs, levels, moderation, server monitoring, giveaways, tickets — what usually takes three subscription bots runs here in one, with its own web interface.',
|
||||
'bot.allFeatures': 'All features',
|
||||
'bot.viewCommands': 'View commands',
|
||||
'bot.statFeatures': 'features, each one switchable',
|
||||
'bot.statCommands': 'slash commands',
|
||||
'bot.statGames': 'games in the monitor',
|
||||
'bot.statCost': 'cost, no premium tiers',
|
||||
'bot.whatItDoes': '// What it does',
|
||||
'bot.forDevs': '// For developers',
|
||||
'bot.runsFor': 'The bot runs for the D4RKST3R community',
|
||||
'bot.runsForWith': 'The bot runs for the D4RKST3R community with %s members',
|
||||
'bot.toHub': 'to the community hub',
|
||||
|
||||
'bot.hl.devlogs': 'Devlogs & feeds',
|
||||
'bot.hl.devlogs.text': 'Development reports land in Discord as a branded embed and get archived searchably at the same time. Commits and releases from Gitea, too.',
|
||||
'bot.hl.monitor': 'Server monitor',
|
||||
'bot.hl.monitor.text': 'Over 300 games via gamedig, plus FiveM and plain reachability checks. One live embed per server, an alert when one goes down, player count in the bot presence.',
|
||||
'bot.hl.level': 'Levels & activity',
|
||||
'bot.hl.level.text': 'XP for joining the conversation with role rewards, a public leaderboard and activity stats for the server.',
|
||||
'bot.hl.roles': 'Role menus',
|
||||
'bot.hl.roles.text': 'Self-service at the press of a button — on Discord and in the browser. A colour per button, exclusive if you want.',
|
||||
'bot.hl.support': 'Support & contact',
|
||||
'bot.hl.support.text': 'Tickets as private threads with a transcript, plus modmail: a DM to the bot reaches the team.',
|
||||
'bot.hl.mod': 'Moderation',
|
||||
'bot.hl.mod.text': 'Warnings with history, timeouts, cleanup — all in the mod log, plus a record of joins, name changes and role changes.',
|
||||
'bot.hl.giveaway': 'Giveaways',
|
||||
'bot.hl.giveaway.text': 'Enter with a button, automatic draw, reroll when nobody answers.',
|
||||
'bot.hl.birthday': 'Birthdays',
|
||||
'bot.hl.birthday.text': 'Add your day if you like — the bot congratulates you in the morning and hands out a role for the day.',
|
||||
'bot.hl.welcome': 'Welcome cards',
|
||||
'bot.hl.welcome.text': 'New members get a rendered image with their avatar and member number.',
|
||||
'bot.dev.api': 'API & single sign-on',
|
||||
'bot.dev.api.text': 'Your own scripts post through API v1 with bearer keys and scoped permissions. Other services reuse the bot’s Discord login and get the roles delivered with it.',
|
||||
'bot.dev.host': 'Runs anywhere',
|
||||
'bot.dev.host.text': 'Node and SQLite in one container, one volume for the data. Nightly backups included — of the database and every repository.',
|
||||
|
||||
// ── Bot product page: features ────────────────────
|
||||
'features.tag': '// Everything included, nothing behind a paywall',
|
||||
'features.title': 'Features',
|
||||
'features.subtitleN': '%s features — each one switchable, each one configurable in the browser.',
|
||||
'features.subtitle': 'Every feature switchable on its own, every one configurable in the browser.',
|
||||
'features.extras': '// Around it',
|
||||
'features.outro': 'Built because three bots with three subscriptions for one community is neither tidy nor cheap. The source is open, running it costs one container.',
|
||||
'features.overview': 'Back to the overview',
|
||||
'features.x.brand': 'Web interface in your own look',
|
||||
'features.x.brand.text': 'Every feature is set in the browser — no config file, no restart. Colours, bot avatar and banner included, and other services can share the same look.',
|
||||
'features.x.texts': 'Write the texts yourself',
|
||||
'features.x.texts.text': 'Welcome message, level announcement, ticket texts — everything the bot says out loud can be changed in the panel, with placeholders for name, number and link.',
|
||||
'features.x.api': 'API & single sign-on',
|
||||
'features.x.api.text': 'Your own scripts post through API v1 with bearer keys and scoped permissions. Other services reuse the Discord login and get the roles delivered with it.',
|
||||
'features.x.pages': 'Your own pages',
|
||||
'features.x.pages.text': 'Rules, about us, FAQ — written in Markdown, published with one click and added to the menu automatically.',
|
||||
'features.x.team': 'Permissions for the team',
|
||||
'features.x.team.text': 'Team members get targeted access to individual areas. Who changed what is in the log.',
|
||||
'features.x.host': 'Runs anywhere',
|
||||
'features.x.host.text': 'Node and SQLite in one container, one volume for the data. Nightly backups included — of the database and every repository.',
|
||||
|
||||
// ── Module names for the features page ────────────
|
||||
// Keyed by module id from src/modules.js. Anything missing here keeps the
|
||||
// German text the server sends, so a new module never vanishes.
|
||||
'fgroup.inhalte': 'Content & feeds',
|
||||
'fgroup.community': 'Community',
|
||||
'fgroup.moderation': 'Moderation & support',
|
||||
'fgroup.server': 'Server & tech',
|
||||
|
||||
'feat.devlogs': 'Devlogs',
|
||||
'feat.devlogs.desc': 'Post development reports as a branded embed and archive them searchably.',
|
||||
'feat.commit_feed': 'Commit feed',
|
||||
'feat.commit_feed.desc': 'Pushes from Gitea land in the channel as an embed. Archiving happens regardless.',
|
||||
'feat.releases': 'Release announcements',
|
||||
'feat.releases.desc': 'New releases get announced and land in the public changelog.',
|
||||
'feat.weekly_recap': 'Weekly recap',
|
||||
'feat.weekly_recap.desc': 'A summary of the week, Sundays at 8 pm.',
|
||||
'feat.devlog_threads': 'Devlog threads',
|
||||
'feat.devlog_threads.desc': 'A discussion thread opens under every devlog.',
|
||||
'feat.social': 'Twitch & YouTube',
|
||||
'feat.social.desc': 'Announces live streams and new videos.',
|
||||
|
||||
'feat.levels': 'Level system',
|
||||
'feat.levels.desc': 'XP for joining the conversation, role rewards and a public leaderboard.',
|
||||
'feat.starboard': 'Starboard',
|
||||
'feat.starboard.desc': 'Messages with enough stars land in a best-of channel.',
|
||||
'feat.gallery': 'Screenshot gallery',
|
||||
'feat.gallery.desc': 'Images from one channel show up in the public gallery.',
|
||||
'feat.voting': 'Feature wishes',
|
||||
'feat.voting.desc': 'Submit wishes and vote on them — on Discord and on the roadmap.',
|
||||
'feat.giveaways': 'Giveaways',
|
||||
'feat.giveaways.desc': 'Enter with a button, automatic draw, reroll available.',
|
||||
'feat.birthdays': 'Birthdays',
|
||||
'feat.birthdays.desc': 'Congratulations on the big day, with a role for the day if you like.',
|
||||
'feat.events': 'Event announcements',
|
||||
'feat.events.desc': 'New Discord events get announced and show up on the events page.',
|
||||
'feat.playtester': 'Playtester programme',
|
||||
'feat.playtester.desc': 'Application button, role and participant list.',
|
||||
'feat.alpha_keys': 'Alpha keys',
|
||||
'feat.alpha_keys.desc': 'A pool of keys handed out by direct message.',
|
||||
'feat.role_menus': 'Role menus',
|
||||
'feat.role_menus.desc': 'Self-service roles at the press of a button — on Discord and in the profile.',
|
||||
'feat.applications': 'Applications',
|
||||
'feat.applications.desc': 'Forms as input dialogs, review with accept and reject.',
|
||||
|
||||
'feat.moderation': 'Moderation',
|
||||
'feat.moderation.desc': 'Warnings, timeouts, cleanup — with history and a log.',
|
||||
'feat.modlog': 'Audit log',
|
||||
'feat.modlog.desc': 'Deleted and edited messages, joins, name and role changes.',
|
||||
'feat.modmail': 'Modmail',
|
||||
'feat.modmail.desc': 'Direct messages to the bot reach the team as a thread.',
|
||||
'feat.tickets': 'Tickets',
|
||||
'feat.tickets.desc': 'Private threads at the press of a button, with a transcript on close.',
|
||||
'feat.welcome': 'Welcome cards',
|
||||
'feat.welcome.desc': 'New members are greeted with a rendered image.',
|
||||
'feat.autorole': 'Auto role',
|
||||
'feat.autorole.desc': 'New members automatically get a starting role.',
|
||||
'feat.sticky_roles': 'Sticky roles',
|
||||
'feat.sticky_roles.desc': 'Leave the server and come back — your roles come back with you.',
|
||||
'feat.bug_reports': 'Bug reports',
|
||||
'feat.bug_reports.desc': 'Reports become issues in the repository, with a reply when they close.',
|
||||
|
||||
'feat.server_monitor': 'Server monitor',
|
||||
'feat.server_monitor.desc': 'Live status of the game servers with history and downtime alerts.',
|
||||
'feat.watchdog': 'Reachability watchdog',
|
||||
'feat.watchdog.desc': 'Checks the addresses you enter and reports outages by direct message.',
|
||||
'feat.temp_voice': 'Temporary voice channels',
|
||||
'feat.temp_voice.desc': 'Joining the hub creates your own channel with a control panel.',
|
||||
'feat.triggers': 'Auto replies',
|
||||
'feat.triggers.desc': 'The bot answers to keywords you set.',
|
||||
'feat.reminders': 'Reminders',
|
||||
'feat.reminders.desc': 'Members can have themselves reminded by direct message.',
|
||||
'feat.scheduled_posts': 'Scheduled posts',
|
||||
'feat.scheduled_posts.desc': 'Messages at fixed times, once or recurring.',
|
||||
'feat.tags': 'Text snippets',
|
||||
'feat.tags.desc': 'Call up saved texts with a command.',
|
||||
'feat.backups': 'Database backup',
|
||||
'feat.backups.desc': 'A nightly backup kept for 14 days.',
|
||||
'feat.repo_backups': 'Repository backup',
|
||||
'feat.repo_backups.desc': 'Back up every Gitea repository as a bundle.',
|
||||
'feat.member_gate': 'Members-only login',
|
||||
'feat.member_gate.desc': 'Only people on the Discord server can sign in on the web.',
|
||||
|
||||
// ── Bot product page: commands ────────────────────
|
||||
'commands.tag': '// Reference',
|
||||
'commands.title': 'Commands',
|
||||
'commands.subtitle': 'Every slash command at a glance. A lot also runs through buttons right inside the messages.',
|
||||
'commands.note': 'Moderation and setup commands are reserved for members with the matching Discord permissions.',
|
||||
'commands.g.everyone': 'For everyone',
|
||||
'commands.g.mod': 'Moderation',
|
||||
'commands.g.setup': 'Setup',
|
||||
'commands.rank': 'Shows your rank, your XP and the progress to the next level.',
|
||||
'commands.wunsch': 'Submits a feature wish — lands in the voting channel and on the roadmap.',
|
||||
'commands.bug': 'Reports a bug as an issue in the repository. When it is closed, you get a message.',
|
||||
'commands.bdaySet': 'Saves your birthday. The bot congratulates you in the morning and hands out a role for the day.',
|
||||
'commands.bdayDel': 'Removes it again.',
|
||||
'commands.remind': 'Reminds you by direct message — something like “2h” or “30m”.',
|
||||
'commands.tag': 'Fetches a saved text snippet, with suggestions as you type.',
|
||||
'commands.ping': 'A sign of life from the bot.',
|
||||
'commands.warn': 'Warns someone, sends them a message and writes it to the log.',
|
||||
'commands.warns': 'Shows the warning history.',
|
||||
'commands.timeout': 'Puts someone in timeout.',
|
||||
'commands.purge': 'Deletes several messages at once.',
|
||||
'commands.giveaway': 'Starts a giveaway with an entry button and an automatic draw.',
|
||||
'commands.ticketSetup': 'Posts the button that turns support tickets into private threads.',
|
||||
'commands.playtesterSetup': 'Posts the application button for the playtester programme.',
|
||||
'commands.devlogBackfill': 'Pulls older devlog messages into the archive after the fact.',
|
||||
'commands.galerieBackfill': 'The same for the screenshot channel.',
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
import BotApp from './BotApp.jsx';
|
||||
import { IS_BOT } from './site.js';
|
||||
import { LangProvider } from './i18n.jsx';
|
||||
import '@fontsource/bebas-neue';
|
||||
import '@fontsource/barlow-condensed/300.css';
|
||||
import '@fontsource/barlow-condensed/400.css';
|
||||
@@ -16,7 +17,9 @@ import './style.css';
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
{IS_BOT ? <BotApp /> : <App />}
|
||||
<LangProvider>
|
||||
{IS_BOT ? <BotApp /> : <App />}
|
||||
</LangProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
// Übersicht aller Slash-Befehle des Bots.
|
||||
import { useEffect } from 'react';
|
||||
import { IconLock } from '../icons.jsx';
|
||||
import { useT } from '../i18n.jsx';
|
||||
|
||||
// Befehlsname bleibt wie er ist — nur die Erklärung wird übersetzt.
|
||||
const GROUPS = [
|
||||
['Für alle', [
|
||||
['/rank', 'Zeigt deinen Rang, deine XP und den Fortschritt zum nächsten Level.'],
|
||||
['/wunsch idee:…', 'Reicht einen Feature-Wunsch ein — landet im Voting-Kanal und auf der Roadmap.'],
|
||||
['/bug titel:… beschreibung:…', 'Meldet einen Fehler als Issue im Repository. Wird er geschlossen, bekommst du eine Nachricht.'],
|
||||
['/geburtstag setzen tag:… monat:…', 'Trägt deinen Geburtstag ein. Der Bot gratuliert morgens und vergibt eine Rolle für den Tag.'],
|
||||
['/geburtstag entfernen', 'Trägt ihn wieder aus.'],
|
||||
['/remind dauer:… text:…', 'Erinnert dich per Direktnachricht — etwa „2h" oder „30m".'],
|
||||
['/tag name:…', 'Ruft einen gespeicherten Textbaustein ab, mit Vorschlägen beim Tippen.'],
|
||||
['/ping', 'Lebenszeichen des Bots.'],
|
||||
['commands.g.everyone', false, [
|
||||
['/rank', 'commands.rank'],
|
||||
['/wunsch idee:…', 'commands.wunsch'],
|
||||
['/bug titel:… beschreibung:…', 'commands.bug'],
|
||||
['/geburtstag setzen tag:… monat:…', 'commands.bdaySet'],
|
||||
['/geburtstag entfernen', 'commands.bdayDel'],
|
||||
['/remind dauer:… text:…', 'commands.remind'],
|
||||
['/tag name:…', 'commands.tag'],
|
||||
['/ping', 'commands.ping'],
|
||||
]],
|
||||
['Moderation', [
|
||||
['/warn user:… grund:…', 'Verwarnt jemanden, schickt eine Nachricht und schreibt es ins Protokoll.'],
|
||||
['/warns user:…', 'Zeigt die Verwarnungs-Historie.'],
|
||||
['/timeout user:… dauer:…', 'Setzt eine Auszeit.'],
|
||||
['/purge anzahl:…', 'Löscht mehrere Nachrichten auf einmal.'],
|
||||
['commands.g.mod', true, [
|
||||
['/warn user:… grund:…', 'commands.warn'],
|
||||
['/warns user:…', 'commands.warns'],
|
||||
['/timeout user:… dauer:…', 'commands.timeout'],
|
||||
['/purge anzahl:…', 'commands.purge'],
|
||||
]],
|
||||
['Einrichtung', [
|
||||
['/giveaway preis:… dauer:… gewinner:…', 'Startet eine Verlosung mit Teilnahme-Knopf und automatischer Ziehung.'],
|
||||
['/ticket-setup', 'Postet den Knopf, über den Support-Tickets als private Threads entstehen.'],
|
||||
['/playtester-setup', 'Postet den Bewerbungs-Knopf für das Playtester-Programm.'],
|
||||
['/devlog-backfill', 'Holt ältere Devlog-Nachrichten nachträglich ins Archiv.'],
|
||||
['/galerie-backfill', 'Dasselbe für den Screenshot-Kanal.'],
|
||||
['commands.g.setup', true, [
|
||||
['/giveaway preis:… dauer:… gewinner:…', 'commands.giveaway'],
|
||||
['/ticket-setup', 'commands.ticketSetup'],
|
||||
['/playtester-setup', 'commands.playtesterSetup'],
|
||||
['/devlog-backfill', 'commands.devlogBackfill'],
|
||||
['/galerie-backfill', 'commands.galerieBackfill'],
|
||||
]],
|
||||
];
|
||||
|
||||
export default function BotCommands() {
|
||||
const { t } = useT();
|
||||
useEffect(() => { window.scrollTo(0, 0); }, []);
|
||||
|
||||
return (
|
||||
@@ -37,37 +40,30 @@ export default function BotCommands() {
|
||||
<div className="page-bg-text">BEFEHLE</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Referenz</div>
|
||||
<h1 className="page-title">Befehle</h1>
|
||||
<p className="page-subtitle">
|
||||
Alle Slash-Befehle im Überblick. Vieles läuft zusätzlich über Knöpfe
|
||||
direkt in den Nachrichten.
|
||||
</p>
|
||||
<div className="page-tag">{t('commands.tag')}</div>
|
||||
<h1 className="page-title">{t('commands.title')}</h1>
|
||||
<p className="page-subtitle">{t('commands.subtitle')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{GROUPS.map(([group, commands]) => (
|
||||
<div className="roadmap-group" key={group}>
|
||||
{GROUPS.map(([groupKey, restricted, commands]) => (
|
||||
<div className="roadmap-group" key={groupKey}>
|
||||
<h2 className="settings-title">
|
||||
// {group}
|
||||
{group === 'Moderation' && <IconLock size={13} />}
|
||||
{group === 'Einrichtung' && <IconLock size={13} />}
|
||||
// {t(groupKey)}
|
||||
{restricted && <IconLock size={13} />}
|
||||
</h2>
|
||||
<div className="cmd-list">
|
||||
{commands.map(([name, desc]) => (
|
||||
{commands.map(([name, descKey]) => (
|
||||
<div className="cmd-row" key={name}>
|
||||
<code className="cmd-name">{name}</code>
|
||||
<span className="cmd-desc">{desc}</span>
|
||||
<span className="cmd-desc">{t(descKey)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="notice">
|
||||
Moderations- und Einrichtungs-Befehle sind Mitgliedern mit den passenden
|
||||
Discord-Rechten vorbehalten.
|
||||
</p>
|
||||
<p className="notice">{t('commands.note')}</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// Funktionsliste der Produktseite. Die Einträge kommen aus /api/features und
|
||||
// damit aus demselben Register wie die Module im Panel — die Seite kann also
|
||||
// nicht veralten, wenn eine Funktion dazukommt.
|
||||
//
|
||||
// Der Server liefert deutsch; die englischen Namen liegen im Wörterbuch unter
|
||||
// feat.<modul-id>. Fehlt einer, bleibt der deutsche Text stehen.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiGet } from '../api.js';
|
||||
import { useT } from '../i18n.jsx';
|
||||
import {
|
||||
IconMessage, IconTeam, IconServer, IconApi, IconPages, IconBrand,
|
||||
IconBot, IconArrowRight,
|
||||
@@ -12,27 +16,16 @@ import { moduleIcon } from '../module-icons.jsx';
|
||||
|
||||
// Was kein Discord-Modul ist, sondern zum Drumherum gehört
|
||||
const EXTRAS = [
|
||||
[IconBrand, 'Webinterface im eigenen Look',
|
||||
'Jede Funktion wird im Browser eingestellt — keine Konfigurationsdatei, kein Neustart. ' +
|
||||
'Farben, Bot-Avatar und Banner gehören dazu, und andere Dienste können denselben Look mitbenutzen.'],
|
||||
[IconMessage, 'Texte selbst schreiben',
|
||||
'Begrüßung, Level-Ansage, Ticket-Texte — alles, was der Bot nach außen schreibt, ' +
|
||||
'lässt sich im Panel ändern, mit Platzhaltern für Name, Zahl und Link.'],
|
||||
[IconApi, 'API & Single Sign-On',
|
||||
'Eigene Skripte posten über die API v1 mit Bearer-Schlüsseln und Bereichs-Rechten. ' +
|
||||
'Andere Dienste nutzen den Discord-Login mit und bekommen die Rollen gleich mitgeliefert.'],
|
||||
[IconPages, 'Eigene Seiten',
|
||||
'Regeln, Über uns, FAQ — in Markdown geschrieben, mit einem Klick veröffentlicht ' +
|
||||
'und automatisch im Menü.'],
|
||||
[IconTeam, 'Rechte fürs Team',
|
||||
'Team-Mitglieder bekommen gezielten Zugriff auf einzelne Bereiche. ' +
|
||||
'Wer was geändert hat, steht im Protokoll.'],
|
||||
[IconServer, 'Läuft überall',
|
||||
'Node und SQLite in einem Container, ein Volume für die Daten. Nächtliche Sicherungen ' +
|
||||
'inklusive — von der Datenbank und allen Repositories.'],
|
||||
[IconBrand, 'features.x.brand'],
|
||||
[IconMessage, 'features.x.texts'],
|
||||
[IconApi, 'features.x.api'],
|
||||
[IconPages, 'features.x.pages'],
|
||||
[IconTeam, 'features.x.team'],
|
||||
[IconServer, 'features.x.host'],
|
||||
];
|
||||
|
||||
export default function BotFeatures() {
|
||||
const { t, tOr } = useT();
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,25 +41,23 @@ export default function BotFeatures() {
|
||||
<div className="page-bg-text">FEATURES</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Alles drin, nichts hinter einer Paywall</div>
|
||||
<h1 className="page-title">Funktionen</h1>
|
||||
<div className="page-tag">{t('features.tag')}</div>
|
||||
<h1 className="page-title">{t('features.title')}</h1>
|
||||
<p className="page-subtitle">
|
||||
{total
|
||||
? `${total} Funktionen — jede einzeln ein- und ausschaltbar, jede im Browser einstellbar.`
|
||||
: 'Jede Funktion einzeln ein- und ausschaltbar, jede im Browser einstellbar.'}
|
||||
{total ? t('features.subtitleN', total) : t('features.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{!data && <p className="notice">Lade …</p>}
|
||||
{!data && <p className="notice">{t('common.loading')}</p>}
|
||||
|
||||
{data?.groups.map((group) => {
|
||||
const items = data.features.filter((f) => f.group === group.id);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="feature-group" key={group.id}>
|
||||
<h2 className="settings-title">// {group.label}</h2>
|
||||
<h2 className="settings-title">// {tOr(`fgroup.${group.id}`, group.label)}</h2>
|
||||
<div className="feature-grid">
|
||||
{items.map((f) => {
|
||||
const Icon = moduleIcon(f.id);
|
||||
@@ -74,8 +65,8 @@ export default function BotFeatures() {
|
||||
<div className="card feature-card" key={f.id}>
|
||||
<span className="feature-icon"><Icon size={20} /></span>
|
||||
<div>
|
||||
<span className="feature-name">{f.name}</span>
|
||||
<p className="feature-desc">{f.desc}</p>
|
||||
<span className="feature-name">{tOr(`feat.${f.id}`, f.name)}</span>
|
||||
<p className="feature-desc">{tOr(`feat.${f.id}.desc`, f.desc)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -87,14 +78,14 @@ export default function BotFeatures() {
|
||||
|
||||
{data && (
|
||||
<div className="feature-group">
|
||||
<h2 className="settings-title">// Drumherum</h2>
|
||||
<h2 className="settings-title">{t('features.extras')}</h2>
|
||||
<div className="feature-grid">
|
||||
{EXTRAS.map(([Icon, name, desc]) => (
|
||||
<div className="card feature-card" key={name}>
|
||||
{EXTRAS.map(([Icon, key]) => (
|
||||
<div className="card feature-card" key={key}>
|
||||
<span className="feature-icon"><Icon size={20} /></span>
|
||||
<div>
|
||||
<span className="feature-name">{name}</span>
|
||||
<p className="feature-desc">{desc}</p>
|
||||
<span className="feature-name">{t(key)}</span>
|
||||
<p className="feature-desc">{t(`${key}.text`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -104,17 +95,13 @@ export default function BotFeatures() {
|
||||
|
||||
{data && (
|
||||
<div className="feature-outro">
|
||||
<p>
|
||||
Gebaut, weil drei Bots mit je eigenem Abo für eine Community
|
||||
weder übersichtlich noch günstig sind. Der Quelltext liegt offen,
|
||||
der Betrieb kostet einen Container.
|
||||
</p>
|
||||
<p>{t('features.outro')}</p>
|
||||
<div className="home-cta">
|
||||
<Link className="btn btn-save with-icon" to="/commands">
|
||||
<IconBot /> Befehle ansehen
|
||||
<IconBot /> {t('bot.viewCommands')}
|
||||
</Link>
|
||||
<Link className="btn btn-ghost with-icon" to="/">
|
||||
Zur Übersicht <IconArrowRight size={15} />
|
||||
{t('features.overview')} <IconArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,24 +3,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiGet } from '../api.js';
|
||||
import { useT } from '../i18n.jsx';
|
||||
import {
|
||||
IconDevlog, IconLevel, IconRoles, IconServer, IconSupport, IconGiveaway,
|
||||
IconBirthday, IconShield, IconMagic, IconKey, IconArrowRight, IconBot,
|
||||
} from '../icons.jsx';
|
||||
|
||||
const HIGHLIGHTS = [
|
||||
[IconDevlog, 'Devlogs & Feeds', 'Entwicklungs-Berichte landen als gebrandetes Embed im Discord und werden gleichzeitig durchsuchbar archiviert. Commits und Releases aus Gitea genauso.'],
|
||||
[IconServer, 'Server-Monitor', 'Über 300 Spiele per gamedig, dazu FiveM und einfache Erreichbarkeits-Checks. Ein Live-Embed pro Server, Alarm bei Ausfall, Spielerzahl in der Bot-Präsenz.'],
|
||||
[IconLevel, 'Level & Aktivität', 'XP fürs Mitreden mit Rollen-Belohnungen, öffentliche Bestenliste und Aktivitäts-Statistik des Servers.'],
|
||||
[IconRoles, 'Rollen-Menüs', 'Selbstbedienung per Knopfdruck — im Discord und im Browser. Farben pro Knopf, wahlweise exklusiv.'],
|
||||
[IconSupport, 'Support & Kontakt', 'Tickets als private Threads mit Gesprächsprotokoll, dazu Modmail: eine Direktnachricht an den Bot landet beim Team.'],
|
||||
[IconShield, 'Moderation', 'Verwarnungen mit Verlauf, Auszeiten, Aufräumen — alles im Mod-Log, dazu Protokoll für Beitritte, Namens- und Rollenwechsel.'],
|
||||
[IconGiveaway, 'Verlosungen', 'Teilnahme per Knopf, automatische Ziehung, Neuauslosung wenn sich niemand meldet.'],
|
||||
[IconBirthday, 'Geburtstage', 'Wer mag, trägt seinen Tag ein — der Bot gratuliert morgens und vergibt eine Rolle für den Tag.'],
|
||||
[IconMagic, 'Willkommens-Karten', 'Neue Mitglieder bekommen ein gerendertes Bild mit Avatar und Mitglieds-Nummer.'],
|
||||
[IconDevlog, 'bot.hl.devlogs'],
|
||||
[IconServer, 'bot.hl.monitor'],
|
||||
[IconLevel, 'bot.hl.level'],
|
||||
[IconRoles, 'bot.hl.roles'],
|
||||
[IconSupport, 'bot.hl.support'],
|
||||
[IconShield, 'bot.hl.mod'],
|
||||
[IconGiveaway, 'bot.hl.giveaway'],
|
||||
[IconBirthday, 'bot.hl.birthday'],
|
||||
[IconMagic, 'bot.hl.welcome'],
|
||||
];
|
||||
|
||||
export default function BotLanding() {
|
||||
const { t } = useT();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [hub, setHub] = useState(null);
|
||||
const [featureCount, setFeatureCount] = useState(null);
|
||||
@@ -40,21 +42,17 @@ export default function BotLanding() {
|
||||
<div className="page-bg-text">BOT</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Selbst gebaut, keine Paywall</div>
|
||||
<div className="page-tag">{t('bot.tag')}</div>
|
||||
<h1 className="page-title">
|
||||
Ein Discord-Bot,<br />der alles macht
|
||||
{t('bot.title1')}<br />{t('bot.title2')}
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
Devlogs, Level, Moderation, Server-Monitoring, Verlosungen, Tickets —
|
||||
was sonst drei Bots mit Abo-Modell erledigen, läuft hier in einem,
|
||||
mit eigenem Webinterface.
|
||||
</p>
|
||||
<p className="page-subtitle">{t('bot.subtitle')}</p>
|
||||
<div className="home-cta">
|
||||
<Link className="btn btn-save with-icon" to="/features">
|
||||
Alle Funktionen <IconArrowRight size={15} />
|
||||
{t('bot.allFeatures')} <IconArrowRight size={15} />
|
||||
</Link>
|
||||
<Link className="btn btn-ghost with-icon" to="/commands">
|
||||
<IconBot /> Befehle ansehen
|
||||
<IconBot /> {t('bot.viewCommands')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,62 +63,56 @@ export default function BotLanding() {
|
||||
{featureCount && (
|
||||
<div className="srv-sum-item">
|
||||
<span className="srv-sum-n">{featureCount}</span>
|
||||
<span className="srv-sum-label">Funktionen, einzeln schaltbar</span>
|
||||
<span className="srv-sum-label">{t('bot.statFeatures')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="srv-sum-item">
|
||||
<span className="srv-sum-n">16</span>
|
||||
<span className="srv-sum-label">Slash-Befehle</span>
|
||||
<span className="srv-sum-label">{t('bot.statCommands')}</span>
|
||||
</div>
|
||||
<div className="srv-sum-item">
|
||||
<span className="srv-sum-n">300<span className="srv-sum-of">+</span></span>
|
||||
<span className="srv-sum-label">Spiele im Monitor</span>
|
||||
<span className="srv-sum-label">{t('bot.statGames')}</span>
|
||||
</div>
|
||||
<div className="srv-sum-item">
|
||||
<span className="srv-sum-n">0<span className="srv-sum-of"> €</span></span>
|
||||
<span className="srv-sum-label">Kosten, keine Premium-Stufen</span>
|
||||
<span className="srv-sum-label">{t('bot.statCost')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-title" style={{ marginTop: '2.5rem' }}>// Was er kann</h2>
|
||||
<h2 className="settings-title" style={{ marginTop: '2.5rem' }}>{t('bot.whatItDoes')}</h2>
|
||||
<div className="home-tiles">
|
||||
{HIGHLIGHTS.map(([Icon, title, text]) => (
|
||||
<div className="card home-tile" key={title}>
|
||||
{HIGHLIGHTS.map(([Icon, key]) => (
|
||||
<div className="card home-tile" key={key}>
|
||||
<span className="home-tile-icon"><Icon size={24} /></span>
|
||||
<span className="home-tile-title">{title}</span>
|
||||
<span className="home-tile-desc">{text}</span>
|
||||
<span className="home-tile-title">{t(key)}</span>
|
||||
<span className="home-tile-desc">{t(`${key}.text`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||||
<h2 className="settings-title">// Für Entwickler</h2>
|
||||
<h2 className="settings-title">{t('bot.forDevs')}</h2>
|
||||
<div className="home-tiles">
|
||||
<div className="card home-tile">
|
||||
<span className="home-tile-icon"><IconKey size={24} /></span>
|
||||
<span className="home-tile-title">API & Single Sign-On</span>
|
||||
<span className="home-tile-desc">
|
||||
Eigene Skripte posten über die API v1 mit Bearer-Schlüsseln und Bereichs-Rechten.
|
||||
Andere Dienste nutzen den Discord-Login des Bots mit und bekommen die Rollen
|
||||
gleich mitgeliefert.
|
||||
</span>
|
||||
<span className="home-tile-title">{t('bot.dev.api')}</span>
|
||||
<span className="home-tile-desc">{t('bot.dev.api.text')}</span>
|
||||
</div>
|
||||
<div className="card home-tile">
|
||||
<span className="home-tile-icon"><IconServer size={24} /></span>
|
||||
<span className="home-tile-title">Läuft überall</span>
|
||||
<span className="home-tile-desc">
|
||||
Node und SQLite in einem Container, ein Volume für die Daten.
|
||||
Nächtliche Sicherungen inklusive — von der Datenbank und allen Repositories.
|
||||
</span>
|
||||
<span className="home-tile-title">{t('bot.dev.host')}</span>
|
||||
<span className="home-tile-desc">{t('bot.dev.host.text')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hub && (
|
||||
<p className="notice" style={{ marginTop: '2rem' }}>
|
||||
Der Bot läuft für die D4RKST3R-Community
|
||||
{status?.members ? ` mit ${status.members} Mitgliedern` : ''} —
|
||||
{' '}<a href={hub} style={{ textDecoration: 'underline' }}>zum Community-Hub</a>.
|
||||
{status?.members
|
||||
? t('bot.runsForWith', status.members)
|
||||
: t('bot.runsFor')} —
|
||||
{' '}<a href={hub} style={{ textDecoration: 'underline' }}>{t('bot.toHub')}</a>.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -5,12 +5,13 @@ import EmptyState from '../components/EmptyState.jsx';
|
||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||
import { Markdown } from '../markdown.jsx';
|
||||
import ToTop from '../components/ToTop.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit', month: 'long', year: 'numeric',
|
||||
});
|
||||
const DATUM = { day: '2-digit', month: 'long', year: 'numeric' };
|
||||
|
||||
export default function Changelog() {
|
||||
const { t } = useT();
|
||||
const { datum } = useFmt();
|
||||
const [data, setData] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -29,21 +30,21 @@ export default function Changelog() {
|
||||
<div className="page-bg-text">CHANGELOG</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Releases</div>
|
||||
<h1 className="page-title">Changelog</h1>
|
||||
<p className="page-subtitle">Alle Versionen und Release-Notes.</p>
|
||||
<div className="page-tag">{t('changelog.tag')}</div>
|
||||
<h1 className="page-title">{t('nav.changelog')}</h1>
|
||||
<p className="page-subtitle">{t('changelog.subtitle')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <p className="notice">Changelog konnte nicht geladen werden.</p>}
|
||||
{error && <p className="notice">{t('changelog.error')}</p>}
|
||||
{!error && !data && <SkeletonCards n={3} />}
|
||||
{data?.total === 0 && (
|
||||
<EmptyState
|
||||
Icon={IconChangelog}
|
||||
title="Noch keine Releases"
|
||||
text="Sobald eine Version veröffentlicht wird, steht sie hier mit allen Änderungen. Bis dahin steht der Fortschritt im Devlog."
|
||||
actions={[{ to: '/devlogs', label: 'Devlogs lesen' }, { to: '/roadmap', label: 'Zur Roadmap' }]}
|
||||
title={t('changelog.emptyTitle')}
|
||||
text={t('changelog.emptyText')}
|
||||
actions={[{ to: '/devlogs', label: t('common.readDevlogs') }, { to: '/roadmap', label: t('common.toRoadmap') }]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -52,17 +53,17 @@ export default function Changelog() {
|
||||
<article className="card release-card" key={`${r.repo}/${r.tag}`}>
|
||||
<div className="card-meta">
|
||||
<span className="release-tag">{r.tag}</span>
|
||||
{r.prerelease ? <span className="chip chip-branch">Pre-Release</span> : null}
|
||||
{r.prerelease ? <span className="chip chip-branch">{t('changelog.prerelease')}</span> : null}
|
||||
<span className="chip chip-repo">{r.repo.split('/').pop()}</span>
|
||||
<span className="card-rel">{dateFmt.format(new Date(r.published_at))}</span>
|
||||
<span className="card-rel">{datum(r.published_at, DATUM)}</span>
|
||||
</div>
|
||||
{r.name && r.name !== r.tag && <h3 className="release-name">{r.name}</h3>}
|
||||
<div className="card-body">
|
||||
<Markdown text={r.body || '*Keine Release-Notes.*'} />
|
||||
<Markdown text={r.body || t('changelog.noNotes')} />
|
||||
</div>
|
||||
{r.url && (
|
||||
<div className="card-footer">
|
||||
<a href={r.url} target="_blank" rel="noreferrer">Release in Gitea ansehen <IconChevronRight size={13} /></a>
|
||||
<a href={r.url} target="_blank" rel="noreferrer">{t('changelog.viewInGitea')} <IconChevronRight size={13} /></a>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
@@ -71,13 +72,13 @@ export default function Changelog() {
|
||||
{pages > 1 && (
|
||||
<div className="pager">
|
||||
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<IconChevronLeft size={14} /> Neuere
|
||||
<IconChevronLeft size={14} /> {t('common.newer')}
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
||||
</span>
|
||||
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
||||
Ältere <IconChevronRight size={14} />
|
||||
{t('common.older')} <IconChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,13 +8,14 @@ import { Markdown } from '../markdown.jsx';
|
||||
import Lightbox from '../components/Lightbox.jsx';
|
||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||
import { splitContent } from './Devlogs.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
|
||||
});
|
||||
const DATUM = { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' };
|
||||
|
||||
export default function DevlogDetail() {
|
||||
const { id } = useParams();
|
||||
const { t } = useT();
|
||||
const { datum } = useFmt();
|
||||
const [item, setItem] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [lightbox, setLightbox] = useState(null);
|
||||
@@ -43,16 +44,16 @@ export default function DevlogDetail() {
|
||||
<div className="page-bg-text">DEVLOG</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Devlog</div>
|
||||
<h1 className="page-title">{item ? dateFmt.format(new Date(item.posted_at)) : 'Devlog'}</h1>
|
||||
{parts?.project && <p className="page-subtitle">Projekt: {parts.project}</p>}
|
||||
<div className="page-tag">{t('devlog.tag')}</div>
|
||||
<h1 className="page-title">{item ? datum(item.posted_at, DATUM) : t('nav.devlog')}</h1>
|
||||
{parts?.project && <p className="page-subtitle">{t('devlog.project', parts.project)}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && (
|
||||
<p className="notice">
|
||||
Devlog nicht gefunden — <Link to="/devlogs" style={{ textDecoration: 'underline' }}>zurück zum Archiv</Link>.
|
||||
{t('devlog.notFound')} <Link to="/devlogs" style={{ textDecoration: 'underline' }}>{t('devlog.backToArchive')}</Link>.
|
||||
</p>
|
||||
)}
|
||||
{!error && !item && <SkeletonCards n={1} />}
|
||||
@@ -67,7 +68,7 @@ export default function DevlogDetail() {
|
||||
<div className={`card-images n${Math.min(item.images.length, 4)}`}>
|
||||
{item.images.map((src, i) => (
|
||||
<a key={src} href={src} onClick={(e) => { e.preventDefault(); setLightbox(i); }}>
|
||||
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
|
||||
<img src={src} alt={t('devlogs.screenshot')} loading="lazy" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -80,8 +81,8 @@ export default function DevlogDetail() {
|
||||
</article>
|
||||
|
||||
<div className="field-row" style={{ marginTop: '1.2rem' }}>
|
||||
<Link className="btn with-icon" to="/devlogs"><IconChevronLeft size={14} /> Alle Devlogs</Link>
|
||||
<button className="btn" onClick={copyLink}>{copied ? <span className="with-icon"><IconCheck size={14} /> Link kopiert</span> : <span className="with-icon"><IconLink size={14} /> Link kopieren</span>}</button>
|
||||
<Link className="btn with-icon" to="/devlogs"><IconChevronLeft size={14} /> {t('devlog.allDevlogs')}</Link>
|
||||
<button className="btn" onClick={copyLink}>{copied ? <span className="with-icon"><IconCheck size={14} /> {t('devlog.linkCopied')}</span> : <span className="with-icon"><IconLink size={14} /> {t('devlog.copyLink')}</span>}</button>
|
||||
</div>
|
||||
|
||||
{lightbox !== null && (
|
||||
|
||||
@@ -5,20 +5,19 @@ import { Markdown } from '../markdown.jsx';
|
||||
import ToTop from '../components/ToTop.jsx';
|
||||
import Lightbox from '../components/Lightbox.jsx';
|
||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
});
|
||||
const monthFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
|
||||
const DATUM = { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' };
|
||||
const MONAT = { month: 'long', year: 'numeric' };
|
||||
|
||||
/** Relative Angabe wie „vor 3 Tagen" für die Karten-Ecke */
|
||||
function relative(iso) {
|
||||
function relative(iso, t) {
|
||||
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
|
||||
if (days <= 0) return 'heute';
|
||||
if (days === 1) return 'gestern';
|
||||
if (days < 30) return `vor ${days} Tagen`;
|
||||
if (days <= 0) return t('common.today');
|
||||
if (days === 1) return t('common.yesterday');
|
||||
if (days < 30) return t('common.daysAgo', days);
|
||||
const months = Math.floor(days / 30);
|
||||
return months === 1 ? 'vor 1 Monat' : `vor ${months} Monaten`;
|
||||
return months === 1 ? t('common.monthAgo') : t('common.monthsAgo', months);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +44,8 @@ export function splitContent(raw) {
|
||||
}
|
||||
|
||||
export default function Devlogs({ me }) {
|
||||
const { t } = useT();
|
||||
const { datum } = useFmt();
|
||||
const [data, setData] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -83,12 +84,12 @@ export default function Devlogs({ me }) {
|
||||
|
||||
// Admin: Devlog aus dem Archiv entfernen (löscht auch die lokalen Bilder)
|
||||
async function remove(id) {
|
||||
if (!window.confirm('Dieses Devlog aus dem Archiv löschen?')) return;
|
||||
if (!window.confirm(t('devlogs.confirmDelete'))) return;
|
||||
try {
|
||||
await apiDelete(`/api/devlogs/${id}`);
|
||||
load();
|
||||
} catch {
|
||||
window.alert('Löschen fehlgeschlagen.');
|
||||
window.alert(t('devlogs.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +104,9 @@ export default function Devlogs({ me }) {
|
||||
<div className="page-bg-text">DEVLOG</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Archiv</div>
|
||||
<h1 className="page-title">Devlog</h1>
|
||||
<p className="page-subtitle">
|
||||
Entwicklungs-Updates, frisch aus dem Editor — automatisch archiviert.
|
||||
</p>
|
||||
<div className="page-tag">{t('devlogs.tag')}</div>
|
||||
<h1 className="page-title">{t('nav.devlog')}</h1>
|
||||
<p className="page-subtitle">{t('devlogs.subtitle')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -118,7 +117,7 @@ export default function Devlogs({ me }) {
|
||||
className={`dl-filter${project === '' ? ' active' : ''}`}
|
||||
onClick={() => { setProject(''); setPage(1); }}
|
||||
>
|
||||
Alle <span className="dl-filter-n">{projects.reduce((s, p) => s + p.n, 0)}</span>
|
||||
{t('devlogs.all')} <span className="dl-filter-n">{projects.reduce((s, p) => s + p.n, 0)}</span>
|
||||
</button>
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
@@ -136,22 +135,22 @@ export default function Devlogs({ me }) {
|
||||
<span className="search-icon"><IconSearch size={16} /></span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Devlogs durchsuchen …"
|
||||
placeholder={t('devlogs.search')}
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
/>
|
||||
{q && data && <span className="search-count">{data.total} Treffer</span>}
|
||||
{q && data && <span className="search-count">{t('devlogs.hits', data.total)}</span>}
|
||||
</div>
|
||||
|
||||
{error && <p className="notice">Devlogs konnten nicht geladen werden.</p>}
|
||||
{error && <p className="notice">{t('devlogs.error')}</p>}
|
||||
{!error && !data && <SkeletonCards n={3} />}
|
||||
{data?.total === 0 && (
|
||||
<p className="notice">
|
||||
{q
|
||||
? `Keine Treffer für „${q}".`
|
||||
? t('devlogs.noHits', q)
|
||||
: project
|
||||
? `Noch keine Devlogs für ${project}.`
|
||||
: 'Noch keine Devlogs archiviert.'}
|
||||
? t('devlogs.noneForProject', project)
|
||||
: t('devlogs.none')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -159,7 +158,7 @@ export default function Devlogs({ me }) {
|
||||
<div className="timeline">
|
||||
{data.items.map((d) => {
|
||||
const { project, footer, body } = splitContent(d.content);
|
||||
const month = monthFmt.format(new Date(d.posted_at));
|
||||
const month = datum(d.posted_at, MONAT);
|
||||
const showMonth = month !== lastMonth;
|
||||
lastMonth = month;
|
||||
return (
|
||||
@@ -168,15 +167,15 @@ export default function Devlogs({ me }) {
|
||||
<article className="card">
|
||||
<div className="card-meta">
|
||||
<span className="card-date">
|
||||
{dateFmt.format(new Date(d.posted_at))}
|
||||
{datum(d.posted_at, DATUM)}
|
||||
</span>
|
||||
{project && <span className="card-project">{project}</span>}
|
||||
<span className="card-rel">{relative(d.posted_at)}</span>
|
||||
<a className="card-permalink" href={`/devlogs/${d.message_id}`} title="Direktlink zu diesem Devlog"><IconLink size={14} /></a>
|
||||
<span className="card-rel">{relative(d.posted_at, t)}</span>
|
||||
<a className="card-permalink" href={`/devlogs/${d.message_id}`} title={t('devlogs.permalink')}><IconLink size={14} /></a>
|
||||
{me?.admin && (
|
||||
<button
|
||||
className="card-delete"
|
||||
title="Aus dem Archiv löschen"
|
||||
title={t('devlogs.delete')}
|
||||
onClick={() => remove(d.message_id)}
|
||||
>
|
||||
<IconX size={15} />
|
||||
@@ -196,7 +195,7 @@ export default function Devlogs({ me }) {
|
||||
setLightbox({ images: d.images, start: i });
|
||||
}}
|
||||
>
|
||||
<img src={src} alt="Devlog-Screenshot" loading="lazy" />
|
||||
<img src={src} alt={t('devlogs.screenshot')} loading="lazy" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -224,13 +223,13 @@ export default function Devlogs({ me }) {
|
||||
{pages > 1 && (
|
||||
<div className="pager">
|
||||
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<IconChevronLeft size={14} /> Neuere
|
||||
<IconChevronLeft size={14} /> {t('common.newer')}
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
||||
</span>
|
||||
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
||||
Ältere <IconChevronRight size={14} />
|
||||
{t('common.older')} <IconChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,12 +3,15 @@ import { apiGet } from '../api.js';
|
||||
import { IconStar, IconChevronRight, IconEvents } from '../icons.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import { Markdown } from '../markdown.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
const DATUM = {
|
||||
weekday: 'long', day: '2-digit', month: 'long', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export default function Events() {
|
||||
const { t } = useT();
|
||||
const { datum } = useFmt();
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
@@ -23,21 +26,21 @@ export default function Events() {
|
||||
<div className="page-bg-text">EVENTS</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Termine</div>
|
||||
<h1 className="page-title">Events</h1>
|
||||
<p className="page-subtitle">Playtests, Streams & Community-Abende — direkt aus dem Discord.</p>
|
||||
<div className="page-tag">{t('events.tag')}</div>
|
||||
<h1 className="page-title">{t('nav.events')}</h1>
|
||||
<p className="page-subtitle">{t('events.subtitle')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <p className="notice">Events konnten nicht geladen werden.</p>}
|
||||
{!error && !data && <p className="notice">Lade …</p>}
|
||||
{error && <p className="notice">{t('events.error')}</p>}
|
||||
{!error && !data && <p className="notice">{t('common.loading')}</p>}
|
||||
{data?.events.length === 0 && (
|
||||
<EmptyState
|
||||
Icon={IconEvents}
|
||||
title="Noch keine Events geplant"
|
||||
text="Playtests, Streams und Community-Runden kündigen wir hier an — und im Discord bekommst du eine Erinnerung."
|
||||
actions={[{ to: '/devlogs', label: 'Devlogs lesen' }, { to: '/roadmap', label: 'Zur Roadmap' }]}
|
||||
title={t('events.emptyTitle')}
|
||||
text={t('events.emptyText')}
|
||||
actions={[{ to: '/devlogs', label: t('common.readDevlogs') }, { to: '/roadmap', label: t('common.toRoadmap') }]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -45,8 +48,8 @@ export default function Events() {
|
||||
<article className="card event-card" key={e.url}>
|
||||
{e.cover && <a href={e.url} target="_blank" rel="noreferrer"><img className="event-cover" src={e.cover} alt="" /></a>}
|
||||
<div className="card-meta">
|
||||
<span className="card-date">{e.start ? dateFmt.format(new Date(e.start)) : 'Termin folgt'}</span>
|
||||
{e.interested != null && <span className="card-rel with-icon"><IconStar size={13} /> {e.interested} interessiert</span>}
|
||||
<span className="card-date">{e.start ? datum(e.start, DATUM) : t('events.tbd')}</span>
|
||||
{e.interested != null && <span className="card-rel with-icon"><IconStar size={13} /> {t('events.interested', e.interested)}</span>}
|
||||
</div>
|
||||
<h3 className="release-name">{e.name}</h3>
|
||||
{e.description && (
|
||||
@@ -55,7 +58,7 @@ export default function Events() {
|
||||
</div>
|
||||
)}
|
||||
<div className="card-footer">
|
||||
<a href={e.url} target="_blank" rel="noreferrer">Im Discord ansehen <IconChevronRight size={13} /></a>
|
||||
<a href={e.url} target="_blank" rel="noreferrer">{t('events.viewInDiscord')} <IconChevronRight size={13} /></a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { IconChevronLeft, IconChevronRight, IconGallery } from '../icons.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import Lightbox from '../components/Lightbox.jsx';
|
||||
import { SkeletonGrid } from '../components/Skeleton.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const DATUM = { day: '2-digit', month: '2-digit', year: 'numeric' };
|
||||
|
||||
export default function Galerie() {
|
||||
const { t } = useT();
|
||||
const { datum } = useFmt();
|
||||
const [data, setData] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -30,21 +33,21 @@ export default function Galerie() {
|
||||
<div className="page-bg-text">GALERIE</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Community</div>
|
||||
<h1 className="page-title">Galerie</h1>
|
||||
<p className="page-subtitle">Screenshots aus der Community — direkt aus dem Discord.</p>
|
||||
<div className="page-tag">{t('gallery.tag')}</div>
|
||||
<h1 className="page-title">{t('nav.gallery')}</h1>
|
||||
<p className="page-subtitle">{t('gallery.subtitle')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <p className="notice">Galerie konnte nicht geladen werden.</p>}
|
||||
{error && <p className="notice">{t('gallery.error')}</p>}
|
||||
{!error && !data && <SkeletonGrid n={9} />}
|
||||
{data?.total === 0 && (
|
||||
<EmptyState
|
||||
Icon={IconGallery}
|
||||
title="Noch keine Screenshots"
|
||||
text="Alles, was im Screenshot-Kanal landet, erscheint automatisch hier — mitsamt Name und Datum."
|
||||
actions={[{ to: '/devlogs', label: 'Devlogs lesen' }]}
|
||||
title={t('gallery.emptyTitle')}
|
||||
text={t('gallery.emptyText')}
|
||||
actions={[{ to: '/devlogs', label: t('common.readDevlogs') }]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -54,10 +57,10 @@ export default function Galerie() {
|
||||
className="shot" href={s.src} key={s.key}
|
||||
onClick={(e) => { e.preventDefault(); setLightbox(i); }}
|
||||
>
|
||||
<img src={s.src} alt={`Screenshot von ${s.author}`} loading="lazy" />
|
||||
<img src={s.src} alt={t('gallery.shotBy', s.author)} loading="lazy" />
|
||||
<span className="shot-meta">
|
||||
<span className="shot-author">{s.author}</span>
|
||||
<span className="shot-date">{dateFmt.format(new Date(s.date))}</span>
|
||||
<span className="shot-date">{datum(s.date, DATUM)}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
@@ -67,7 +70,7 @@ export default function Galerie() {
|
||||
<Lightbox
|
||||
images={shots.map((s) => ({
|
||||
src: s.src,
|
||||
caption: `${s.author} · ${dateFmt.format(new Date(s.date))}`,
|
||||
caption: `${s.author} · ${datum(s.date, DATUM)}`,
|
||||
}))}
|
||||
start={lightbox}
|
||||
onClose={() => setLightbox(null)}
|
||||
@@ -77,13 +80,13 @@ export default function Galerie() {
|
||||
{pages > 1 && (
|
||||
<div className="pager">
|
||||
<button className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<IconChevronLeft size={14} /> Neuere
|
||||
<IconChevronLeft size={14} /> {t('common.newer')}
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
{String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')}
|
||||
</span>
|
||||
<button className="btn" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
||||
Ältere <IconChevronRight size={14} />
|
||||
{t('common.older')} <IconChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+24
-24
@@ -4,18 +4,19 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiGet } from '../api.js';
|
||||
import { Markdown } from '../markdown.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
import {
|
||||
IconDevlog, IconRoadmap, IconServer, IconGallery, IconLevel, IconEvents,
|
||||
IconMessage, IconArrowRight, IconLink,
|
||||
} from '../icons.jsx';
|
||||
|
||||
const TILES = [
|
||||
['/devlogs', IconDevlog, 'Devlog', 'Entwicklungs-Updates, frisch aus dem Editor'],
|
||||
['/roadmap', IconRoadmap, 'Roadmap', 'Meilensteine + Community-Wünsche zum Abstimmen'],
|
||||
['/server', IconServer, 'Server', 'Live-Status aller Game-Server mit Verlauf'],
|
||||
['/galerie', IconGallery, 'Galerie', 'Screenshots aus der Community'],
|
||||
['/level', IconLevel, 'Level', 'XP-Bestenliste — wer redet am meisten mit?'],
|
||||
['/events', IconEvents, 'Events', 'Playtests, Streams und was sonst ansteht'],
|
||||
['/devlogs', IconDevlog, 'nav.devlog', 'home.tile.devlog'],
|
||||
['/roadmap', IconRoadmap, 'nav.roadmap', 'home.tile.roadmap'],
|
||||
['/server', IconServer, 'nav.server', 'home.tile.server'],
|
||||
['/galerie', IconGallery, 'nav.gallery', 'home.tile.gallery'],
|
||||
['/level', IconLevel, 'nav.level', 'home.tile.level'],
|
||||
['/events', IconEvents, 'nav.events', 'home.tile.events'],
|
||||
];
|
||||
|
||||
/** Devlog-Text für den Teaser kürzen (erste Sätze, ohne Titel-Zeile) */
|
||||
@@ -28,6 +29,8 @@ function teaser(content) {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useT();
|
||||
const { zahl } = useFmt();
|
||||
const [stats, setStats] = useState(null);
|
||||
const [servers, setServers] = useState(null);
|
||||
const [latest, setLatest] = useState(null);
|
||||
@@ -52,22 +55,19 @@ export default function Home() {
|
||||
<div className="page-bg-text">D4RKST3R</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Community-Hub</div>
|
||||
<div className="page-tag">{t('home.tag')}</div>
|
||||
<h1 className="page-title">
|
||||
Willkommen<br />bei D4RKST<span className="home-accent">3</span>R
|
||||
{t('home.welcome')}<br />{t('home.at')} D4RKST<span className="home-accent">3</span>R
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
Devlogs, Roadmap, Server-Status und alles aus der Community —
|
||||
an einem Ort, direkt aus dem Discord.
|
||||
</p>
|
||||
<p className="page-subtitle">{t('home.subtitle')}</p>
|
||||
<div className="home-cta">
|
||||
{invite && (
|
||||
<a className="btn btn-save with-icon" href={invite} target="_blank" rel="noreferrer">
|
||||
<IconMessage /> Zum Discord
|
||||
<IconMessage /> {t('home.discord')}
|
||||
</a>
|
||||
)}
|
||||
<Link className="btn btn-ghost home-cta-alt with-icon" to="/devlogs">
|
||||
<IconDevlog /> Devlogs lesen
|
||||
<IconDevlog /> {t('home.readDevlogs')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,38 +78,38 @@ export default function Home() {
|
||||
<div className="srv-summary home-stats">
|
||||
{stats?.members > 0 && (
|
||||
<div className="srv-sum-item">
|
||||
<span className="srv-sum-n">{stats.members.toLocaleString('de-DE')}</span>
|
||||
<span className="srv-sum-label">Member im Discord</span>
|
||||
<span className="srv-sum-n">{zahl(stats.members)}</span>
|
||||
<span className="srv-sum-label">{t('home.members')}</span>
|
||||
</div>
|
||||
)}
|
||||
{servers?.length > 0 && (
|
||||
<div className="srv-sum-item">
|
||||
<span className="srv-sum-n">{online.length}<span className="srv-sum-of"> / {servers.length}</span></span>
|
||||
<span className="srv-sum-label">Server online</span>
|
||||
<span className="srv-sum-label">{t('home.serversOnline')}</span>
|
||||
</div>
|
||||
)}
|
||||
{online.length > 0 && (
|
||||
<div className="srv-sum-item">
|
||||
<span className="srv-sum-n">{players}</span>
|
||||
<span className="srv-sum-label">Spieler gerade drauf</span>
|
||||
<span className="srv-sum-label">{t('home.playersNow')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="home-tiles">
|
||||
{TILES.map(([to, Icon, title, desc]) => (
|
||||
{TILES.map(([to, Icon, titleKey, descKey]) => (
|
||||
<Link className="card home-tile" to={to} key={to}>
|
||||
<span className="home-tile-icon"><Icon size={26} /></span>
|
||||
<span className="home-tile-title">{title}</span>
|
||||
<span className="home-tile-desc">{desc}</span>
|
||||
<span className="home-tile-title">{t(titleKey)}</span>
|
||||
<span className="home-tile-desc">{t(descKey)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{services.length > 0 && (
|
||||
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||||
<h2 className="settings-title">// Dienste</h2>
|
||||
<h2 className="settings-title">{t('home.services')}</h2>
|
||||
<div className="home-tiles">
|
||||
{/* Dienst-Icons pflegt der Owner selbst — daher bleiben es Emojis */}
|
||||
{services.map((s) => (
|
||||
@@ -127,12 +127,12 @@ export default function Home() {
|
||||
|
||||
{latest && (
|
||||
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||||
<h2 className="settings-title">// Neuestes Devlog</h2>
|
||||
<h2 className="settings-title">{t('home.latestDevlog')}</h2>
|
||||
<Link to="/devlogs" className="card home-devlog">
|
||||
<div className="card-body">
|
||||
<Markdown text={teaser(latest.content)} />
|
||||
</div>
|
||||
<span className="home-devlog-more with-icon">Weiterlesen <IconArrowRight size={14} /></span>
|
||||
<span className="home-devlog-more with-icon">{t('home.more')} <IconArrowRight size={14} /></span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiGet } from '../api.js';
|
||||
import { SkeletonRows } from '../components/Skeleton.jsx';
|
||||
import { IconTrophy, IconLevel } from '../icons.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
|
||||
/** Muss der Formel in src/bot/levels.js entsprechen */
|
||||
function xpForLevel(level) {
|
||||
@@ -16,6 +17,8 @@ const MEDAL_COLORS = ['#f5c518', '#c0c4cc', '#c07a3e'];
|
||||
|
||||
/** Nachrichten/Tag der letzten 30 Tage als Balken */
|
||||
function ActivityChart({ stats }) {
|
||||
const { t } = useT();
|
||||
const { zahl } = useFmt();
|
||||
const byDay = new Map(stats.days.map((d) => [d.day, d]));
|
||||
const days = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
@@ -26,14 +29,14 @@ function ActivityChart({ stats }) {
|
||||
const total = days.reduce((s, d) => s + d.messages, 0);
|
||||
return (
|
||||
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||||
<h2 className="settings-title">// Server-Aktivität — {total.toLocaleString('de-DE')} Nachrichten in 30 Tagen · {stats.members} Member</h2>
|
||||
<h2 className="settings-title">{t('level.activity', zahl(total), stats.members)}</h2>
|
||||
<div className="act-chart">
|
||||
{days.map((d) => (
|
||||
<span
|
||||
key={d.key}
|
||||
className="act-bar"
|
||||
style={{ height: `${Math.max(4, (d.messages / max) * 100)}%` }}
|
||||
title={`${d.key}: ${d.messages} Nachrichten · +${d.joins}/−${d.leaves} Member`}
|
||||
title={t('level.barTitle', d.key, d.messages, d.joins, d.leaves)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -42,6 +45,8 @@ function ActivityChart({ stats }) {
|
||||
}
|
||||
|
||||
export default function Level() {
|
||||
const { t } = useT();
|
||||
const { zahl } = useFmt();
|
||||
const [rows, setRows] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -58,23 +63,23 @@ export default function Level() {
|
||||
<div className="page-bg-text">LEVEL</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Bestenliste</div>
|
||||
<h1 className="page-title">Level</h1>
|
||||
<div className="page-tag">{t('level.tag')}</div>
|
||||
<h1 className="page-title">{t('nav.level')}</h1>
|
||||
<p className="page-subtitle">
|
||||
XP gibt's fürs Mitreden im Discord — hier stehen die Aktivsten. <code>/rank</code> zeigt deinen Platz.
|
||||
{t('level.subtitle').split('%s')[0]}<code>/rank</code>{t('level.subtitle').split('%s')[1]}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <p className="notice">Bestenliste konnte nicht geladen werden.</p>}
|
||||
{error && <p className="notice">{t('level.error')}</p>}
|
||||
{!error && !rows && <SkeletonRows n={10} />}
|
||||
{rows?.length === 0 && (
|
||||
<EmptyState
|
||||
Icon={IconLevel}
|
||||
title="Noch keine XP vergeben"
|
||||
text="XP gibt es fürs Mitreden im Discord. Sobald geschrieben wird, füllt sich die Bestenliste von selbst."
|
||||
actions={[{ to: '/devlogs', label: 'Devlogs lesen' }]}
|
||||
title={t('level.emptyTitle')}
|
||||
text={t('level.emptyText')}
|
||||
actions={[{ to: '/devlogs', label: t('common.readDevlogs') }]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -99,8 +104,8 @@ export default function Level() {
|
||||
<div className="lvl-progress">
|
||||
<div className="progress"><div className="progress-bar" style={{ width: `${pct}%` }} /></div>
|
||||
</div>
|
||||
<span className="lvl-level">Lv. {r.level}</span>
|
||||
<span className="lvl-xp">{r.xp.toLocaleString('de-DE')} XP</span>
|
||||
<span className="lvl-level">{t('level.lv', r.level)}</span>
|
||||
<span className="lvl-xp">{zahl(r.xp)} XP</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { apiGet, apiPost } from '../api.js';
|
||||
import { Markdown } from '../markdown.jsx';
|
||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||
import { IconThumbsUp } from '../icons.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
|
||||
const dueFmt = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' });
|
||||
const ZIEL = { month: 'long', year: 'numeric' };
|
||||
|
||||
/** Milestone-Gruppe bestimmen: Fertig / In Arbeit / Geplant */
|
||||
function groupOf(m) {
|
||||
@@ -13,12 +14,14 @@ function groupOf(m) {
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{ id: 'active', tag: '// In Arbeit', empty: 'Gerade nichts in Arbeit.' },
|
||||
{ id: 'planned', tag: '// Geplant', empty: null },
|
||||
{ id: 'done', tag: '// Fertig', empty: null },
|
||||
{ id: 'active', tag: 'roadmap.active', empty: 'roadmap.nothingActive' },
|
||||
{ id: 'planned', tag: 'roadmap.planned', empty: null },
|
||||
{ id: 'done', tag: 'roadmap.done', empty: null },
|
||||
];
|
||||
|
||||
function Milestone({ m }) {
|
||||
const { t } = useT();
|
||||
const { datum } = useFmt();
|
||||
const total = m.open + m.closed;
|
||||
const pct = total > 0 ? Math.round((m.closed / total) * 100) : m.state === 'closed' ? 100 : 0;
|
||||
return (
|
||||
@@ -26,7 +29,7 @@ function Milestone({ m }) {
|
||||
<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>
|
||||
<span className="card-rel">{t('roadmap.due', datum(m.due_on, ZIEL))}</span>
|
||||
)}
|
||||
</div>
|
||||
{m.description && (
|
||||
@@ -39,7 +42,7 @@ function Milestone({ m }) {
|
||||
</div>
|
||||
<div className="progress-info">
|
||||
<span>{pct}%</span>
|
||||
{total > 0 && <span>{m.closed} / {total} Aufgaben</span>}
|
||||
{total > 0 && <span>{t('roadmap.tasks', m.closed, total)}</span>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -47,6 +50,7 @@ function Milestone({ m }) {
|
||||
|
||||
/** GitHub-Style-Heatmap: 52 Wochen × 7 Tage */
|
||||
function Heatmap({ days }) {
|
||||
const { t } = useT();
|
||||
const byDay = new Map(days.map((d) => [d.day, d.n]));
|
||||
const max = Math.max(1, ...days.map((d) => d.n));
|
||||
const today = new Date();
|
||||
@@ -67,7 +71,7 @@ function Heatmap({ days }) {
|
||||
<span
|
||||
key={d}
|
||||
className={`hm-cell hm-${level}`}
|
||||
title={`${key}: ${n} Commit${n === 1 ? '' : 's'}`}
|
||||
title={n === 1 ? t('roadmap.commitOn', key) : t('roadmap.commitsOn', key, n)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -76,13 +80,14 @@ function Heatmap({ days }) {
|
||||
const total = days.reduce((sum, d) => sum + d.n, 0);
|
||||
return (
|
||||
<div className="roadmap-group">
|
||||
<h2 className="settings-title">// Aktivität — {total} Commits in 12 Monaten</h2>
|
||||
<h2 className="settings-title">{t('roadmap.activity', total)}</h2>
|
||||
<div className="hm-wrap">{weeks}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Roadmap() {
|
||||
const { t } = useT();
|
||||
const [data, setData] = useState(null);
|
||||
const [wishes, setWishes] = useState([]);
|
||||
const [canVote, setCanVote] = useState(false);
|
||||
@@ -105,20 +110,20 @@ export default function Roadmap() {
|
||||
? w
|
||||
: { ...w, voted: res.voted, score: w.score + (res.voted ? 1 : -1) }));
|
||||
} catch (e) {
|
||||
if (e.status === 403) setSubmitMsg('✗ Voten dürfen nur Discord-Member.');
|
||||
if (e.status === 403) setSubmitMsg(t('roadmap.voteMembersOnly'));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitWish() {
|
||||
const text = idea.trim();
|
||||
if (text.length < 5) { setSubmitMsg('✗ Etwas mehr Text bitte.'); return; }
|
||||
if (text.length < 5) { setSubmitMsg(t('roadmap.tooShort')); return; }
|
||||
try {
|
||||
await apiPost('/api/wishes', { idea: text });
|
||||
setIdea('');
|
||||
setSubmitMsg('✓ Wunsch eingereicht — er steht jetzt auch im Discord zur Abstimmung!');
|
||||
setSubmitMsg(t('roadmap.submitted'));
|
||||
apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {});
|
||||
} catch (e) {
|
||||
setSubmitMsg(e.status === 403 ? '✗ Einreichen dürfen nur Discord-Member.' : '✗ Konnte den Wunsch nicht einreichen.');
|
||||
setSubmitMsg(e.status === 403 ? t('roadmap.submitMembersOnly') : t('roadmap.submitFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,32 +133,30 @@ export default function Roadmap() {
|
||||
<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 className="page-tag">{t('roadmap.tag')}</div>
|
||||
<h1 className="page-title">{t('nav.roadmap')}</h1>
|
||||
<p className="page-subtitle">{t('roadmap.subtitle')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <p className="notice">Roadmap konnte nicht geladen werden.</p>}
|
||||
{error && <p className="notice">{t('roadmap.error')}</p>}
|
||||
{!error && !data && <SkeletonCards n={3} />}
|
||||
{data?.milestones.length === 0 && (
|
||||
<p className="notice">Noch keine Meilensteine angelegt.</p>
|
||||
<p className="notice">{t('roadmap.noMilestones')}</p>
|
||||
)}
|
||||
|
||||
{heatmap && heatmap.length > 0 && <Heatmap days={heatmap} />}
|
||||
|
||||
{(wishes.length > 0 || canVote) && (
|
||||
<div className="roadmap-group">
|
||||
<h2 className="settings-title">// Community-Wünsche</h2>
|
||||
<h2 className="settings-title">{t('roadmap.wishes')}</h2>
|
||||
{wishes.map((w, i) => (
|
||||
<div className="wish-row" key={w.message_id ?? `${w.created_at}-${i}`}>
|
||||
{canVote && w.message_id ? (
|
||||
<button
|
||||
className={`wish-vote with-icon${w.voted ? ' voted' : ''}`}
|
||||
title={w.voted ? 'Vote zurücknehmen' : 'Dafür stimmen'}
|
||||
title={w.voted ? t('roadmap.unvote') : t('roadmap.vote')}
|
||||
onClick={() => vote(w.message_id)}
|
||||
>
|
||||
<IconThumbsUp size={14} /> {w.score}
|
||||
@@ -168,16 +171,16 @@ export default function Roadmap() {
|
||||
{canVote ? (
|
||||
<div className="wish-form">
|
||||
<input
|
||||
type="text" maxLength={500} placeholder="Deine Idee für den Server …"
|
||||
type="text" maxLength={500} placeholder={t('roadmap.ideaPlaceholder')}
|
||||
value={idea}
|
||||
onChange={(e) => setIdea(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }}
|
||||
/>
|
||||
<button className="btn btn-save" onClick={submitWish}>Einreichen</button>
|
||||
<button className="btn btn-save" onClick={submitWish}>{t('roadmap.submit')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="notice" style={{ padding: '.6rem 0 0' }}>
|
||||
Eigene Idee? Im Discord <code>/wunsch</code> benutzen — oder hier einloggen und direkt abstimmen.
|
||||
{t('roadmap.hint').split('%s')[0]}<code>/wunsch</code>{t('roadmap.hint').split('%s')[1]}
|
||||
</p>
|
||||
)}
|
||||
{submitMsg && <p className="notice" style={{ padding: '.6rem 0 0' }}>{submitMsg}</p>}
|
||||
@@ -189,8 +192,8 @@ export default function Roadmap() {
|
||||
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>}
|
||||
<h2 className="settings-title">{t(tag)}</h2>
|
||||
{items.length === 0 && <p className="notice">{t(empty)}</p>}
|
||||
{items.map((m) => (
|
||||
<Milestone m={m} key={m.title} />
|
||||
))}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useEffect, useState } from 'react';
|
||||
import { apiGet } from '../api.js';
|
||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||
import { IconGame, IconMapPin, IconClock, IconCheck } from '../icons.jsx';
|
||||
import { useT, useFmt } from '../i18n.jsx';
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
const UHRZEIT = { hour: '2-digit', minute: '2-digit' };
|
||||
|
||||
/** 24h-Verlauf als SVG-Fläche; Offline-Samples als rote Punkte auf der Nulllinie */
|
||||
function HistoryChart({ history, max }) {
|
||||
@@ -48,6 +49,8 @@ function historyStats(history) {
|
||||
}
|
||||
|
||||
function ServerCard({ s }) {
|
||||
const { t } = useT();
|
||||
const { datum } = useFmt();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const pct = s.online && s.max ? Math.round((s.players / s.max) * 100) : 0;
|
||||
const stats = historyStats(s.history);
|
||||
@@ -70,8 +73,8 @@ function ServerCard({ s }) {
|
||||
<span className="srv-name">{s.name}</span>
|
||||
<span className={`srv-status ${s.online ? 'on' : s.online === false ? 'off' : ''}`}>
|
||||
{s.online === null
|
||||
? <><IconClock size={13} /> Warte auf Check</>
|
||||
: <><span className="srv-dot" /> {s.online ? 'Online' : 'Offline'}</>}
|
||||
? <><IconClock size={13} /> {t('server.waiting')}</>
|
||||
: <><span className="srv-dot" /> {s.online ? t('server.online') : t('server.offline')}</>}
|
||||
{s.ping != null && s.online && <span className="srv-ping"> · {s.ping}ms</span>}
|
||||
{s.map && s.online && (
|
||||
<span className="srv-map"> · <IconMapPin size={13} /> {s.map}</span>
|
||||
@@ -95,9 +98,9 @@ function ServerCard({ s }) {
|
||||
{s.history.length > 1 && <HistoryChart history={s.history} max={s.max} />}
|
||||
{stats && (
|
||||
<div className="srv-stats">
|
||||
<span>Spielerzahl · letzte 24h</span>
|
||||
<span>{t('server.players24h')}</span>
|
||||
<span>
|
||||
Peak <b>{stats.peak}</b> · Uptime{' '}
|
||||
{t('server.peak')} <b>{stats.peak}</b> · {t('server.uptime')}{' '}
|
||||
<b className={stats.uptime >= 99 ? 'up-good' : stats.uptime >= 90 ? '' : 'up-bad'}>
|
||||
{stats.uptime}%
|
||||
</b>
|
||||
@@ -109,24 +112,25 @@ function ServerCard({ s }) {
|
||||
<div className="srv-connect">
|
||||
{s.connect_url && (
|
||||
<a className="btn btn-save with-icon" href={s.connect_url}>
|
||||
<IconGame size={15} /> Connect
|
||||
<IconGame size={15} /> {t('server.connect')}
|
||||
</a>
|
||||
)}
|
||||
{s.address && (
|
||||
<button className="alpha-key srv-addr" onClick={copyAddress} title="Adresse kopieren">
|
||||
{copied ? <span className="with-icon"><IconCheck size={14} /> Kopiert!</span> : s.address}
|
||||
<button className="alpha-key srv-addr" onClick={copyAddress} title={t('server.copyAddress')}>
|
||||
{copied ? <span className="with-icon"><IconCheck size={14} /> {t('server.copied')}</span> : s.address}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{s.checkedAt && (
|
||||
<p className="srv-checked">Zuletzt geprüft: {timeFmt.format(new Date(s.checkedAt))} Uhr</p>
|
||||
<p className="srv-checked">{t('server.checkedAt', datum(s.checkedAt, UHRZEIT))}</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Server() {
|
||||
const { t } = useT();
|
||||
const [servers, setServers] = useState(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
@@ -147,28 +151,26 @@ export default function Server() {
|
||||
<div className="page-bg-text">SERVER</div>
|
||||
<div className="page-header-grid" aria-hidden="true" />
|
||||
<div className="page-header-content">
|
||||
<div className="page-tag">// Live-Status</div>
|
||||
<h1 className="page-title">Server</h1>
|
||||
<p className="page-subtitle">
|
||||
Alle Game-Server auf einen Blick — aktualisiert alle 2 Minuten.
|
||||
</p>
|
||||
<div className="page-tag">{t('server.tag')}</div>
|
||||
<h1 className="page-title">{t('nav.server')}</h1>
|
||||
<p className="page-subtitle">{t('server.subtitle')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <p className="notice">Server-Status konnte nicht geladen werden.</p>}
|
||||
{error && <p className="notice">{t('server.error')}</p>}
|
||||
{!error && !servers && <SkeletonCards n={2} />}
|
||||
{servers?.length === 0 && <p className="notice">Noch keine Server eingetragen.</p>}
|
||||
{servers?.length === 0 && <p className="notice">{t('server.none')}</p>}
|
||||
|
||||
{servers?.length > 0 && (
|
||||
<div className="srv-summary">
|
||||
<div className="srv-sum-item">
|
||||
<span className="srv-sum-n">{online.length}<span className="srv-sum-of"> / {servers.length}</span></span>
|
||||
<span className="srv-sum-label">Server online</span>
|
||||
<span className="srv-sum-label">{t('server.serversOnline')}</span>
|
||||
</div>
|
||||
<div className="srv-sum-item">
|
||||
<span className="srv-sum-n">{totalPlayers}</span>
|
||||
<span className="srv-sum-label">Spieler gerade drauf</span>
|
||||
<span className="srv-sum-label">{t('server.playersNow')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1736,6 +1736,33 @@ label.toggle {
|
||||
.srv-checked { font-family: var(--mono); font-size: .62rem; color: var(--muted2); margin-top: .8rem; }
|
||||
|
||||
/* ── AUDIT-LOG ─────────────────────────────────────── */
|
||||
/* ── SPRACHUMSCHALTER ──────────────────────────────── */
|
||||
.lang-switch {
|
||||
display: inline-flex;
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-right: .6rem;
|
||||
}
|
||||
.lang-btn {
|
||||
font-family: var(--mono);
|
||||
font-size: .62rem; letter-spacing: .12em;
|
||||
padding: .3rem .55rem;
|
||||
background: transparent; border: none;
|
||||
color: var(--muted2); cursor: pointer;
|
||||
transition: color .15s ease, background .15s ease;
|
||||
}
|
||||
.lang-btn:hover { color: var(--text); }
|
||||
.lang-btn.on {
|
||||
background: color-mix(in srgb, var(--neon) 14%, transparent);
|
||||
color: var(--neon);
|
||||
}
|
||||
|
||||
/* Im Schubladen-Menü steht er allein in einer Zeile */
|
||||
.drawer-lang { padding: .6rem 0 0; }
|
||||
.drawer-lang .lang-switch { margin-right: 0; }
|
||||
.drawer-lang .lang-btn { padding: .4rem .8rem; font-size: .7rem; }
|
||||
|
||||
/* ── TEAM & PROTOKOLL ──────────────────────────────── */
|
||||
.tm-list {
|
||||
display: flex; flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user