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,28 +1,13 @@
import { useEffect, useState, useMemo } from "react";
import {
Blocks,
Bot,
BrainCircuit,
ChevronRight,
Code,
Database,
FileCode,
FileSearch,
Globe,
Image,
LayoutDashboard,
Monitor,
Package,
Paintbrush,
Search,
Server,
Shield,
Sparkles,
Terminal,
Wrench,
ChevronDown,
ChevronRight,
Filter,
X,
} from "lucide-react";
import type { ComponentType } from "react";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
@ -31,11 +16,19 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/i18n";
/* ------------------------------------------------------------------ */
/* Types & helpers */
/* ------------------------------------------------------------------ */
interface CategoryGroup {
name: string; // display name
key: string; // raw key (or "__none__")
skills: SkillInfo[];
enabledCount: number;
}
const CATEGORY_LABELS: Record<string, string> = {
mlops: "MLOps",
"mlops/cloud": "MLOps / Cloud",
@ -53,8 +46,8 @@ const CATEGORY_LABELS: Record<string, string> = {
ui: "UI",
};
function prettyCategory(raw: string | null | undefined): string {
if (!raw) return "General";
function prettyCategory(raw: string | null | undefined, generalLabel: string): string {
if (!raw) return generalLabel;
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
return raw
.split(/[-_/]/)
@ -62,63 +55,31 @@ function prettyCategory(raw: string | null | undefined): string {
.join(" ");
}
const TOOLSET_ICONS: Record<string, ComponentType<{ className?: string }>> = {
terminal: Terminal,
shell: Terminal,
browser: Globe,
web: Globe,
code: Code,
coding: Code,
python: FileCode,
files: FileSearch,
file: FileSearch,
search: Search,
image: Image,
vision: Image,
memory: BrainCircuit,
database: Database,
db: Database,
mcp: Blocks,
ai: Sparkles,
agent: Bot,
security: Shield,
server: Server,
deploy: Server,
ui: Paintbrush,
ux: LayoutDashboard,
display: Monitor,
};
function toolsetIcon(name: string, label: string): ComponentType<{ className?: string }> {
const lower = name.toLowerCase();
if (TOOLSET_ICONS[lower]) return TOOLSET_ICONS[lower];
for (const [key, icon] of Object.entries(TOOLSET_ICONS)) {
if (lower.includes(key) || label.toLowerCase().includes(key)) return icon;
}
return Wrench;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function SkillsPage() {
const [view, setView] = useState<"skills" | "toolsets">("skills");
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
// Start collapsed by default
const [collapsedCategories, setCollapsedCategories] = useState<Set<string> | "all">("all");
const { toast, showToast } = useToast();
const { t } = useI18n();
useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()])
.then(([s, t]) => {
.then(([s, tsets]) => {
setSkills(s);
setToolsets(t);
setToolsets(tsets);
})
.catch(() => showToast("Failed to load skills/toolsets", "error"))
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
}, []);
@ -133,11 +94,11 @@ export default function SkillsPage() {
)
);
showToast(
`${skill.name} ${skill.enabled ? "disabled" : "enabled"}`,
`${skill.name} ${skill.enabled ? t.common.disabled : t.common.enabled}`,
"success"
);
} catch {
showToast(`Failed to toggle ${skill.name}`, "error");
showToast(`${t.common.failedToToggle} ${skill.name}`, "error");
} finally {
setTogglingSkills((prev) => {
const next = new Set(prev);
@ -164,6 +125,27 @@ export default function SkillsPage() {
});
}, [skills, search, lowerSearch, activeCategory]);
const categoryGroups: CategoryGroup[] = useMemo(() => {
const map = new Map<string, SkillInfo[]>();
for (const s of filteredSkills) {
const key = s.category || "__none__";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
}
// Sort: General first, then alphabetical
const entries = [...map.entries()].sort((a, b) => {
if (a[0] === "__none__") return -1;
if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]);
});
return entries.map(([key, list]) => ({
key,
name: prettyCategory(key === "__none__" ? null : key, t.common.general),
skills: list.sort((a, b) => a.name.localeCompare(b.name)),
enabledCount: list.filter((s) => s.enabled).length,
}));
}, [filteredSkills]);
const allCategories = useMemo(() => {
const cats = new Map<string, number>();
for (const s of skills) {
@ -176,39 +158,40 @@ export default function SkillsPage() {
if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]);
})
.map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key), count }));
.map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key, t.common.general), count }));
}, [skills]);
const enabledCount = skills.filter((s) => s.enabled).length;
const filteredToolsets = useMemo(() => {
return toolsets.filter(
(t) =>
(ts) =>
!search ||
t.name.toLowerCase().includes(lowerSearch) ||
t.label.toLowerCase().includes(lowerSearch) ||
t.description.toLowerCase().includes(lowerSearch)
ts.name.toLowerCase().includes(lowerSearch) ||
ts.label.toLowerCase().includes(lowerSearch) ||
ts.description.toLowerCase().includes(lowerSearch)
);
}, [toolsets, search, lowerSearch]);
const isSearching = search.trim().length > 0;
const isCollapsed = (key: string): boolean => {
if (collapsedCategories === "all") return true;
return collapsedCategories.has(key);
};
const activeToolsetCount = toolsets.filter((t) => t.enabled).length;
const searchMatchedSkills = useMemo(() => {
if (!isSearching) return [];
return skills.filter(
(s) =>
s.name.toLowerCase().includes(lowerSearch) ||
s.description.toLowerCase().includes(lowerSearch) ||
(s.category ?? "").toLowerCase().includes(lowerSearch),
);
}, [isSearching, skills, lowerSearch]);
const activeSkills = useMemo(() => {
if (isSearching) return [];
return [...filteredSkills].sort((a, b) => a.name.localeCompare(b.name));
}, [isSearching, filteredSkills]);
const toggleCollapse = (key: string) => {
setCollapsedCategories((prev) => {
if (prev === "all") {
// Switching from "all collapsed" → expand just this one
const allKeys = new Set(categoryGroups.map((g) => g.key));
allKeys.delete(key);
return allKeys;
}
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
/* ---- Loading ---- */
if (loading) {
@ -219,303 +202,240 @@ export default function SkillsPage() {
);
}
const activeCategoryName = activeCategory
? prettyCategory(activeCategory === "__none__" ? null : activeCategory)
: "All Skills";
const renderSkillList = (list: SkillInfo[]) => (
<div className="grid gap-1">
{list.map((skill) => (
<div
key={skill.name}
className="group flex items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="pt-0.5 shrink-0">
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill)}
disabled={togglingSkills.has(skill.name)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`font-mono-ui text-sm ${
skill.enabled ? "text-foreground" : "text-muted-foreground"
}`}
>
{skill.name}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{skill.description || "No description available."}
</p>
</div>
</div>
))}
</div>
);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-6">
<Toast toast={toast} />
{/* ═══════════════ Header ═══════════════ */}
<div className="flex items-center gap-3">
{view === "skills" ? (
<Package className="h-4 w-4 text-muted-foreground" />
) : (
<Wrench className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground">
{view === "skills"
? `${enabledCount}/${skills.length} skills enabled`
: `${activeToolsetCount}/${toolsets.length} toolsets active`}
</span>
{/* ═══════════════ Header + Search ═══════════════ */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">{t.skills.title}</h1>
<span className="text-xs text-muted-foreground">
{t.skills.enabledOf.replace("{enabled}", String(enabledCount)).replace("{total}", String(skills.length))}
</span>
</div>
</div>
{/* ═══════════════ Sidebar + Content ═══════════════ */}
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
{/* ---- Sidebar ---- */}
<div className="sm:w-52 sm:shrink-0">
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
{/* Search */}
<div className="relative mb-2 hidden sm:block">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="pl-8 h-8 text-xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Nav items */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
{/* Skills top-level */}
<button
type="button"
onClick={() => {
setView("skills");
setActiveCategory(null);
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "skills" && !activeCategory && !isSearching
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Package className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">All Skills</span>
<span className={`text-[10px] tabular-nums ${
view === "skills" && !activeCategory && !isSearching
? "text-primary/60"
: "text-muted-foreground/50"
}`}>
{skills.length}
</span>
{view === "skills" && !activeCategory && !isSearching && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
{/* Skill category sub-items */}
{allCategories.map(({ key, name, count }) => {
const isActive = view === "skills" && activeCategory === key && !isSearching;
return (
<button
key={key}
type="button"
onClick={() => {
setView("skills");
setActiveCategory(key);
setSearch("");
}}
className={`group flex items-center gap-2 sm:pl-6 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="flex-1 truncate">{name}</span>
<span className={`text-[10px] tabular-nums ${
isActive ? "text-primary/60" : "text-muted-foreground/50"
}`}>
{count}
</span>
{isActive && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
);
})}
{/* Divider */}
<div className="hidden sm:block border-t border-border my-1" />
{/* Toolsets top-level */}
<button
type="button"
onClick={() => {
setView("toolsets");
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "toolsets" && !isSearching
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Wrench className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">Toolsets</span>
<span className={`text-[10px] tabular-nums ${
view === "toolsets" && !isSearching
? "text-primary/60"
: "text-muted-foreground/50"
}`}>
{toolsets.length}
</span>
{view === "toolsets" && !isSearching && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
</div>
</div>
</div>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
{/* Search results (across both skills and toolsets) */}
{isSearching ? (
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Search className="h-4 w-4" />
Search Results
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{searchMatchedSkills.length} skill{searchMatchedSkills.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{searchMatchedSkills.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No skills match &ldquo;<span className="text-foreground">{search}</span>&rdquo;
</p>
) : (
renderSkillList(searchMatchedSkills)
)}
</CardContent>
</Card>
) : view === "skills" ? (
/* ---- Skills view ---- */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Package className="h-4 w-4" />
{activeCategoryName}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{activeSkills.length} skill{activeSkills.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{activeSkills.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{skills.length === 0
? "No skills found. Skills are loaded from ~/.hermes/skills/"
: "No skills in this category."}
</p>
) : (
renderSkillList(activeSkills)
)}
</CardContent>
</Card>
) : (
/* ---- Toolsets view ---- */
<>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No toolsets found.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredToolsets.map((ts) => {
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
const TsIcon = toolsetIcon(ts.name, ts.label);
return (
<Card key={ts.name}>
<CardContent className="py-4">
<div className="flex items-start gap-3">
<TsIcon className="h-5 w-5 shrink-0 mt-0.5 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{labelText}</span>
<Badge
variant={ts.enabled ? "success" : "outline"}
className="text-[10px]"
>
{ts.enabled ? "active" : "inactive"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2">
Setup needed
</p>
)}
{ts.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{ts.tools.map((tool) => (
<Badge
key={tool}
variant="secondary"
className="text-[10px] font-mono"
>
{tool}
</Badge>
))}
</div>
)}
{ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60">
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</>
{/* ═══════════════ Search + Category Filter ═══════════════ */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder={t.skills.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Category pills */}
{allCategories.length > 1 && (
<div className="flex items-center gap-2 flex-wrap">
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<button
type="button"
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
!activeCategory
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() => setActiveCategory(null)}
>
{t.skills.all} ({skills.length})
</button>
{allCategories.map(({ key, name, count }) => (
<button
key={key}
type="button"
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
activeCategory === key
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() =>
setActiveCategory(activeCategory === key ? null : key)
}
>
{name}
<span className="ml-1 opacity-60">{count}</span>
</button>
))}
</div>
)}
{/* ═══════════════ Skills by Category ═══════════════ */}
<section className="flex flex-col gap-3">
{filteredSkills.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
{skills.length === 0
? t.skills.noSkills
: t.skills.noSkillsMatch}
</CardContent>
</Card>
) : (
categoryGroups.map(({ key, name, skills: catSkills, enabledCount: catEnabled }) => {
const collapsed = isCollapsed(key);
return (
<Card key={key}>
<CardHeader
className="cursor-pointer select-none py-3 px-4"
onClick={() => toggleCollapse(key)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{collapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<CardTitle className="text-sm font-medium">{name}</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{t.skills.skillCount.replace("{count}", String(catSkills.length)).replace("{s}", catSkills.length !== 1 ? "s" : "")}
</Badge>
</div>
<Badge
variant={catEnabled === catSkills.length ? "success" : "outline"}
className="text-[10px]"
>
{t.skills.enabledOf.replace("{enabled}", String(catEnabled)).replace("{total}", String(catSkills.length))}
</Badge>
</div>
</CardHeader>
{collapsed ? (
/* Peek: show first few skill names so collapsed isn't blank */
<div className="px-4 pb-3 flex items-center min-h-[28px]">
<p className="text-xs text-muted-foreground/60 truncate leading-normal">
{catSkills.slice(0, 4).map((s) => s.name).join(", ")}
{catSkills.length > 4 && `, ${t.skills.more.replace("{count}", String(catSkills.length - 4))}`}
</p>
</div>
) : (
<CardContent className="pt-0 px-4 pb-3">
<div className="grid gap-1">
{catSkills.map((skill) => (
<div
key={skill.name}
className="group flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="pt-0.5 shrink-0">
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill)}
disabled={togglingSkills.has(skill.name)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`font-mono-ui text-sm ${
skill.enabled
? "text-foreground"
: "text-muted-foreground"
}`}
>
{skill.name}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{skill.description || t.skills.noDescription}
</p>
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
);
})
)}
</section>
{/* ═══════════════ Toolsets ═══════════════ */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Wrench className="h-4 w-4" />
{t.skills.toolsets} ({filteredToolsets.length})
</h2>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.skills.noToolsetsMatch}
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredToolsets.map((ts) => {
// Strip emoji prefix from label for cleaner display
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
const emoji = ts.label.match(/^[\p{Emoji}]+/u)?.[0] || "🔧";
return (
<Card key={ts.name} className="relative overflow-hidden">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<div className="text-2xl shrink-0 leading-none mt-0.5">{emoji}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{labelText}</span>
<Badge
variant={ts.enabled ? "success" : "outline"}
className="text-[10px]"
>
{ts.enabled ? t.common.active : t.common.inactive}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2">
{t.skills.setupNeeded}
</p>
)}
{ts.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{ts.tools.map((tool) => (
<Badge
key={tool}
variant="secondary"
className="text-[10px] font-mono"
>
{tool}
</Badge>
))}
</div>
)}
{ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60">
{ts.enabled ? `${ts.name} toolset` : t.skills.disabledForCli}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</section>
</div>
);
}