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>
120 lines
5.3 KiB
React
120 lines
5.3 KiB
React
import { useEffect, useState } from 'react';
|
||
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) {
|
||
let total = 0;
|
||
for (let n = 0; n < level; n++) total += 5 * n * n + 50 * n + 100;
|
||
return total;
|
||
}
|
||
|
||
// Platz 1–3 bekommen einen Pokal in Gold/Silber/Bronze, danach schlicht die Nummer
|
||
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--) {
|
||
const key = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10);
|
||
days.push({ key, ...(byDay.get(key) ?? { messages: 0, joins: 0, leaves: 0 }) });
|
||
}
|
||
const max = Math.max(1, ...days.map((d) => d.messages));
|
||
const total = days.reduce((s, d) => s + d.messages, 0);
|
||
return (
|
||
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||
<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={t('level.barTitle', d.key, d.messages, d.joins, d.leaves)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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);
|
||
|
||
useEffect(() => {
|
||
apiGet('/api/levels').then((d) => setRows(d.levels)).catch(() => setError(true));
|
||
apiGet('/api/serverstats').then(setStats).catch(() => {});
|
||
window.scrollTo(0, 0);
|
||
}, []);
|
||
|
||
return (
|
||
<>
|
||
<section className="page-header">
|
||
<div className="page-bg-text">LEVEL</div>
|
||
<div className="page-header-grid" aria-hidden="true" />
|
||
<div className="page-header-content">
|
||
<div className="page-tag">{t('level.tag')}</div>
|
||
<h1 className="page-title">{t('nav.level')}</h1>
|
||
<p className="page-subtitle">
|
||
{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">{t('level.error')}</p>}
|
||
{!error && !rows && <SkeletonRows n={10} />}
|
||
{rows?.length === 0 && (
|
||
<EmptyState
|
||
Icon={IconLevel}
|
||
title={t('level.emptyTitle')}
|
||
text={t('level.emptyText')}
|
||
actions={[{ to: '/devlogs', label: t('common.readDevlogs') }]}
|
||
/>
|
||
)}
|
||
|
||
{rows?.length > 0 && (
|
||
<div className="lvl-list">
|
||
{rows.map((r, i) => {
|
||
const base = xpForLevel(r.level);
|
||
const next = xpForLevel(r.level + 1);
|
||
const pct = Math.floor(((r.xp - base) / (next - base)) * 100);
|
||
const name = r.display_name || r.username || r.user_id;
|
||
return (
|
||
<div className={`lvl-row${i < 3 ? ` top top-${i + 1}` : ''}`} key={r.user_id}>
|
||
<span className="lvl-rank">
|
||
{i < 3 && <IconTrophy size={15} style={{ color: MEDAL_COLORS[i] }} />}
|
||
<span className="lvl-rank-n">{i + 1}</span>
|
||
</span>
|
||
{/* Ohne Bild bleibt der Anfangsbuchstabe — kein leeres Loch */}
|
||
{r.avatar
|
||
? <img className="lvl-avatar" src={r.avatar} alt="" loading="lazy" />
|
||
: <span className="lvl-avatar lvl-avatar-empty">{name.slice(0, 1).toUpperCase()}</span>}
|
||
<span className="lvl-name">{name}</span>
|
||
<div className="lvl-progress">
|
||
<div className="progress"><div className="progress-bar" style={{ width: `${pct}%` }} /></div>
|
||
</div>
|
||
<span className="lvl-level">{t('level.lv', r.level)}</span>
|
||
<span className="lvl-xp">{zahl(r.xp)} XP</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{stats && <ActivityChart stats={stats} />}
|
||
</section>
|
||
</>
|
||
);
|
||
}
|