Englisch als zweite Sprache fuer die oeffentlichen Seiten
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

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:
2026-07-31 22:41:32 +02:00
co-authored by Claude Fable 5
parent 570e45fe60
commit 5360c8229f
19 changed files with 1084 additions and 329 deletions
+33 -37
View File
@@ -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>
</>
);
+26 -39
View File
@@ -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>
+34 -42
View File
@@ -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 &amp; 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>
+17 -16
View File
@@ -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>
)}
+11 -10
View File
@@ -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 && (
+30 -31
View File
@@ -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>
)}
+16 -13
View File
@@ -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>
))}
+16 -13
View File
@@ -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
View File
@@ -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>
)}
+16 -11
View File
@@ -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>
);
})}
+29 -26
View File
@@ -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} />
))}
+20 -18
View File
@@ -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>
)}