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
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

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

View file

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

View file

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

View file

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

View file

@ -5,9 +5,10 @@
"type": "module",
"scripts": {
"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 .",
"preview": "vite preview"
"preview": "vite preview",
"check-translations": "tsx scripts/check-translations.ts"
},
"dependencies": {
"@ant-design/icons": "^5.6.0",
@ -36,6 +37,7 @@
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"chalk": "^5.4.1",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.18",

9
pnpm-lock.yaml generated
View file

@ -81,6 +81,9 @@ importers:
autoprefixer:
specifier: ^10.4.20
version: 10.4.20(postcss@8.5.1)
chalk:
specifier: ^5.4.1
version: 5.4.1
eslint:
specifier: ^9.19.0
version: 9.19.0(jiti@1.21.7)
@ -989,6 +992,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
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:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@ -2942,6 +2949,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@5.4.1: {}
chokidar@3.6.0:
dependencies:
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[] = [
{
code: 'en-US',
dataDir: 'en-US',
title: 'STARSET Mirror Site Updates',
description: 'Latest updates from STARSET Mirror Site'
code: 'en',
dataDir: 'en',
title: 'Starset Mirror - Updates',
description: 'Latest updates from Starset Mirror'
},
{
code: 'zh-CN',
dataDir: 'zh-CN',
title: 'STARSET Mirror 项目动态',
description: 'STARSET Mirror 最新动态'
code: 'zh-Hans',
dataDir: 'zh-Hans',
title: 'Starset Mirror - 更新',
description: 'Starset Mirror 的最新更新'
},
{
code: 'zh-Hant',
dataDir: 'zh-TW',
title: 'STARSET Mirror 專案動态',
description: 'STARSET Mirror 最新動態'
dataDir: 'zh-Hant',
title: 'Starset Mirror - 更新',
description: 'Starset Mirror 的最新更新'
}
];

View file

@ -6,7 +6,7 @@ import xml2js from 'xml2js';
const __filename = fileURLToPath(import.meta.url);
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';
interface Update {

View file

@ -6,7 +6,7 @@ import xml2js from 'xml2js';
const __filename = fileURLToPath(import.meta.url);
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
async function getYearlyIndices(lang) {

View file

@ -4,22 +4,24 @@ import iconMap from '../utils/iconMap';
const About = () => {
const { t } = useTranslation();
const aboutData = t('data.about', { returnObjects: true });
const socialLinks = t('social.links', { returnObjects: true });
const aboutData = t('data.about', { returnObjects: true }) || {};
const socialLinks = t('social.links', { returnObjects: true }) || [];
if (!aboutData) return null;
return (
<div className="container mx-auto px-2 md:px-4">
<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>
<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">
{paragraph}
</p>
))}
<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">
<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>
@ -27,7 +29,7 @@ const About = () => {
))}
</div>
{aboutData.content.workScope && (
{aboutData.content?.workScope && (
<div className="mt-12">
<h3 className="text-2xl font-semibold mb-6 text-gray-900 dark:text-white"></h3>
<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">
{index + 1}
</span>
<span className="text-lg">{item}</span>
<span>{item}</span>
</li>
))}
</ol>
@ -67,14 +69,14 @@ const About = () => {
<div className="mt-12">
<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">
{aboutData.content.contact.description}
{aboutData.content?.contact?.description}
</p>
<div className="mt-4">
<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"
>
{aboutData.content.contact.email}
{aboutData.content?.contact?.email}
</a>
</div>
</div>

View file

@ -1,5 +1,6 @@
import { useCallback, useRef, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import 'artalk/dist/Artalk.css'
import Artalk from 'artalk'
@ -9,6 +10,7 @@ interface CommentsProps {
const Comments = ({ title }: CommentsProps) => {
const { pathname } = useLocation()
const { t } = useTranslation()
const artalkRef = useRef<Artalk>()
const containerRef = useRef<HTMLDivElement>(null)
@ -59,10 +61,10 @@ const Comments = ({ title }: CommentsProps) => {
}, [pathname, title])
return (
<div
<section
ref={containerRef}
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"
/>
)

View file

@ -23,7 +23,8 @@ interface Contributor {
const Contributors: React.FC = () => {
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
const shuffleArray = <T,>(array: T[]): T[] => {

View file

@ -3,14 +3,14 @@ import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react';
const LanguageSwitcher = () => {
const { i18n } = useTranslation();
const { i18n, t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const languages = [
{ code: 'zh-CN', name: '简体中文' },
{ code: 'zh-TW', name: '繁體中文' },
{ code: 'en-US', name: 'English' }
{ code: 'zh-Hans', name: '简体中文' },
{ code: 'zh-Hant', name: '繁體中文' },
{ code: 'en', name: 'English' }
];
useEffect(() => {
@ -32,9 +32,12 @@ const LanguageSwitcher = () => {
return (
<div className="relative" ref={menuRef}>
<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"
aria-label="Change language"
onClick={() => setIsOpen(!isOpen)}
aria-label={t('aria.language')}
aria-expanded={isOpen}
aria-haspopup="true"
>
<Globe className="w-5 h-5" />
</button>

View file

@ -5,8 +5,12 @@ const LoadingSpinner: React.FC = () => {
const { t } = useTranslation();
return (
<div className="flex items-center justify-center min-h-[200px]" role="status">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent" />
<div
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>
</div>
);

View file

@ -34,7 +34,11 @@ const Navbar = () => {
</div>
{/* 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">
{navItems.map((item) => (
<Link
@ -54,7 +58,7 @@ const Navbar = () => {
<ThemeSwitcher />
<LanguageSwitcher />
</div>
</div>
</nav>
{/* Mobile Navigation Button */}
<div className="md:hidden flex items-center space-x-1.5">
@ -71,7 +75,11 @@ const Navbar = () => {
{/* Mobile Navigation Menu */}
{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">
{navItems.map((item) => (
<Link

View file

@ -13,18 +13,19 @@ interface Project {
const Projects = () => {
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 allTags = Array.from(
new Set(projects.flatMap(project => project.tags))
new Set(projects.flatMap(project => project.tags || []))
);
// 根据选中的标签筛选项目
const filteredProjects = selectedTags.length > 0
? projects.filter(project =>
selectedTags.every(tag => project.tags.includes(tag))
selectedTags.every(tag => (project.tags || []).includes(tag))
)
: projects;
@ -119,7 +120,7 @@ const Projects = () => {
{/* 标签区域固定高度 */}
<div className="h-8 mb-4 flex items-center overflow-x-auto">
<div className="flex gap-2">
{project.tags.map((tag) => (
{(project.tags || []).map((tag) => (
<button
key={tag}
onClick={() => handleTagClick(tag)}

View file

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

View file

@ -23,7 +23,16 @@ const ThemeSwitcher = () => {
items={themes}
value={theme}
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 { t, i18n } = useTranslation();
const LANGUAGE_CODE_MAP: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant'
};
const [searchParams, setSearchParams] = useSearchParams();
@ -112,8 +113,12 @@ const Timeline = () => {
if (error) {
return (
<div className="text-center py-20">
<p className="text-red-500">{t('updates.error')}</p>
<div
className="text-center py-8"
role="alert"
aria-label={t('aria.error')}
>
<p className="text-red-500">{t('common.error')}</p>
</div>
);
}
@ -152,7 +157,7 @@ const Timeline = () => {
{isLoading ? (
<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>
) : updates.length > 0 ? (
<>
@ -211,7 +216,7 @@ const Timeline = () => {
</>
) : (
<div className="text-center py-20">
<p>{t('updates.no_results')}</p>
<p>{t('updates.notFound.title')}</p>
</div>
)}
</div>

View file

@ -36,10 +36,33 @@ const TranslationBackground = () => {
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]) => {
switch (i18n.language) {
case 'zh-Hans':
case 'zh-CN':
return translation.zh_CN;
case 'zh-Hant':
case 'zh-TW':
return translation.zh_TW;
default:
@ -49,7 +72,9 @@ const TranslationBackground = () => {
const getSecondaryText = (translation: typeof translations.translations[0]) => {
switch (i18n.language) {
case 'zh-Hans':
case 'zh-CN':
case 'zh-Hant':
case 'zh-TW':
return translation.en;
default:

View file

@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
interface DropdownProps {
icon: React.ReactNode;
@ -16,6 +17,7 @@ const Dropdown = ({ icon, items, value, onChange, position = 'right' }: Dropdown
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -31,9 +33,12 @@ const Dropdown = ({ icon, items, value, onChange, position = 'right' }: Dropdown
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
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"
aria-label="Toggle dropdown"
aria-label={t(isOpen ? 'aria.closeMenu' : 'aria.openMenu')}
aria-expanded={isOpen}
aria-haspopup="true"
>
{icon}
</button>

View file

@ -104,10 +104,11 @@ const TagFilter: React.FC<TagFilterProps> = ({
<input
ref={inputRef}
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}
onChange={(e) => setSearchQuery(e.target.value)}
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" />
</div>

View file

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