Compare commits

...

3 commits

Author SHA1 Message Date
CDN
9ac43ef4f9
feat: migrate language code
All checks were successful
Deploy / Deploy (push) Successful in 1m15s
2025-02-03 22:52:56 +08:00
CDN
47cf6171c5
feat: translations integrity validation
closes #7
2025-02-03 22:22:07 +08:00
CDN
25bd99abc5
fix: the loading string updates page does not supports dark mode 2025-02-03 21:21:06 +08:00
94 changed files with 412 additions and 144 deletions

View file

@ -46,7 +46,7 @@ homepage
## Contribution Guide ## Contribution Guide
See [CONTRIBUTING.md](docs/en-US/CONTRIBUTING.md) in the `docs` directory to learn how to participate in the project. See [CONTRIBUTING.md](docs/en/CONTRIBUTING.md) in the `docs` directory to learn how to participate in the project.
## License ## License

View file

@ -46,7 +46,7 @@ homepage
## 贡献指南 ## 贡献指南
参见 `docs` 目录中的 [CONTRIBUTING.md](docs/zh-CN/CONTRIBUTING.md) 了解如何参与项目。 参见 `docs` 目录中的 [CONTRIBUTING.md](docs/zh-Hans/CONTRIBUTING.md) 了解如何参与项目。
## 许可 ## 许可

View file

@ -46,7 +46,7 @@ homepage
## 貢獻指南 ## 貢獻指南
參見 `docs` 目錄中的 [CONTRIBUTING.md](docs/zh-TW/CONTRIBUTING.md) 了解如何參與專案。 參見 `docs` 目錄中的 [CONTRIBUTING.md](docs/zh-Hant/CONTRIBUTING.md) 了解如何參與專案。
## 許可 ## 許可

View file

@ -17,9 +17,7 @@
"meta": { "meta": {
"title": "STARSET Mirror", "title": "STARSET Mirror",
"description": "STARSET Mirror, connecting STARSET and you." "description": "STARSET Mirror, connecting STARSET and you."
}, }
"latestUpdates": "Latest Updates",
"featuredProjects": "Featured Projects"
}, },
"projects": { "projects": {
"meta": { "meta": {
@ -49,7 +47,6 @@
"loading": "Loading...", "loading": "Loading...",
"back_to_list": "Back to Updates", "back_to_list": "Back to Updates",
"filter": { "filter": {
"all": "All Updates",
"title": "Filter by Tags", "title": "Filter by Tags",
"search_placeholder": "Search tags...", "search_placeholder": "Search tags...",
"no_results": "No matching tags found", "no_results": "No matching tags found",
@ -76,7 +73,6 @@
"title": "Contributors - STARSET Mirror", "title": "Contributors - STARSET Mirror",
"description": "Meet the amazing people behind STARSET Mirror. Our contributors work tirelessly to bring STARSET closer to you." "description": "Meet the amazing people behind STARSET Mirror. Our contributors work tirelessly to bring STARSET closer to you."
}, },
"title": "Contributors",
"tabs": { "tabs": {
"contributors": "Contributors", "contributors": "Contributors",
"sponsors": "Sponsors" "sponsors": "Sponsors"
@ -99,16 +95,17 @@
}, },
"aria": { "aria": {
"mainContent": "Main content", "mainContent": "Main content",
"breadcrumb": "Page navigation",
"navigation": "Site navigation",
"menu": "Menu", "menu": "Menu",
"navigation": "Navigation",
"search": "Search", "search": "Search",
"darkMode": "Dark mode", "language": "Change language",
"language": "Language selection", "darkMode": "Toggle dark mode",
"loading": "Loading", "openMenu": "Open menu",
"error": "Error",
"closeMenu": "Close menu", "closeMenu": "Close menu",
"openMenu": "Open menu" "error": "Error",
"loading": "Loading",
"comments": "Comments section",
"breadcrumb": "Breadcrumb"
}, },
"hero": { "hero": {
"title": "Connecting STARSET and You", "title": "Connecting STARSET and You",
@ -174,9 +171,7 @@
}, },
"common": { "common": {
"loading": "Loading...", "loading": "Loading...",
"error": "An error occurred", "error": "An error occurred"
"retry": "Retry",
"close": "Close"
} }
} }
} }

View file

