feat: add internationalization (i18n) to web dashboard — English + Chinese (#9453)

Add a lightweight i18n system to the web dashboard with English (default) and
Chinese language support. A language switcher with flag icons is placed in the
header bar, allowing users to toggle between languages. The choice persists
to localStorage.

Implementation:
- src/i18n/ — types, translation files (en.ts, zh.ts), React context + hook
- LanguageSwitcher component shows the *other* language's flag as the toggle
- I18nProvider wraps the app in main.tsx
- All 8 pages + OAuth components updated to use t() translation calls
- Zero new dependencies — pure React context + localStorage
This commit is contained in:
Teknium 2026-04-13 23:19:13 -07:00 committed by GitHub
parent 19199cd38d
commit a2ea237db2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1715 additions and 977 deletions

View file

@ -1,5 +1,4 @@
import { useEffect, useState, useCallback } from "react";
import { formatTokenCount } from "@/lib/format";
import {
BarChart3,
Cpu,
@ -10,6 +9,7 @@ import { api } from "@/lib/api";
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/i18n";
const PERIODS = [
{ label: "7d", days: 7 },
@ -19,7 +19,11 @@ const PERIODS = [
const CHART_HEIGHT_PX = 160;
const formatTokens = formatTokenCount;
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function formatDate(day: string): string {
try {
@ -56,6 +60,7 @@ function SummaryCard({
}
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
if (daily.length === 0) return null;
const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1);
@ -65,16 +70,16 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Daily Token Usage</CardTitle>
<CardTitle className="text-base">{t.analytics.dailyTokenUsage}</CardTitle>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
Input
<div className="h-2.5 w-2.5 rounded-sm bg-[#ffe6cb]" />
{t.analytics.input}
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-emerald-500" />
Output
<div className="h-2.5 w-2.5 rounded-sm bg-emerald-500" />
{t.analytics.output}
</div>
</div>
</CardHeader>
@ -92,11 +97,11 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
<div className="rounded-md bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div>
<div>Input: {formatTokens(d.input_tokens)}</div>
<div>Output: {formatTokens(d.output_tokens)}</div>
<div>Total: {formatTokens(total)}</div>
<div>{t.analytics.input}: {formatTokens(d.input_tokens)}</div>
<div>{t.analytics.output}: {formatTokens(d.output_tokens)}</div>
<div>{t.analytics.total}: {formatTokens(total)}</div>
</div>
</div>
{/* Input bar */}
@ -127,6 +132,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
}
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
if (daily.length === 0) return null;
const sorted = [...daily].reverse();
@ -136,7 +142,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Daily Breakdown</CardTitle>
<CardTitle className="text-base">{t.analytics.dailyBreakdown}</CardTitle>
</div>
</CardHeader>
<CardContent>
@ -144,10 +150,10 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">Date</th>
<th className="text-right py-2 px-4 font-medium">Sessions</th>
<th className="text-right py-2 px-4 font-medium">Input</th>
<th className="text-right py-2 pl-4 font-medium">Output</th>
<th className="text-left py-2 pr-4 font-medium">{t.analytics.date}</th>
<th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
<th className="text-right py-2 px-4 font-medium">{t.analytics.input}</th>
<th className="text-right py-2 pl-4 font-medium">{t.analytics.output}</th>
</tr>
</thead>
<tbody>
@ -174,6 +180,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
}
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
const { t } = useI18n();
if (models.length === 0) return null;
const sorted = [...models].sort(
@ -185,7 +192,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
<CardHeader>
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Per-Model Breakdown</CardTitle>
<CardTitle className="text-base">{t.analytics.perModelBreakdown}</CardTitle>
</div>
</CardHeader>
<CardContent>
@ -193,9 +200,9 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">Model</th>
<th className="text-right py-2 px-4 font-medium">Sessions</th>
<th className="text-right py-2 pl-4 font-medium">Tokens</th>
<th className="text-left py-2 pr-4 font-medium">{t.analytics.model}</th>
<th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
<th className="text-right py-2 pl-4 font-medium">{t.analytics.tokens}</th>
</tr>
</thead>
<tbody>
@ -225,6 +232,7 @@ export default function AnalyticsPage() {
const [data, setData] = useState<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { t } = useI18n();
const load = useCallback(() => {
setLoading(true);
@ -244,7 +252,7 @@ export default function AnalyticsPage() {
<div className="flex flex-col gap-6">
{/* Period selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground font-medium">Period:</span>
<span className="text-sm text-muted-foreground font-medium">{t.analytics.period}</span>
{PERIODS.map((p) => (
<Button
key={p.label}
@ -278,21 +286,21 @@ export default function AnalyticsPage() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SummaryCard
icon={Hash}
label="Total Tokens"
label={t.analytics.totalTokens}
value={formatTokens(data.totals.total_input + data.totals.total_output)}
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`}
sub={t.analytics.inOut.replace("{input}", formatTokens(data.totals.total_input)).replace("{output}", formatTokens(data.totals.total_output))}
/>
<SummaryCard
icon={BarChart3}
label="Total Sessions"
label={t.analytics.totalSessions}
value={String(data.totals.total_sessions)}
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`}
sub={`~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg}`}
/>
<SummaryCard
icon={TrendingUp}
label="API Calls"
label={t.analytics.apiCalls}
value={String(data.daily.reduce((sum, d) => sum + d.sessions, 0))}
sub={`across ${data.by_model.length} models`}
sub={t.analytics.acrossModels.replace("{count}", String(data.by_model.length))}
/>
</div>
@ -310,8 +318,8 @@ export default function AnalyticsPage() {
<CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">No usage data for this period</p>
<p className="text-xs mt-1 text-muted-foreground/60">Start a session to see analytics here</p>
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
<p className="text-xs mt-1 text-muted-foreground/60">{t.analytics.startSession}</p>
</div>
</CardContent>
</Card>