Skip to content

Commit 9fc17c1

Browse files
Vonngclaude
andcommitted
feat(i18n): add hand-rolled EN/ZH core, dictionaries, and language toggle
Zero-dependency i18n for the embedded console: English source strings are the dictionary keys, missing keys fall back to English, and the language preference mirrors the dark-mode pattern (localStorage + systemSlice). - i18n/lang.ts: pure primitives (translate, localizeUrl) kept free of store imports to avoid a circular dependency with systemSlice - i18n/index.tsx: useT/useLanguage/useLocalizedLink hooks + interpolate() for paragraphs that mix text with links - dictionaries: zh (chrome), zhHelp (help topics), zhScreens (screens) - 文/A stroke-drawn toggle icon + LanguageActivator, mounted in PageHeaderWrapper (all pages) and reused on the login page - localizeUrl: silo.pgsty.com gains a /zh prefix in Chinese; the Pigsty site swaps domains (pigsty.io EN ↔ pigsty.cc ZH) - StyleHandler syncs <html lang>; index.css carries CJK letter-spacing fixes and zh-scoped swaps for mds-hardcoded "Sign Out" / "Actions:" Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fa11576 commit 9fc17c1

13 files changed

Lines changed: 2563 additions & 3 deletions

File tree

web-app/src/StyleHandler.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
// You should have received a copy of the GNU Affero General Public License
1515
// along with this program. If not, see <http://www.gnu.org/licenses/>.
1616

17-
import React, { Fragment } from "react";
17+
import React, { Fragment, useEffect } from "react";
1818
import { GlobalStyles, ThemeHandler } from "mds";
1919
import { ThemeProvider } from "styled-components";
2020
import merge from "lodash/merge";
@@ -38,6 +38,13 @@ const StyleHandler = ({ children }: IStyleHandler) => {
3838
(state: AppState) => state.system.overrideStyles,
3939
);
4040
const darkMode = useSelector((state: AppState) => state.system.darkMode);
41+
const language = useSelector((state: AppState) => state.system.language);
42+
43+
// Keep <html lang> in sync for accessibility and the zh CSS overrides in
44+
// index.css; this wraps both the login page and the console.
45+
useEffect(() => {
46+
document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
47+
}, [language]);
4148

4249
let thm = undefined;
4350

web-app/src/i18n/index.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 Pigsty
2+
//
3+
// This program is free software: you can redistribute it and/or modify
4+
// it under the terms of the GNU Affero General Public License as published by
5+
// the Free Software Foundation, either version 3 of the License, or
6+
// (at your option) any later version.
7+
8+
// React bindings for the i18n primitives in ./lang. Components subscribe to
9+
// the language through Redux, so a toggle re-renders every consumer.
10+
11+
import React, { Fragment, useMemo } from "react";
12+
import { useSelector } from "react-redux";
13+
import { AppState } from "../store";
14+
import { Lang, localizeUrl, translate } from "./lang";
15+
16+
export const useLanguage = (): Lang =>
17+
useSelector((state: AppState) => state.system.language);
18+
19+
export const useT = (): ((text: string) => string) => {
20+
const lang = useLanguage();
21+
return useMemo(() => (text: string) => translate(lang, text), [lang]);
22+
};
23+
24+
export const useLocalizedLink = (): ((url: string) => string) => {
25+
const lang = useLanguage();
26+
return useMemo(() => (url: string) => localizeUrl(url, lang), [lang]);
27+
};
28+
29+
// Renders a translated template with React nodes in {slot} positions, so
30+
// paragraphs that mix text and links stay one dictionary entry:
31+
// interpolate(t("Maintained by {pigsty}."), { pigsty: <a …>Pigsty</a> })
32+
export const interpolate = (
33+
template: string,
34+
slots: Record<string, React.ReactNode>,
35+
): React.ReactNode[] =>
36+
template.split(/(\{\w+\})/g).map((part, index) => {
37+
const match = /^\{(\w+)\}$/.exec(part);
38+
return (
39+
<Fragment key={index}>
40+
{match ? (slots[match[1]] ?? part) : part}
41+
</Fragment>
42+
);
43+
});
44+
45+
export * from "./lang";