@ -17,22 +17,21 @@
"meta": { "meta": {
"title": "STARSET Mirror", "title": "STARSET Mirror",
"description": "STARSET Mirror连接星落与你" "description": "STARSET Mirror连接星落与你"
}, }
"latestUpdates": "最新动态",
"featuredProjects": "精选项目"
}, },
"aria": { "aria": {
"mainContent": "主要内容", "mainContent": "主要内容",
"breadcrumb": "页面导航",
"navigation": "网站导航",
"menu": "菜单", "menu": "菜单",
"navigation": "导航",
"search": "搜索", "search": "搜索",
"darkMode": "深色模式", "language": "切换语言",
"language": "语言选择", "darkMode": "切换深色模式",
"loading": "加载中", "openMenu": "打开菜单",
"error": "错误",
"closeMenu": "关闭菜单", "closeMenu": "关闭菜单",
"openMenu": "打开菜单" "error": "错误",
"loading": "加载中",
"comments": "评论区",
"breadcrumb": "面包屑导航"
}, },
"hero": { "hero": {
"title": "连接星落与你", "title": "连接星落与你",
@ -70,7 +69,6 @@
"loading": "正在加载...", "loading": "正在加载...",
"back_to_list": "返回动态列表", "back_to_list": "返回动态列表",
"filter": { "filter": {
"all": "全部动态",
"title": "按标签筛选", "title": "按标签筛选",
"search_placeholder": "搜索标签...", "search_placeholder": "搜索标签...",
"no_results": "未找到匹配的标签", "no_results": "未找到匹配的标签",
@ -97,7 +95,6 @@
"title": "贡献者 - STARSET Mirror", "title": "贡献者 - STARSET Mirror",
"description": "认识 STARSET Mirror 背后的优秀贡献者们。他们不懈努力,让 STARSET 与你更近。" "description": "认识 STARSET Mirror 背后的优秀贡献者们。他们不懈努力,让 STARSET 与你更近。"
}, },
"title": "贡献者",
"tabs": { "tabs": {
"contributors": "贡献者", "contributors": "贡献者",
"sponsors": "赞助人" "sponsors": "赞助人"
@ -168,9 +165,7 @@
}, },
"common": { "common": {
"loading": "加载中...", "loading": "加载中...",
"error": "发生错误", "error": "发生错误"
"retry": "重试",
"close": "关闭"
} }
} }
} }

View file

@ -17,22 +17,21 @@
"meta": { "meta": {
"title": "STARSET Mirror", "title": "STARSET Mirror",
"description": "STARSET Mirror連結星落與你。" "description": "STARSET Mirror連結星落與你。"
}, }
"latestUpdates": "最新動態",
"featuredProjects": "查看專案"
}, },
"aria": { "aria": {
"mainContent": "主要內容", "mainContent": "主要內容",
"breadcrumb": "頁面導航",
"navigation": "網站導航",
"menu": "選單", "menu": "選單",
"navigation": "導航",
"search": "搜尋", "search": "搜尋",
"darkMode": "深色模式", "language": "切換語言",
"language": "語言選擇", "darkMode": "切換深色模式",
"loading": "載入中", "openMenu": "打開選單",
"error": "錯誤",
"closeMenu": "關閉選單", "closeMenu": "關閉選單",
"openMenu": "開啟選單" "error": "錯誤",
"loading": "載入中",
"comments": "評論區",
"breadcrumb": "麵包屑導航"
}, },
"hero": { "hero": {
"title": "連接星落與你", "title": "連接星落與你",
@ -43,6 +42,10 @@
} }
}, },
"projects": { "projects": {
"meta": {
"title": "專案 - STARSET Mirror",
"description": "探索我們為 STARSET 和社群打造的專案。從翻譯到社群服務,了解我們如何為社群貢獻力量。"
},
"title": "專案", "title": "專案",
"tags": { "tags": {
"translation": "翻譯", "translation": "翻譯",
@ -58,11 +61,14 @@
} }
}, },
"updates": { "updates": {
"meta": {
"title": "動態 - STARSET Mirror",
"description": "了解 STARSET Mirror 的最新動態、更新和活動。跟隨我們支持 STARSET 社群的脚步。"
},
"title": "專案動態", "title": "專案動態",
"loading": "正在載入...", "loading": "正在載入...",
"back_to_list": "返回動態列表", "back_to_list": "返回動態列表",
"filter": { "filter": {
"all": "全部動態",
"title": "按標籤篩選", "title": "按標籤篩選",
"search_placeholder": "搜尋標籤...", "search_placeholder": "搜尋標籤...",
"no_results": "未找到匹配的標籤", "no_results": "未找到匹配的標籤",
@ -85,7 +91,10 @@
} }
}, },
"contributors": { "contributors": {
"title": "貢獻者", "meta": {
"title": "貢獻者 - STARSET Mirror",
"description": "了解 STARSET Mirror 的核心貢獻者和贊助人。"
},
"tabs": { "tabs": {
"contributors": "貢獻者", "contributors": "貢獻者",
"sponsors": "贊助人" "sponsors": "贊助人"
@ -94,6 +103,10 @@
"regular_members": "專案成員 & 社群貢獻者" "regular_members": "專案成員 & 社群貢獻者"
}, },
"about": { "about": {
"meta": {
"title": "關於 - STARSET Mirror",
"description": "了解 STARSET Mirror 的使命、價值觀,以及我们致力于连接 STARSET 与全球粉丝的愿景。"
},
"title": "關於我們", "title": "關於我們",
"contact": { "contact": {
"title": "聯絡方式" "title": "聯絡方式"
@ -158,9 +171,7 @@
}, },
"common": { "common": {
"loading": "載入中...", "loading": "載入中...",
"error": "發生錯誤", "error": "發生錯誤"
"retry": "重試",
"close": "關閉"
} }
} }
} }

