Files
d4rkbot/frontend/src/pages/Level.jsx
T
D4rkst3randClaude Fable 5 5360c8229f
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
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>
2026-07-31 22:41:32 +02:00

120 lines
5.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 13 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>
</>
);
}