web-app/src/i18n/lang.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 Pigsty
2+
//
3+
// This program is free software: you can redistribute it and/or modify
4+
// it under the terms of the GNU Affero General Public License as published by
5+
// the Free Software Foundation, either version 3 of the License, or
6+
// (at your option) any later version.
7+
8+
// Pure i18n primitives. This module must stay free of store/react imports:
9+
// systemSlice initializes its state from here, so importing the store back
10+
// would create a circular dependency.
11+
12+
import { zh } from "./zh";
13+
import { zhHelp } from "./zhHelp";
14+
import { zhScreens } from "./zhScreens";
15+
16+
export type Lang = "en" | "zh";
17+
18+
export const DEFAULT_LANG: Lang = "en";
19+
20+
const LANGUAGE_STORAGE_KEY = "language";
21+
22+
// Default is always English by design — no browser-language detection.
23+
export const getStoredLanguage = (): Lang =>
24+
localStorage.getItem(LANGUAGE_STORAGE_KEY) === "zh" ? "zh" : DEFAULT_LANG;
25+
26+
export const storeLanguage = (lang: Lang) => {
27+
localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
28+
};
29+
30+
// Precedence on duplicate keys: curated chrome (zh) > feature screens
31+
// (zhScreens) > help topics (zhHelp).
32+
const dictionaries: Record<Lang, Record<string, string> | null> = {
33+
en: null,
34+
zh: { ...zhHelp, ...zhScreens, ...zh },
35+
};
36+
37+
// Escape hatch for the rare case where one English string needs two different
38+
// translations: call t("Word@context") and add that exact key to the
39+
// dictionary. The suffix is stripped before falling back, so English UI never
40+
// shows it. Only bare lowercase suffixes are treated as context markers —
41+
// emails and the like are left untouched.
42+
const CONTEXT_SUFFIX = /@[a-z]+$/;
43+
44+
export const translate = (lang: Lang, text: string): string => {
45+
const dictionary = dictionaries[lang];
46+
if (dictionary) {
47+
const hit = dictionary[text];
48+
if (hit !== undefined) {
49+
return hit;
50+
}
51+
}
52+
return text.includes("@") ? text.replace(CONTEXT_SUFFIX, "") : text;
53+
};
54+
55+
// Site link localization rules (confirmed against the live sites):
56+
// - silo.pgsty.com serves Chinese under a /zh path prefix.
57+
// - The Pigsty site is a domain pair with no prefix: pigsty.io (EN) and
58+
// pigsty.cc (ZH).
59+
// Anything else (GitHub, min.io, AWS, YouTube, …) has no Chinese mirror and is
60+
// returned untouched, as are relative or malformed URLs.
61+
export const localizeUrl = (url: string, lang: Lang): string => {
62+
if (lang !== "zh") {
63+
return url;
64+
}
65+
try {
66+
const parsed = new URL(url);
67+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
68+
return url;
69+
}
70+
if (parsed.hostname === "pigsty.io") {
71+
parsed.hostname = "pigsty.cc";
72+
return parsed.toString();
73+
}
74+
if (parsed.hostname === "silo.pgsty.com") {
75+
if (parsed.pathname === "/zh" || parsed.pathname.startsWith("/zh/")) {
76+
return url;
77+
}
78+
parsed.pathname =
79+
parsed.pathname === "/" ? "/zh/" : `/zh${parsed.pathname}`;
80+
return parsed.toString();
81+
}
82+
return url;
83+
} catch {
84+
return url;
85+
}
86+
};