View file

@ -1,3 +1,3 @@
- [简体中文](./zh-CN/) - [简体中文](./zh-Hans/)
- [繁體中文](./zh-TW/) - [繁體中文](./zh-Hant/)
- [English](./en-US/) - [English](./en/)

View file

@ -5,9 +5,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build && tsx scripts/generate-rss.ts && tsx scripts/generate-sitemap.ts", "build": "tsx scripts/check-translations.ts && vite build && tsx scripts/generate-rss.ts && tsx scripts/generate-sitemap.ts",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"check-translations": "tsx scripts/check-translations.ts"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.0", "@ant-design/icons": "^5.6.0",
@ -36,6 +37,7 @@
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"chalk": "^5.4.1",
"eslint": "^9.19.0", "eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.18", "eslint-plugin-react-refresh": "^0.4.18",

9
pnpm-lock.yaml generated
View file

@ -81,6 +81,9 @@ importers:
autoprefixer: autoprefixer:
specifier: ^10.4.20 specifier: ^10.4.20
version: 10.4.20(postcss@8.5.1) version: 10.4.20(postcss@8.5.1)
chalk:
specifier: ^5.4.1
version: 5.4.1
eslint: eslint:
specifier: ^9.19.0 specifier: ^9.19.0
version: 9.19.0(jiti@1.21.7) version: 9.19.0(jiti@1.21.7)
@ -989,6 +992,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
chalk@5.4.1:
resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@ -2942,6 +2949,8 @@ snapshots:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
chalk@5.4.1: {}
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3

View file

