feat: translations integrity validation

closes #7
This commit is contained in:
CDN 2025-02-03 22:22:07 +08:00
parent 25bd99abc5
commit 47cf6171c5
Signed by: CDN
GPG key ID: 0C656827F9F80080
14 changed files with 291 additions and 62 deletions

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

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

@ -59,10 +59,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

@ -3,7 +3,7 @@ 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);
@ -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,7 +5,11 @@ 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
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" /> <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

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

@ -112,8 +112,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>
); );
} }
@ -211,7 +215,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

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