web-app/src/i18n/zh.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) 2026 Pigsty
2+
//
3+
// This program is free software: you can redistribute it and/or modify
4+
// it under the terms of the GNU Affero General Public License as published by
5+
// the Free Software Foundation, either version 3 of the License, or
6+
// (at your option) any later version.
7+
8+
// Chinese dictionary for console chrome. Keys are the exact English source
9+
// strings — including any odd whitespace. In particular "Logs " (menu entry in
10+
// valid-routes.tsx) carries a trailing space; never trim keys here.
11+
// Untranslated strings simply fall back to English at runtime.
12+
13+
export const zh: Record<string, string> = {
14+
// ---- Login page: brand panel -------------------------------------------
15+
"Open-Source S3/MinIO-Compatible Object Storage":
16+
"开源的 S3/MinIO 兼容对象存储",
17+
"Keep the {s3}": "保留 {s3}",
18+
"S3 Interface": "S3 接口",
19+
"Own the {store}": "掌控 {store}",
20+
"Object Store": "对象存储",
21+
"Community maintained {minio}, forked by {pigsty} · {agpl}":
22+
"社区维护的 {minio},由 {pigsty} 分叉维护 · {agpl}",
23+
"A high-performance object store that runs on any infrastructure — public cloud, private cloud, or bare metal. It powers data lakes, AI/ML, and fast backup & recovery, with erasure coding, encryption, and replication built in.":
24+
"一个可以运行在任何基础设施上的高性能对象存储——公有云、私有云或裸金属均可。内建纠删码、加密与复制能力,为数据湖、AI/ML 以及快速备份恢复提供支撑。",
25+
"MinIO® is a registered trademark of {minioInc}; SILO incorporates {minioSource}.":
26+
"MinIO® 是 {minioInc} 的注册商标;SILO 包含 {minioSource}。",
27+
"MinIO source code": "MinIO 源代码",
28+
"SILO is maintained by {pigsty}, without MinIO affiliation, endorsement, or sponsorship.":
29+
"SILO 由 {pigsty} 维护,与 MinIO 无隶属、背书或赞助关系。",
30+
"SILO website": "SILO 官网",
31+
"Object Storage Console": "对象存储控制台",
32+
33+
// ---- Login page: form, footer, errors ----------------------------------
34+
"Use Credentials": "使用凭证",
35+
"Use STS": "使用 STS",
36+
"Login with SSO": "使用 SSO 登录",
37+
"LDAP Authentication": "LDAP 认证",
38+
"STS Username": "STS 用户名",
39+
Username: "用户名",
40+
"STS Secret": "STS 密钥",
41+
Password: "密码",
42+
"STS Token": "STS 令牌",
43+
Login: "登录",
44+
"Other Authentication Methods": "其他认证方式",
45+
"An error has occurred": "发生错误",
46+
"The backend cannot be reached.": "无法连接到后端服务。",
47+
Retry: "重试",
48+
Documentation: "文档",
49+
Download: "下载",
50+
"Error from IDP": "IDP 返回错误",
51+
"Back to Login": "返回登录",
52+
53+
// ---- Navigation menu ----------------------------------------------------
54+
Administrator: "管理",
55+
User: "用户",
56+
Buckets: "存储桶",
57+
Policies: "策略",
58+
Identity: "身份",
59+
Users: "用户",
60+
Groups: "用户组",
61+
Monitoring: "监控",
62+
Metrics: "指标",
63+
"Logs ": "日志",
64+
Logs: "日志",
65+
Audit: "审计",
66+
Trace: "跟踪",
67+
Watch: "监视",
68+
Encryption: "加密",
69+
Events: "事件",
70+
Tiering: "分层",
71+
"Site Replication": "站点复制",
72+
Configuration: "配置",
73+
Tools: "工具",
74+
Health: "健康报告",
75+
Performance: "吞吐测试",
76+
Profile: "性能剖析",
77+
Inspect: "对象检查",
78+
"Create Bucket": "创建存储桶",
79+
"Object Browser": "对象浏览器",
80+
"Access Keys": "访问密钥",
81+
"Collapse menu": "折叠菜单",
82+
"Expand menu": "展开菜单",
83+
License: "许可证",
84+
85+
// ---- Command palette (kbar) ---------------------------------------------
86+
Navigation: "导航",
87+
"List of Buckets": "存储桶列表",
88+
89+
// ---- Help menu chrome ---------------------------------------------------
90+
Video: "视频",
91+
Blog: "博客",
92+
"SILO Blog": "SILO 博客",
93+
"Read SILO release notes, security advisories, and project updates.":
94+
"阅读 SILO 的版本发布说明、安全公告与项目动态。",
95+
"Read this update on the SILO Blog.": "在 SILO 博客阅读这篇更新。",
96+
"SILO does not yet maintain a native video catalog. These links open upstream MinIO compatibility material.":
97+
"SILO 尚未维护自有的视频目录,以下链接指向上游 MinIO 的兼容性资料。",
98+
"Visit SILO Documentation": "访问 SILO 文档",
99+
"Visit MinIO Videos (upstream)": "访问 MinIO 视频(上游)",
100+
"Visit SILO Blog": "访问 SILO 博客",
101+
"Learn more": "了解更多",
102+
"Open help": "打开帮助",
103+
"Close help": "关闭帮助",
104+
105+
// ---- Page titles (via PageHeaderWrapper) --------------------------------
106+
Components: "组件",
107+
"Audit Logs": "审计日志",
108+
"Event Destinations": "事件目标",
109+
"IAM Policies": "IAM 策略",
110+
"Key Management Service": "密钥管理服务",
111+
"Key Management Service Keys": "密钥管理服务密钥",
112+
Tiers: "存储层",
113+
"OPENID Configurations": "OPENID 配置",
114+
"LDAP Configurations": "LDAP 配置",
115+
116+
// ---- Dashboard: tabs and chrome -----------------------------------------
117+
Info: "信息",
118+
Usage: "用量",
119+
Traffic: "流量",
120+
Resources: "资源",
121+
"Server Information": "服务器信息",
122+
Sync: "同步",
123+
Advanced: "高级",
124+
"We can’t retrieve advanced metrics at this time.": "目前无法获取高级指标。",
125+
"It looks like Prometheus is not available or reachable at the moment.":
126+
"Prometheus 目前似乎不可用或无法访问。",
127+
"Console Dashboard will display basic metrics as we couldn’t connect to Prometheus successfully. Please try again in a few minutes. If the problem persists, you can review your configuration and confirm that Prometheus server is up and running.":
128+
"由于未能成功连接 Prometheus,控制台仪表盘将只显示基础指标。请过几分钟再试;若问题持续,请检查相关配置并确认 Prometheus 服务正常运行。",
129+
"Read more about Prometheus on the Docs site.":
130+
"在文档站阅读更多关于 Prometheus 的内容。",
131+
132+
// ---- Dashboard: Info tab cards ------------------------------------------
133+
Objects: "对象",
134+
Browse: "浏览",
135+
Servers: "服务器",
136+
Drives: "磁盘",
137+
Online: "在线",
138+
Offline: "离线",
139+
"Reported Usage": "报告用量",
140+
"Time since last": "距上次",
141+
"Heal Activity": "修复活动",
142+
"Scan Activity": "扫描活动",
143+
Uptime: "运行时间",
144+
"Up time": "运行时间",
145+
"Backend type": "后端类型",
146+
"Standard storage class parity": "标准存储类奇偶校验",
147+
"Reduced redundancy storage class parity": "低冗余存储类奇偶校验",
148+
"Online Drive": "在线磁盘",
149+
"Offline Drive": "离线磁盘",
150+
Unknown: "未知",
151+
"Drive Name": "磁盘名称",
152+
"Drive Status": "磁盘状态",
153+
"Version:": "版本:",
154+
"Used Capacity": "已用容量",
155+
Capacity: "容量",
156+
"Used:": "已用:",
157+
"Of:": "共:",
158+
Free: "空闲",
159+
Network: "网络",
160+
161+
// ---- Dashboard: Prometheus panel titles ---------------------------------
162+
"Usable Capacity": "可用容量",
163+
"Data Usage Growth": "数据用量增长",
164+
"Object size distribution": "对象大小分布",
165+
"API Data Received Rate": "API 数据接收速率",
166+
"API Data Sent Rate": "API 数据发送速率",
167+
"API Request Rate": "API 请求速率",
168+
"API Request Error Rate": "API 请求错误率",
169+
"Total Open FDs": "打开文件描述符总数",
170+
"Total Goroutines": "Goroutine 总数",
171+
"Node CPU Usage": "节点 CPU 使用率",
172+
"Node Memory Usage": "节点内存使用",
173+
"Node IO": "节点 IO",
174+
"Node Syscalls": "节点系统调用",
175+
"Node File Descriptors": "节点文件描述符",
176+
"Internode Data Transfer": "节点间数据传输",
177+
"Time Since Last Heal Activity": "距上次修复活动时间",
178+
"Time Since Last Scan Activity": "距上次扫描活动时间",
179+
"Drive Used Capacity": "磁盘已用容量",
180+
"Drives Free Inodes": "磁盘空闲 Inode",
181+
Upload: "上传",
182+
183+
// ---- Common buttons and dialogs -----------------------------------------
184+
Cancel: "取消",
185+
Confirm: "确认",
186+
Delete: "删除",
187+
Save: "保存",
188+
Close: "关闭",
189+
Create: "创建",
190+
Update: "更新",
191+
Restore: "恢复",
192+
"Yes, Reset Configuration": "是的,重置配置",
193+
194+
// ---- Aria labels ---------------------------------------------------------
195+
"Switch to light mode": "切换到亮色模式",
196+
"Switch to dark mode": "切换到暗色模式",
197+
};
198+
199+
export default zh;

0 commit comments

Comments
 (0)