@ -0,0 +1,183 @@
import fs from 'fs-extra';
import path from 'path';
import * as globModule from 'glob';
const glob = globModule.glob;
import chalk from 'chalk';
interface TranslationMap {
[key: string]: string | TranslationMap;
}
interface TranslationResult {
missingKeys: string[];
unusedKeys: string[];
}
// 递归获取所有翻译键
function getAllTranslationKeys(obj: TranslationMap, prefix = ''): string[] {
return Object.entries(obj).reduce<string[]>((acc, [key, value]) => {
const currentKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object') {
return [...acc, ...getAllTranslationKeys(value, currentKey)];
}
// 如果键以 translation. 开头,移除这个前缀
const finalKey = currentKey.startsWith('translation.')
? currentKey.substring('translation.'.length)
: currentKey;
if (!shouldIgnoreKey(finalKey)) {
return [...acc, finalKey];
}
return acc;
}, []);
}
// 从源代码中提取翻译键的正则表达式
const translationKeyRegexes = [
/(?:t|i18n\.t)\(['"]([a-zA-Z][a-zA-Z0-9._-]+)['"](?:\s*,\s*{[^}]*})?\)/gi, // t('key') or t('key', {})
/(?:t|i18n\.t)\(\s*(?:[a-zA-Z][a-zA-Z0-9._]+\s*\?\s*)?['"]([a-zA-Z][a-zA-Z0-9._-]+)['"](?:\s*:\s*['"]([a-zA-Z][a-zA-Z0-9._-]+)['"])?(?:\s*,\s*{[^}]*})?\)/gi, // t(isOpen ? 'key1' : 'key2') or t('key')
/useTranslation\(['"]([a-zA-Z][a-zA-Z0-9._-]+)['"]\)/gi, // useTranslation('key')
/<Trans[^>]*i18nKey=["']([a-zA-Z][a-zA-Z0-9._-]+)["']/gi, // <Trans i18nKey="key" />
];
// 从文件内容中提取翻译键
function extractTranslationKeys(content: string): Set<string> {
const keys = new Set<string>();
for (const regex of translationKeyRegexes) {
let match;
while ((match = regex.exec(content)) !== null) {
// 如果有多个捕获组(三元运算符的情况),添加所有捕获的键
for (let i = 1; i < match.length; i++) {
const key = match[i];
if (key && !shouldIgnoreKey(key)) {
keys.add(key);
}
}
}
}
return keys;
}
// 忽略的翻译键
const ignoredKeys = new Set([
'content-type',
'page',
'tags'
]);
// 忽略的翻译键前缀
const ignoredPrefixes = [
'projects.status.',
'projects.tags.',
'social.links',
'updates.tags',
'data.'
];
// 检查键是否应该被忽略
function shouldIgnoreKey(key: string): boolean {
if (ignoredKeys.has(key)) return true;
return ignoredPrefixes.some(prefix => key.startsWith(prefix));
}
// 比较翻译键
function compareTranslations(usedKeys: Set<string>, availableKeys: Set<string>): TranslationResult {
const missingKeys = Array.from(usedKeys)
.filter(key => !availableKeys.has(key))
.sort();
const unusedKeys = Array.from(availableKeys)
.filter(key => !usedKeys.has(key))
.sort();
return { missingKeys, unusedKeys };
}
async function main() {
console.log(chalk.blue('Checking translations...'));
// 获取所有支持的语言
const dataDir = path.resolve('data');
const langs = fs.readdirSync(dataDir)
.filter(file => fs.statSync(path.join(dataDir, file)).isDirectory())
.filter(dir => /^[a-z]{2}-[A-Z]{2}$/.test(dir));
// 扫描源代码文件
const srcFiles = await glob('src/**/*.{ts,tsx,js,jsx}', { absolute: true });
const usedKeys = new Set<string>();
// 从所有源代码文件中提取翻译键
for (const file of srcFiles) {
const content = await fs.readFile(file, 'utf-8');
const keys = extractTranslationKeys(content);
keys.forEach(key => usedKeys.add(key));
}
// 检查每个语言的翻译
let hasWarnings = false;
for (const lang of langs) {
const indexPath = path.join(dataDir, lang, 'index.json');
if (fs.existsSync(indexPath)) {
const content = await fs.readJson(indexPath);
// 确保我们从 translations 对象开始
const translationsObj = content.translations || content;
const availableKeys = new Set(getAllTranslationKeys(translationsObj));
const { missingKeys, unusedKeys } = compareTranslations(usedKeys, availableKeys);
if (missingKeys.length > 0 || unusedKeys.length > 0) {
hasWarnings = true;
console.log(chalk.yellow(`\n[${lang}]`));
if (missingKeys.length > 0) {
console.log(chalk.yellow(` Missing translations: (${missingKeys.length} keys)`));
// 按命名空间分组输出
const groupedKeys = missingKeys.reduce((acc, key) => {
const namespace = key.split('.')[0];
if (!acc[namespace]) acc[namespace] = [];
acc[namespace].push(key);
return acc;
}, {} as Record<string, string[]>);
Object.entries(groupedKeys)
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([namespace, keys]) => {
console.log(chalk.cyan(`\n ${namespace}:`));
keys.forEach(key => {
console.log(chalk.red(` ${key}`));
});
});
}
if (unusedKeys.length > 0) {
console.log(chalk.yellow(`\n Unused translations: (${unusedKeys.length} keys)`));
// 按命名空间分组输出
const groupedKeys = unusedKeys.reduce((acc, key) => {
const namespace = key.split('.')[0];
if (!acc[namespace]) acc[namespace] = [];
acc[namespace].push(key);
return acc;
}, {} as Record<string, string[]>);
Object.entries(groupedKeys)
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([namespace, keys]) => {
console.log(chalk.cyan(`\n ${namespace}:`));
keys.forEach(key => {
console.log(chalk.yellow(` ${key}`));
});
});
}
}
}
}
if (!hasWarnings) {
console.log(chalk.green('\n✓ All translations are complete and clean!'));
} else {
console.log(chalk.yellow('\n⚠ Please review the translation issues listed above.'));
}
}
main().catch(error => {
console.error(chalk.red('Error:'), error);
process.exit(1);
});

View file

@ -14,22 +14,22 @@ interface LanguageConfig {
const LANGUAGES: LanguageConfig[] = [ const LANGUAGES: LanguageConfig[] = [
{ {
code: 'en-US', code: 'en',
dataDir: 'en-US', dataDir: 'en',
title: 'STARSET Mirror Site Updates', title: 'Starset Mirror - Updates',
description: 'Latest updates from STARSET Mirror Site' description: 'Latest updates from Starset Mirror'
}, },
{ {
code: 'zh-CN', code: 'zh-Hans',
dataDir: 'zh-CN', dataDir: 'zh-Hans',
title: 'STARSET Mirror 项目动态', title: 'Starset Mirror - 更新',
description: 'STARSET Mirror 最新动态' description: 'Starset Mirror 的最新更新'
}, },
{ {
code: 'zh-Hant', code: 'zh-Hant',
dataDir: 'zh-TW', dataDir: 'zh-Hant',
title: 'STARSET Mirror 專案動态', title: 'Starset Mirror - 更新',
description: 'STARSET Mirror 最新動態' description: 'Starset Mirror 的最新更新'
} }
]; ];

View file

@ -6,7 +6,7 @@ import xml2js from 'xml2js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const LANGUAGES = ['en-US', 'zh-CN', 'zh-TW']; const LANGUAGES = ['en', 'zh-Hans', 'zh-Hant'];
const BASE_URL = 'mirror.starset.fans'; const BASE_URL = 'mirror.starset.fans';
interface Update { interface Update {

View file

@ -6,7 +6,7 @@ import xml2js from 'xml2js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const LANGUAGES = ['en-US', 'zh-CN', 'zh-TW']; const LANGUAGES = ['en', 'zh-Hans', 'zh-Hant'];
const BASE_URL = 'starset.wiki'; // Replace with your actual domain const BASE_URL = 'starset.wiki'; // Replace with your actual domain
async function getYearlyIndices(lang) { async function getYearlyIndices(lang) {

View file

@ -4,22 +4,24 @@ import iconMap from '../utils/iconMap';
const About = () => { const About = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const aboutData = t('data.about', { returnObjects: true }); const aboutData = t('data.about', { returnObjects: true }) || {};
const socialLinks = t('social.links', { returnObjects: true }); const socialLinks = t('social.links', { returnObjects: true }) || [];
if (!aboutData) return null;
return ( return (
<div className="container mx-auto px-2 md:px-4"> <div className="container mx-auto px-2 md:px-4">
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-8 text-gray-900 dark:text-white">{t('about.title')}</h2> <h2 className="text-3xl font-bold text-center mb-8 text-gray-900 dark:text-white">{t('about.title')}</h2>
<div className="prose prose-lg mx-auto dark:prose-invert"> <div className="prose prose-lg mx-auto dark:prose-invert">
{aboutData.content.intro.map((paragraph: string, index: number) => ( {aboutData.content?.intro?.map((paragraph: string, index: number) => (
<p key={index} className="text-gray-600 dark:text-gray-300 mb-6"> <p key={index} className="text-gray-600 dark:text-gray-300 mb-6">
{paragraph} {paragraph}
</p> </p>
))} ))}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mt-12"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 mt-12">
{aboutData.stats.map((stat: { value: string; label: string }, index: number) => ( {aboutData.stats?.map((stat: { value: string; label: string }, index: number) => (
<div key={index} className="text-center"> <div key={index} className="text-center">
<h3 className="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-2">{stat.value}</h3> <h3 className="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-2">{stat.value}</h3>
<p className="text-gray-600 dark:text-gray-300">{stat.label}</p> <p className="text-gray-600 dark:text-gray-300">{stat.label}</p>
@ -27,7 +29,7 @@ const About = () => {
))} ))}
</div> </div>
{aboutData.content.workScope && ( {aboutData.content?.workScope && (
<div className="mt-12"> <div className="mt-12">
<h3 className="text-2xl font-semibold mb-6 text-gray-900 dark:text-white"></h3> <h3 className="text-2xl font-semibold mb-6 text-gray-900 dark:text-white"></h3>
<ol className="space-y-4"> <ol className="space-y-4">
@ -36,7 +38,7 @@ const About = () => {
<span className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 font-semibold"> <span className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 font-semibold">
{index + 1} {index + 1}
</span> </span>
<span className="text-lg">{item}</span> <span>{item}</span>
</li> </li>
))} ))}
</ol> </ol>
@ -67,14 +69,14 @@ const About = () => {
<div className="mt-12"> <div className="mt-12">
<h3 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-white">{t('about.join.title')}</h3> <h3 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-white">{t('about.join.title')}</h3>
<p className="text-gray-600 dark:text-gray-300"> <p className="text-gray-600 dark:text-gray-300">
{aboutData.content.contact.description} {aboutData.content?.contact?.description}
</p> </p>
<div className="mt-4"> <div className="mt-4">
<a <a
href={`mailto:${aboutData.content.contact.email}`} href={`mailto:${aboutData.content?.contact?.email}`}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 no-underline" className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 no-underline"
> >
{aboutData.content.contact.email} {aboutData.content?.contact?.email}
</a> </a>
</div> </div>
</div> </div>

View file

@ -1,5 +1,6 @@
import { useCallback, useRef, useEffect } from 'react' import { useCallback, useRef, useEffect } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import 'artalk/dist/Artalk.css' import 'artalk/dist/Artalk.css'
import Artalk from 'artalk' import Artalk from 'artalk'
@ -9,6 +10,7 @@ interface CommentsProps {
const Comments = ({ title }: CommentsProps) => { const Comments = ({ title }: CommentsProps) => {
const { pathname } = useLocation() const { pathname } = useLocation()
const { t } = useTranslation()
const artalkRef = useRef<Artalk>() const artalkRef = useRef<Artalk>()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -59,10 +61,10 @@ const Comments = ({ title }: CommentsProps) => {
}, [pathname, title]) }, [pathname, title])
return ( return (
<div <section
ref={containerRef} ref={containerRef}
className="mt-12 p-4 bg-white dark:bg-gray-800 rounded-lg shadow" className="mt-12 p-4 bg-white dark:bg-gray-800 rounded-lg shadow"
aria-label="Comments section" aria-label={t('aria.comments')}
role="complementary" role="complementary"
/> />
) )

View file

@ -23,7 +23,8 @@ interface Contributor {
const Contributors: React.FC = () => { const Contributors: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const members = t('data.contributors.members', { returnObjects: true }) as Contributor[]; const contributorsData = t('data.contributors', { returnObjects: true }) || {};
const members = Array.isArray(contributorsData.members) ? contributorsData.members : [];
// Fisher-Yates shuffle algorithm // Fisher-Yates shuffle algorithm
const shuffleArray = <T,>(array: T[]): T[] => { const shuffleArray = <T,>(array: T[]): T[] => {

View file

@ -3,14 +3,14 @@ import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react'; import { Globe } from 'lucide-react';
const LanguageSwitcher = () => { const LanguageSwitcher = () => {
const { i18n } = useTranslation(); const { i18n, t } = useTranslation();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const languages = [ const languages = [
{ code: 'zh-CN', name: '简体中文' }, { code: 'zh-Hans', name: '简体中文' },
{ code: 'zh-TW', name: '繁體中文' }, { code: 'zh-Hant', name: '繁體中文' },
{ code: 'en-US', name: 'English' } { code: 'en', name: 'English' }
]; ];
useEffect(() => { useEffect(() => {
@ -32,9 +32,12 @@ const LanguageSwitcher = () => {
return ( return (
<div className="relative" ref={menuRef}> <div className="relative" ref={menuRef}>
<button <button
onClick={() => setIsOpen(!isOpen)} type="button"
className="p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" className="p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="Change language" onClick={() => setIsOpen(!isOpen)}
aria-label={t('aria.language')}
aria-expanded={isOpen}
aria-haspopup="true"
> >
<Globe className="w-5 h-5" /> <Globe className="w-5 h-5" />
</button> </button>

View file

@ -5,8 +5,12 @@ const LoadingSpinner: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex items-center justify-center min-h-[200px]" role="status"> <div
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent" /> className="flex items-center justify-center min-h-[200px]"
role="status"
aria-label={t('aria.loading')}
>
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary dark:border-primary border-t-transparent dark:border-t-transparent" />
<span className="sr-only">{t('common.loading')}</span> <span className="sr-only">{t('common.loading')}</span>
</div> </div>
); );

View file

@ -34,7 +34,11 @@ const Navbar = () => {
</div> </div>
{/* Desktop Navigation */} {/* Desktop Navigation */}
<div className="hidden md:flex items-center"> <nav
className="hidden md:flex items-center"
role="navigation"
aria-label={t('aria.navigation')}
>
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
@ -54,7 +58,7 @@ const Navbar = () => {
<ThemeSwitcher /> <ThemeSwitcher />
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
</div> </nav>
{/* Mobile Navigation Button */} {/* Mobile Navigation Button */}
<div className="md:hidden flex items-center space-x-1.5"> <div className="md:hidden flex items-center space-x-1.5">
@ -71,7 +75,11 @@ const Navbar = () => {
{/* Mobile Navigation Menu */} {/* Mobile Navigation Menu */}
{isOpen && ( {isOpen && (
<div className="md:hidden"> <div
className="md:hidden"
role="menu"
aria-label={t('aria.menu')}
>
<div className="px-2 pt-2 pb-3 space-y-1"> <div className="px-2 pt-2 pb-3 space-y-1">
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link

View file

@ -13,18 +13,19 @@ interface Project {
const Projects = () => { const Projects = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const projects = t('data.projects.projects', { returnObjects: true }) as Project[]; const projectsData = t('data.projects', { returnObjects: true }) || {};
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : [];
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
// 获取所有可用的标签 // 获取所有可用的标签
const allTags = Array.from( const allTags = Array.from(
new Set(projects.flatMap(project => project.tags)) new Set(projects.flatMap(project => project.tags || []))
); );
// 根据选中的标签筛选项目 // 根据选中的标签筛选项目
const filteredProjects = selectedTags.length > 0 const filteredProjects = selectedTags.length > 0
? projects.filter(project => ? projects.filter(project =>
selectedTags.every(tag => project.tags.includes(tag)) selectedTags.every(tag => (project.tags || []).includes(tag))
) )
: projects; : projects;
@ -119,7 +120,7 @@ const Projects = () => {
{/* 标签区域固定高度 */} {/* 标签区域固定高度 */}
<div className="h-8 mb-4 flex items-center overflow-x-auto"> <div className="h-8 mb-4 flex items-center overflow-x-auto">
<div className="flex gap-2"> <div className="flex gap-2">
{project.tags.map((tag) => ( {(project.tags || []).map((tag) => (
<button <button
key={tag} key={tag}
onClick={() => handleTagClick(tag)} onClick={() => handleTagClick(tag)}

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Card, Avatar, Tag } from 'antd'; import { Card, Avatar, Tag } from 'antd';
import { GithubOutlined, TwitterOutlined } from '@ant-design/icons'; import { GithubOutlined, TwitterOutlined } from '@ant-design/icons';
import sponsorsData from '../../data/zh-CN/sponsors.json'; import sponsorsData from '../../data/zh-Hans/sponsors.json';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface SponsorType { interface SponsorType {
@ -13,26 +13,33 @@ interface SponsorType {
github?: string; github?: string;
twitter?: string; twitter?: string;
}; };
name: string;
name_zh_TW?: string;
name_en?: string;
} }
const Sponsors: React.FC = () => { const Sponsors: React.FC = () => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const getMessage = () => { const getMessage = (sponsor: SponsorType) => {
switch (i18n.language) { switch (i18n.language) {
case 'zh-Hans':
case 'zh-CN': case 'zh-CN':
return '赞助人信息仍在同步,将在晚些时候上线。'; return sponsor.name;
case 'zh-Hant':
case 'zh-TW': case 'zh-TW':
return '贊助人資訊仍在同步,將在晚些時候上線。'; return sponsor.name_zh_TW || sponsor.name;
case 'en':
case 'en-US': case 'en-US':
return sponsor.name_en || sponsor.name;
default: default:
return 'Sponsor information is still being synchronized and will be available later.'; return sponsor.name;
} }
}; };
return ( return (
<div className="flex items-center justify-center min-h-[300px] text-gray-600 dark:text-gray-300 text-lg"> <div className="flex items-center justify-center min-h-[300px] text-gray-600 dark:text-gray-300 text-lg">
{getMessage()} {getMessage(sponsorsData[0])}
</div> </div>
); );
@ -51,7 +58,7 @@ const Sponsors: React.FC = () => {
/> />
</div> </div>
)} )}
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">{sponsor.nickname}</h3> <h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">{getMessage(sponsor)}</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-center gap-3"> <div className="flex justify-center gap-3">
<Tag color="gold">{sponsor.year}</Tag> <Tag color="gold">{sponsor.year}</Tag>

View file

@ -23,7 +23,16 @@ const ThemeSwitcher = () => {
items={themes} items={themes}
value={theme} value={theme}
onChange={setTheme} onChange={setTheme}
/> >
<button
type="button"
className="p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={t('aria.darkMode')}
aria-haspopup="true"
>
{currentIcon}
</button>
</Dropdown>
); );
}; };

View file

@ -62,6 +62,7 @@ const Pagination: React.FC<PaginationProps> = ({
const Timeline = () => { const Timeline = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const LANGUAGE_CODE_MAP: Record<string, string> = { const LANGUAGE_CODE_MAP: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant' 'zh-TW': 'zh-Hant'
}; };
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -112,8 +113,12 @@ const Timeline = () => {
if (error) { if (error) {
return ( return (
<div className="text-center py-20"> <div
<p className="text-red-500">{t('updates.error')}</p> className="text-center py-8"
role="alert"
aria-label={t('aria.error')}
>
<p className="text-red-500">{t('common.error')}</p>
</div> </div>
); );
} }
@ -152,7 +157,7 @@ const Timeline = () => {
{isLoading ? ( {isLoading ? (
<div className="text-center py-20"> <div className="text-center py-20">
<p>{t('updates.loading')}</p> <p className="text-gray-900 dark:text-gray-100">{t('updates.loading')}</p>
</div> </div>
) : updates.length > 0 ? ( ) : updates.length > 0 ? (
<> <>
@ -211,7 +216,7 @@ const Timeline = () => {
</> </>
) : ( ) : (
<div className="text-center py-20"> <div className="text-center py-20">
<p>{t('updates.no_results')}</p> <p>{t('updates.notFound.title')}</p>
</div> </div>
)} )}
</div> </div>

View file

@ -36,10 +36,33 @@ const TranslationBackground = () => {
setIsVisible(true); setIsVisible(true);
}, []); }, []);
const getLanguageName = (language: string) => {
switch (language) {
case 'zh-Hans':
return '简体中文';
case 'zh-Hant':
return '繁體中文';
default:
return 'English';
}
};
const getTranslationName = (language: string) => {
switch (language) {
case 'zh-Hans':
case 'zh-Hant':
return '翻译';
default:
return 'Translation';
}
};
const getMainText = (translation: typeof translations.translations[0]) => { const getMainText = (translation: typeof translations.translations[0]) => {
switch (i18n.language) { switch (i18n.language) {
case 'zh-Hans':
case 'zh-CN': case 'zh-CN':
return translation.zh_CN; return translation.zh_CN;
case 'zh-Hant':
case 'zh-TW': case 'zh-TW':
return translation.zh_TW; return translation.zh_TW;
default: default:
@ -49,7 +72,9 @@ const TranslationBackground = () => {
const getSecondaryText = (translation: typeof translations.translations[0]) => { const getSecondaryText = (translation: typeof translations.translations[0]) => {
switch (i18n.language) { switch (i18n.language) {
case 'zh-Hans':
case 'zh-CN': case 'zh-CN':
case 'zh-Hant':
case 'zh-TW': case 'zh-TW':
return translation.en; return translation.en;
default: default:

View file

@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
interface DropdownProps { interface DropdownProps {
icon: React.ReactNode; icon: React.ReactNode;
@ -16,6 +17,7 @@ const Dropdown = ({ icon, items, value, onChange, position = 'right' }: Dropdown
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -31,9 +33,12 @@ const Dropdown = ({ icon, items, value, onChange, position = 'right' }: Dropdown
return ( return (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" className="p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="Toggle dropdown" aria-label={t(isOpen ? 'aria.closeMenu' : 'aria.openMenu')}
aria-expanded={isOpen}
aria-haspopup="true"
> >
{icon} {icon}
</button> </button>

View file

@ -104,10 +104,11 @@ const TagFilter: React.FC<TagFilterProps> = ({
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
className="pl-10 pr-4 py-2 w-full bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('updates.filter.search_placeholder')} placeholder={t('updates.filter.search_placeholder')}
className="w-full px-4 py-2.5 pl-10 text-sm rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500" aria-label={t('aria.search')}
/> />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
</div> </div>

View file

@ -3,42 +3,42 @@ import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
// 导入翻译文件 // 导入翻译文件
import zhCNTranslation from '../../data/zh-CN/index.json'; import zhHansTranslation from '../../data/zh-Hans/index.json';
import zhTWTranslation from '../../data/zh-TW/index.json'; import zhHantTranslation from '../../data/zh-Hant/index.json';
import enUSTranslation from '../../data/en-US/index.json'; import enTranslation from '../../data/en/index.json';
// 导入数据文件 // 导入数据文件
import zhCNAbout from '../../data/zh-CN/about.json'; import zhHansAbout from '../../data/zh-Hans/about.json';
import zhCNProjects from '../../data/zh-CN/projects.json'; import zhHansProjects from '../../data/zh-Hans/projects.json';
import zhCNContributors from '../../data/zh-CN/contributors.json'; import zhHansContributors from '../../data/zh-Hans/contributors.json';
import zhCNUpdates from '../../data/zh-CN/updates.json'; import zhHansUpdates from '../../data/zh-Hans/updates.json';
import zhTWAbout from '../../data/zh-TW/about.json'; import zhHantAbout from '../../data/zh-Hant/about.json';
import zhTWProjects from '../../data/zh-TW/projects.json'; import zhHantProjects from '../../data/zh-Hant/projects.json';
import zhTWContributors from '../../data/zh-TW/contributors.json'; import zhHantContributors from '../../data/zh-Hant/contributors.json';
import zhTWUpdates from '../../data/zh-TW/updates.json'; import zhHantUpdates from '../../data/zh-Hant/updates.json';
import enUSAbout from '../../data/en-US/about.json'; import enAbout from '../../data/en/about.json';
import enUSProjects from '../../data/en-US/projects.json'; import enProjects from '../../data/en/projects.json';
import enUSContributors from '../../data/en-US/contributors.json'; import enContributors from '../../data/en/contributors.json';
import enUSUpdates from '../../data/en-US/updates.json'; import enUpdates from '../../data/en/updates.json';
i18n i18n
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
resources: { resources: {
'zh-CN': { 'zh-Hans': {
translation: zhCNTranslation.translation translation: zhHansTranslation.translation
}, },
'zh-TW': { 'zh-Hant': {
translation: zhTWTranslation.translation translation: zhHantTranslation.translation
}, },
'en-US': { 'en': {
translation: enUSTranslation.translation translation: enTranslation.translation
} }
}, },
fallbackLng: 'zh-CN', fallbackLng: 'zh-Hans',
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },
@ -48,30 +48,30 @@ i18n
}); });
// 添加数据命名空间 // 添加数据命名空间
i18n.addResourceBundle('zh-CN', 'translation', { i18n.addResourceBundle('zh-Hans', 'translation', {
data: { data: {
about: zhCNAbout, about: zhHansAbout,
projects: zhCNProjects, projects: zhHansProjects,
contributors: zhCNContributors, contributors: zhHansContributors,
updates: zhCNUpdates updates: zhHansUpdates
} }
}, true, true); }, true, true);
i18n.addResourceBundle('zh-TW', 'translation', { i18n.addResourceBundle('zh-Hant', 'translation', {
data: { data: {
about: zhTWAbout, about: zhHantAbout,
projects: zhTWProjects, projects: zhHantProjects,
contributors: zhTWContributors, contributors: zhHantContributors,
updates: zhTWUpdates updates: zhHantUpdates
} }
}, true, true); }, true, true);
i18n.addResourceBundle('en-US', 'translation', { i18n.addResourceBundle('en', 'translation', {
data: { data: {
about: enUSAbout, about: enAbout,
projects: enUSProjects, projects: enProjects,
contributors: enUSContributors, contributors: enContributors,
updates: enUSUpdates updates: enUpdates
} }
}, true, true); }, true, true);