diff --git a/CHANGELOG.md b/CHANGELOG.md index c66f0bc..42c302b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [0.1.3](https://github.com/C4illin/ConvertX/releases/tag/v0.1.3) (2026-01-20) + +### Features + +- **i18n**: Add multi-language UI support with 5 languages + - English (en) - default + - Traditional Chinese (zh-TW) / 繁體中文 + - Simplified Chinese (zh-CN) / 简体中文 + - Japanese (ja) / 日本語 + - Korean (ko) / 한국어 +- Add language selector dropdown in navigation header +- Auto-detect user's preferred language from browser settings +- Persist language preference in cookies +- All UI text (buttons, labels, messages, errors) now supports translation +- Extensible i18n architecture for adding more languages in the future + +### Technical Details + +- New `/src/i18n/` directory with translation core functionality +- New `/src/locales/` directory with JSON translation files +- New `LanguageSelector` component for language switching +- Updated all page components to support localization +- Client-side translation helper for dynamic content + +--- + ## [0.15.0](https://github.com/C4illin/ConvertX/compare/v0.14.1...v0.15.0) (2025-10-07) ### Features diff --git a/README.md b/README.md index 887334f..1397e01 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,39 @@ docker compose up -d --- +## 🌍 多語言支援(i18n) + +ConvertX-CN 支援以下語言: + +| 語言代碼 | 語言名稱 | +| -------- | ---------------- | +| en | English(預設) | +| zh-TW | 繁體中文 | +| zh-CN | 简体中文 | +| ja | 日本語 | +| ko | 한국어 | + +### 語言切換 + +- 在網站右上角的導航列可看到語言選擇器(地球圖示) +- 點擊後可選擇偏好語言 +- 語言偏好會自動保存到 Cookie 中 +- 首次訪問時會自動偵測瀏覽器語言設定 + +### 新增語言 + +如要添加新語言,請: + +1. 在 `src/locales/` 目錄新增語言檔案(例如 `fr.json`) +2. 在 `src/i18n/index.ts` 中: + - 導入新語言檔案 + - 在 `supportedLocales` 陣列中添加語言配置 + - 在 `translations` 物件中註冊翻譯 + +歡迎提交 Pull Request 來新增更多語言! + +--- + ## 📖 教學文章 > [!NOTE] diff --git a/bun.lock b/bun.lock index 6e9be04..f9fe3f9 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@elysiajs/jwt": "^1.4.0", "@elysiajs/static": "^1.4.6", "@kitajs/html": "^4.2.11", + "@sinclair/typebox": "^0.34.47", "elysia": "^1.4.16", "sanitize-filename": "^1.6.3", "tar": "^7.5.2", diff --git a/package.json b/package.json index c2dfc3d..1d2d9e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "convertx-frontend", - "version": "0.17.0", + "version": "0.1.3", "scripts": { "dev": "bun run --watch src/index.tsx", "hot": "bun run --hot src/index.tsx", @@ -20,6 +20,7 @@ "@elysiajs/jwt": "^1.4.0", "@elysiajs/static": "^1.4.6", "@kitajs/html": "^4.2.11", + "@sinclair/typebox": "^0.34.47", "elysia": "^1.4.16", "sanitize-filename": "^1.6.3", "tar": "^7.5.2" diff --git a/public/i18n.js b/public/i18n.js new file mode 100644 index 0000000..7b9718c --- /dev/null +++ b/public/i18n.js @@ -0,0 +1,143 @@ +// Client-side i18n helper +(function () { + const LOCALE_COOKIE_NAME = "locale"; + const LOCALE_EXPIRY_DAYS = 365; + + // Get current locale from meta tag or cookie + function getCurrentLocale() { + const metaLocale = document.querySelector("meta[name='locale']")?.content; + if (metaLocale) return metaLocale; + + const cookieLocale = getCookie(LOCALE_COOKIE_NAME); + if (cookieLocale) return cookieLocale; + + return "en"; + } + + // Get cookie value + function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(";").shift(); + return null; + } + + // Set cookie value + function setCookie(name, value, days) { + const expires = new Date(); + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Strict`; + } + + // Get translation from window.__TRANSLATIONS__ + function t(category, key, params) { + const translations = window.__TRANSLATIONS__ || {}; + let text = translations[category]?.[key]; + + if (!text) { + console.warn(`Missing translation: ${category}.${key}`); + return `${category}.${key}`; + } + + // Interpolate params + if (params) { + text = text.replace(/\{(\w+)\}/g, (match, paramKey) => { + return params[paramKey] !== undefined ? String(params[paramKey]) : match; + }); + } + + return text; + } + + // Expose t function globally + window.t = t; + window.getCurrentLocale = getCurrentLocale; + + // Language selector functionality + document.addEventListener("DOMContentLoaded", () => { + const toggle = document.getElementById("language-toggle"); + const dropdown = document.getElementById("language-dropdown"); + + if (!toggle || !dropdown) return; + + // Toggle dropdown + toggle.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = !dropdown.classList.contains("hidden"); + + if (isOpen) { + dropdown.classList.add("hidden"); + dropdown.classList.remove("flex"); + toggle.setAttribute("aria-expanded", "false"); + } else { + dropdown.classList.remove("hidden"); + dropdown.classList.add("flex"); + toggle.setAttribute("aria-expanded", "true"); + } + }); + + // Close dropdown when clicking outside + document.addEventListener("click", (e) => { + if (!toggle.contains(e.target) && !dropdown.contains(e.target)) { + dropdown.classList.add("hidden"); + dropdown.classList.remove("flex"); + toggle.setAttribute("aria-expanded", "false"); + } + }); + + // Handle language selection + const options = document.querySelectorAll(".language-option"); + options.forEach((option) => { + option.addEventListener("click", () => { + const locale = option.dataset.locale; + const webroot = option.dataset.webroot || ""; + + // Save to cookie + setCookie(LOCALE_COOKIE_NAME, locale, LOCALE_EXPIRY_DAYS); + + // Reload the page to apply the new locale + window.location.reload(); + }); + }); + + // Keyboard navigation for dropdown + toggle.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle.click(); + } + }); + + dropdown.addEventListener("keydown", (e) => { + const items = [...dropdown.querySelectorAll(".language-option")]; + const currentIndex = items.indexOf(document.activeElement); + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + if (currentIndex < items.length - 1) { + items[currentIndex + 1].focus(); + } + break; + case "ArrowUp": + e.preventDefault(); + if (currentIndex > 0) { + items[currentIndex - 1].focus(); + } + break; + case "Escape": + dropdown.classList.add("hidden"); + dropdown.classList.remove("flex"); + toggle.setAttribute("aria-expanded", "false"); + toggle.focus(); + break; + case "Enter": + case " ": + if (document.activeElement.classList.contains("language-option")) { + document.activeElement.click(); + } + break; + } + }); + }); +})(); diff --git a/public/script.js b/public/script.js index 8b89194..49104cf 100644 --- a/public/script.js +++ b/public/script.js @@ -7,6 +7,28 @@ let fileType; let pendingFiles = 0; let formatSelected = false; +// Get translation helper +const getTranslation = (category, key, params) => { + if (typeof window.t === 'function') { + return window.t(category, key, params); + } + // Fallback to English if t is not available + const fallbacks = { + 'common.remove': 'Remove', + 'convert.title': 'Convert', + 'convert.titleWithType': 'Convert .{fileType}', + 'convert.convertButton': 'Convert', + 'convert.uploading': 'Uploading...' + }; + let text = fallbacks[`${category}.${key}`] || key; + if (params) { + Object.entries(params).forEach(([k, v]) => { + text = text.replace(`{${k}}`, v); + }); + } + return text; +}; + dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); @@ -36,13 +58,14 @@ dropZone.addEventListener("drop", (e) => { // Extracted handleFile function for reusability in drag-and-drop and file input function handleFile(file) { const fileList = document.querySelector("#file-list"); + const removeText = getTranslation('common', 'remove'); const row = document.createElement("tr"); row.innerHTML = `