feat(i18n): add multi-language UI support with 5 languages

- Add i18n framework with locale detection and cookie persistence
- Support English, Traditional Chinese (zh-TW), Simplified Chinese (zh-CN), Japanese (ja), Korean (ko)
- Add LanguageSelector component in navigation header
- Create locale JSON files with full translations
- Update all page components (root, user, results, history) with i18n support
- Add client-side translation helpers for dynamic content
- Auto-detect user's preferred language from browser settings
- Bump version to v0.1.3
This commit is contained in:
Your Name 2026-01-20 10:01:55 +08:00
parent e792dfedf1
commit 33301033ae
21 changed files with 1205 additions and 89 deletions

View file

@ -1,31 +1,39 @@
import { version } from "../../package.json";
import { type SupportedLocale, defaultLocale, getTranslations } from "../i18n";
export const BaseHtml = ({
children,
title = "ConvertX",
webroot = "",
locale = defaultLocale,
}: {
children: JSX.Element;
title?: string;
webroot?: string;
locale?: SupportedLocale;
}) => (
<html lang="en">
<html lang={locale}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="webroot" content={webroot} />
<meta name="locale" content={locale} />
<title safe>{title}</title>
<link rel="stylesheet" href={`${webroot}/generated.css`} />
<link rel="apple-touch-icon" sizes="180x180" href={`${webroot}/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${webroot}/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${webroot}/favicon-16x16.png`} />
<link rel="manifest" href={`${webroot}/site.webmanifest`} />
<script>
{`window.__TRANSLATIONS__ = ${JSON.stringify(getTranslations(locale))};`}
</script>
<script src={`${webroot}/i18n.js`} defer />
</head>
<body class={`flex min-h-screen w-full flex-col bg-neutral-900 text-neutral-200`}>
{children}
<footer class="w-full">
<div class="p-4 text-center text-sm text-neutral-500">
<span>Powered by </span>
<span>{locale === "en" ? "Powered by " : `${getTranslations(locale).common.poweredBy} `}</span>
<a
href="https://github.com/C4illin/ConvertX"
class={`

View file

@ -1,20 +1,27 @@
import { type SupportedLocale, type Translator, createTranslator, defaultLocale } from "../i18n";
import { LanguageSelector } from "./languageSelector";
export const Header = ({
loggedIn,
accountRegistration,
allowUnauthenticated,
hideHistory,
webroot = "",
locale = defaultLocale,
t = createTranslator(defaultLocale),
}: {
loggedIn?: boolean;
accountRegistration?: boolean;
allowUnauthenticated?: boolean;
hideHistory?: boolean;
webroot?: string;
locale?: SupportedLocale;
t?: Translator;
}) => {
let rightNav: JSX.Element;
if (loggedIn) {
rightNav = (
<ul class="flex gap-4">
<ul class="flex items-center gap-4">
{!hideHistory && (
<li>
<a
@ -24,7 +31,7 @@ export const Header = ({
`}
href={`${webroot}/history`}
>
History
{t("nav", "history")}
</a>
</li>
)}
@ -37,7 +44,7 @@ export const Header = ({
`}
href={`${webroot}/account`}
>
Account
{t("nav", "account")}
</a>
</li>
) : null}
@ -50,15 +57,18 @@ export const Header = ({
`}
href={`${webroot}/logoff`}
>
Logout
{t("nav", "logout")}
</a>
</li>
) : null}
<li>
<LanguageSelector currentLocale={locale} webroot={webroot} t={t} />
</li>
</ul>
);
} else {
rightNav = (
<ul class="flex gap-4">
<ul class="flex items-center gap-4">
<li>
<a
class={`
@ -67,7 +77,7 @@ export const Header = ({
`}
href={`${webroot}/login`}
>
Login
{t("nav", "login")}
</a>
</li>
{accountRegistration ? (
@ -79,10 +89,13 @@ export const Header = ({
`}
href={`${webroot}/register`}
>
Register
{t("nav", "register")}
</a>
</li>
) : null}
<li>
<LanguageSelector currentLocale={locale} webroot={webroot} t={t} />
</li>
</ul>
);
}

View file

@ -0,0 +1,92 @@
import { supportedLocales, type SupportedLocale, type Translator } from "../i18n";
export const LanguageSelector = ({
currentLocale,
webroot = "",
t,
}: {
currentLocale: SupportedLocale;
webroot?: string;
t: Translator;
}) => {
return (
<div class="language-selector relative">
<button
type="button"
class={`
flex items-center gap-1 text-accent-600 transition-all
hover:text-accent-500
`}
id="language-toggle"
aria-label={t("nav", "language")}
aria-expanded="false"
aria-haspopup="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-5 w-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 21l5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 016-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 01-3.827-5.802"
/>
</svg>
<span class="hidden sm:inline">{t("nav", "language")}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<ul
id="language-dropdown"
class={`
absolute right-0 top-full z-50 mt-2 hidden min-w-[140px] flex-col overflow-hidden rounded
border border-neutral-700 bg-neutral-800 shadow-lg
`}
role="menu"
>
{supportedLocales.map((locale) => (
<li role="none">
<button
type="button"
role="menuitem"
class={`
language-option flex w-full items-center gap-2 px-4 py-2 text-left text-sm
transition-colors
hover:bg-neutral-700
${locale.code === currentLocale ? "bg-neutral-700 text-accent-500" : "text-neutral-200"}
`}
data-locale={locale.code}
data-webroot={webroot}
>
{locale.code === currentLocale && (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="h-4 w-4 text-accent-500"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
<span class={locale.code === currentLocale ? "" : "ml-6"}>{locale.nativeName}</span>
</button>
</li>
))}
</ul>
</div>
);
};

178
src/i18n/index.ts Normal file
View file

@ -0,0 +1,178 @@
import en from "../locales/en.json";
import zhTW from "../locales/zh-TW.json";
import zhCN from "../locales/zh-CN.json";
import ja from "../locales/ja.json";
import ko from "../locales/ko.json";
export type SupportedLocale = "en" | "zh-TW" | "zh-CN" | "ja" | "ko";
export interface LocaleConfig {
code: SupportedLocale;
name: string;
nativeName: string;
}
export const supportedLocales: LocaleConfig[] = [
{ code: "en", name: "English", nativeName: "English" },
{ code: "zh-TW", name: "Chinese (Traditional)", nativeName: "繁體中文" },
{ code: "zh-CN", name: "Chinese (Simplified)", nativeName: "简体中文" },
{ code: "ja", name: "Japanese", nativeName: "日本語" },
{ code: "ko", name: "Korean", nativeName: "한국어" },
];
export const defaultLocale: SupportedLocale = "en";
// Type definitions for translation keys
export type TranslationData = typeof en;
export type TranslationKey = keyof TranslationData;
export type NestedTranslationKey<T extends TranslationKey> = keyof TranslationData[T];
const translations: Record<SupportedLocale, TranslationData> = {
en,
"zh-TW": zhTW as TranslationData,
"zh-CN": zhCN as TranslationData,
ja: ja as TranslationData,
ko: ko as TranslationData,
};
/**
* Get translation for a given key
* @param locale - The locale code
* @param category - The translation category (e.g., 'common', 'nav', 'convert')
* @param key - The translation key within the category
* @param params - Optional parameters for string interpolation
* @returns The translated string
*/
export function t<T extends TranslationKey>(
locale: SupportedLocale,
category: T,
key: NestedTranslationKey<T>,
params?: Record<string, string | number>,
): string {
const translation = translations[locale]?.[category]?.[key as keyof TranslationData[T]];
if (typeof translation !== "string") {
// Fallback to English
const fallback =
translations[defaultLocale]?.[category]?.[key as keyof TranslationData[T]];
if (typeof fallback !== "string") {
// Log missing translation in development
// eslint-disable-next-line no-console
console.warn(`Missing translation: ${category}.${String(key)}`);
return `${category}.${String(key)}`;
}
return interpolate(fallback, params);
}
return interpolate(translation, params);
}
/**
* Interpolate parameters into a translation string
* @param text - The translation string with placeholders like {key}
* @param params - The parameters to interpolate
* @returns The interpolated string
*/
function interpolate(text: string, params?: Record<string, string | number>): string {
if (!params) return text;
return text.replace(/\{(\w+)\}/g, (match, key) => {
return params[key] !== undefined ? String(params[key]) : match;
});
}
/**
* Detect the best locale based on the Accept-Language header
* @param acceptLanguage - The Accept-Language header value
* @returns The best matching locale
*/
export function detectLocale(acceptLanguage?: string): SupportedLocale {
if (!acceptLanguage) return defaultLocale;
// Parse Accept-Language header
const languages = acceptLanguage
.split(",")
.map((lang) => {
const [code, priority] = lang.trim().split(";q=");
return {
code: code?.toLowerCase().trim() ?? "",
priority: priority ? Number.parseFloat(priority) : 1,
};
})
.sort((a, b) => b.priority - a.priority);
// Try to find a match
for (const lang of languages) {
// Direct match
if (isValidLocale(lang.code)) {
return lang.code as SupportedLocale;
}
// Check for language variants (e.g., zh-tw, zh-hant)
if (lang.code.startsWith("zh")) {
if (lang.code.includes("tw") || lang.code.includes("hant") || lang.code.includes("hk")) {
return "zh-TW";
}
if (lang.code.includes("cn") || lang.code.includes("hans") || lang.code === "zh") {
return "zh-CN";
}
}
// Check base language
const baseCode = lang.code.split("-")[0];
if (baseCode === "ja") return "ja";
if (baseCode === "ko") return "ko";
if (baseCode === "en") return "en";
}
return defaultLocale;
}
/**
* Check if a locale code is valid
* @param locale - The locale code to check
* @returns Whether the locale is valid
*/
export function isValidLocale(locale: string): locale is SupportedLocale {
return supportedLocales.some(
(l) => l.code.toLowerCase() === locale.toLowerCase(),
);
}
/**
* Get locale from string, with validation
* @param locale - The locale string
* @returns The valid locale or default
*/
export function getLocale(locale?: string): SupportedLocale {
if (!locale) return defaultLocale;
// Handle case-insensitive matching
const normalized = locale.toLowerCase();
const found = supportedLocales.find((l) => l.code.toLowerCase() === normalized);
return found?.code ?? defaultLocale;
}
/**
* Create a translator function bound to a specific locale
* @param locale - The locale to use
* @returns A translation function
*/
export function createTranslator(locale: SupportedLocale) {
return <T extends TranslationKey>(
category: T,
key: NestedTranslationKey<T>,
params?: Record<string, string | number>,
) => t(locale, category, key, params);
}
export type Translator = ReturnType<typeof createTranslator>;
// Export all translations for client-side use
export function getTranslations(locale: SupportedLocale): TranslationData {
return translations[locale] ?? translations[defaultLocale];
}
// Export locale codes as array
export const localeCodes = supportedLocales.map((l) => l.code);

44
src/i18n/service.ts Normal file
View file

@ -0,0 +1,44 @@
import { Elysia, t } from "elysia";
import {
type SupportedLocale,
createTranslator,
defaultLocale,
detectLocale,
getLocale,
} from "../i18n";
/**
* Elysia plugin for i18n/locale handling
* Adds locale detection and translator to the context
*/
export const localeService = new Elysia({ name: "locale/service" })
.model({
localeSession: t.Cookie({
locale: t.Optional(t.String()),
}),
})
.derive({ as: "global" }, ({ cookie, request }) => {
// Get locale from cookie or detect from Accept-Language header
const cookieLocale = cookie.locale?.value;
const acceptLanguage = request.headers.get("accept-language") ?? undefined;
let locale: SupportedLocale;
if (cookieLocale) {
locale = getLocale(cookieLocale);
} else {
locale = detectLocale(acceptLanguage);
}
const t = createTranslator(locale);
return {
locale,
t,
};
});
export type LocaleContext = {
locale: SupportedLocale;
t: ReturnType<typeof createTranslator>;
};

106
src/locales/en.json Normal file
View file

@ -0,0 +1,106 @@
{
"common": {
"appName": "ConvertX",
"poweredBy": "Powered by",
"version": "v{version}",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"confirm": "Confirm",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"delete": "Delete",
"remove": "Remove",
"download": "Download",
"upload": "Upload",
"submit": "Submit",
"back": "Back",
"next": "Next",
"yes": "Yes",
"no": "No"
},
"nav": {
"history": "History",
"account": "Account",
"logout": "Logout",
"login": "Login",
"register": "Register",
"language": "Language"
},
"convert": {
"title": "Convert",
"titleWithType": "Convert .{fileType}",
"chooseFile": "Choose a file",
"orDragHere": "or drag it here",
"searchConversions": "Search for conversions",
"convertTo": "Convert to",
"convertButton": "Convert",
"uploading": "Uploading...",
"processing": "Processing..."
},
"results": {
"title": "Results",
"convertedFileName": "Converted File Name",
"status": "Status",
"actions": "Actions",
"downloadAll": "All",
"downloadTar": "Tar",
"deleteJob": "Delete",
"noFiles": "No files converted yet.",
"statusPending": "Pending",
"statusCompleted": "Completed",
"statusFailed": "Failed"
},
"history": {
"title": "Results",
"time": "Time",
"files": "Files",
"filesDone": "Files Done",
"status": "Status",
"actions": "Actions",
"expandDetails": "Expand details",
"detailedFileInfo": "Detailed File Information:",
"deleteSelected": "Delete Selected",
"selectAll": "Select all",
"confirmDelete": "Are you sure you want to delete {count} job(s)? This action cannot be undone.",
"deleteSuccess": "Successfully deleted {deleted} job(s).",
"deleteFailed": "Failed to delete {failed} job(s).",
"deleteError": "An error occurred while deleting jobs. Please try again."
},
"auth": {
"email": "Email",
"password": "Password",
"currentPassword": "Current Password",
"newPassword": "Password (leave blank for unchanged)",
"loginButton": "Login",
"registerButton": "Register",
"updateButton": "Update",
"createAccount": "Create account",
"invalidCredentials": "Invalid credentials.",
"emailInUse": "Email already in use.",
"unauthorized": "Unauthorized",
"cookiesRequired": "Cookies should be enabled to use this app.",
"noCookies": "No auth cookie, perhaps your browser is blocking cookies."
},
"setup": {
"welcome": "Welcome to ConvertX!",
"createYourAccount": "Create your account",
"reportIssues": "Report any issues on",
"github": "GitHub"
},
"errors": {
"jobNotFound": "Job not found.",
"fileNotFound": "File not found.",
"uploadFailed": "Upload failed.",
"conversionFailed": "Conversion failed.",
"serverError": "Server error."
},
"upload": {
"success": "Files uploaded successfully.",
"dragDrop": "Drop files here"
},
"file": {
"size": "{size} kB"
}
}

106
src/locales/ja.json Normal file
View file

@ -0,0 +1,106 @@
{
"common": {
"appName": "ConvertX",
"poweredBy": "Powered by",
"version": "v{version}",
"loading": "読み込み中...",
"error": "エラー",
"success": "成功",
"confirm": "確認",
"cancel": "キャンセル",
"close": "閉じる",
"save": "保存",
"delete": "削除",
"remove": "削除",
"download": "ダウンロード",
"upload": "アップロード",
"submit": "送信",
"back": "戻る",
"next": "次へ",
"yes": "はい",
"no": "いいえ"
},
"nav": {
"history": "履歴",
"account": "アカウント",
"logout": "ログアウト",
"login": "ログイン",
"register": "登録",
"language": "言語"
},
"convert": {
"title": "変換",
"titleWithType": ".{fileType} を変換",
"chooseFile": "ファイルを選択",
"orDragHere": "またはここにドラッグ",
"searchConversions": "変換形式を検索",
"convertTo": "変換先",
"convertButton": "変換",
"uploading": "アップロード中...",
"processing": "処理中..."
},
"results": {
"title": "結果",
"convertedFileName": "変換後のファイル名",
"status": "ステータス",
"actions": "操作",
"downloadAll": "すべて",
"downloadTar": "Tar",
"deleteJob": "削除",
"noFiles": "まだファイルが変換されていません。",
"statusPending": "処理中",
"statusCompleted": "完了",
"statusFailed": "失敗"
},
"history": {
"title": "結果",
"time": "時間",
"files": "ファイル数",
"filesDone": "完了",
"status": "ステータス",
"actions": "操作",
"expandDetails": "詳細を展開",
"detailedFileInfo": "ファイル詳細情報:",
"deleteSelected": "選択を削除",
"selectAll": "すべて選択",
"confirmDelete": "{count}件のジョブを削除しますか?この操作は取り消せません。",
"deleteSuccess": "{deleted}件のジョブを削除しました。",
"deleteFailed": "{failed}件のジョブの削除に失敗しました。",
"deleteError": "ジョブの削除中にエラーが発生しました。もう一度お試しください。"
},
"auth": {
"email": "メールアドレス",
"password": "パスワード",
"currentPassword": "現在のパスワード",
"newPassword": "パスワード(変更しない場合は空欄)",
"loginButton": "ログイン",
"registerButton": "登録",
"updateButton": "更新",
"createAccount": "アカウントを作成",
"invalidCredentials": "認証情報が無効です。",
"emailInUse": "このメールアドレスは既に使用されています。",
"unauthorized": "未認証",
"cookiesRequired": "このアプリを使用するにはCookieを有効にしてください。",
"noCookies": "認証Cookieが見つかりません。ブラウザがCookieをブロックしている可能性があります。"
},
"setup": {
"welcome": "ConvertXへようこそ",
"createYourAccount": "アカウントを作成",
"reportIssues": "問題があれば報告",
"github": "GitHub"
},
"errors": {
"jobNotFound": "ジョブが見つかりません。",
"fileNotFound": "ファイルが見つかりません。",
"uploadFailed": "アップロードに失敗しました。",
"conversionFailed": "変換に失敗しました。",
"serverError": "サーバーエラー。"
},
"upload": {
"success": "ファイルのアップロードに成功しました。",
"dragDrop": "ここにファイルをドロップ"
},
"file": {
"size": "{size} kB"
}
}

106
src/locales/ko.json Normal file
View file

@ -0,0 +1,106 @@
{
"common": {
"appName": "ConvertX",
"poweredBy": "Powered by",
"version": "v{version}",
"loading": "로딩 중...",
"error": "오류",
"success": "성공",
"confirm": "확인",
"cancel": "취소",
"close": "닫기",
"save": "저장",
"delete": "삭제",
"remove": "제거",
"download": "다운로드",
"upload": "업로드",
"submit": "제출",
"back": "뒤로",
"next": "다음",
"yes": "예",
"no": "아니오"
},
"nav": {
"history": "기록",
"account": "계정",
"logout": "로그아웃",
"login": "로그인",
"register": "회원가입",
"language": "언어"
},
"convert": {
"title": "변환",
"titleWithType": ".{fileType} 변환",
"chooseFile": "파일 선택",
"orDragHere": "또는 여기로 드래그",
"searchConversions": "변환 형식 검색",
"convertTo": "변환 대상",
"convertButton": "변환",
"uploading": "업로드 중...",
"processing": "처리 중..."
},
"results": {
"title": "결과",
"convertedFileName": "변환된 파일명",
"status": "상태",
"actions": "작업",
"downloadAll": "전체",
"downloadTar": "Tar",
"deleteJob": "삭제",
"noFiles": "아직 변환된 파일이 없습니다.",
"statusPending": "처리 중",
"statusCompleted": "완료",
"statusFailed": "실패"
},
"history": {
"title": "결과",
"time": "시간",
"files": "파일 수",
"filesDone": "완료됨",
"status": "상태",
"actions": "작업",
"expandDetails": "상세 정보 펼치기",
"detailedFileInfo": "파일 상세 정보:",
"deleteSelected": "선택 삭제",
"selectAll": "전체 선택",
"confirmDelete": "{count}개의 작업을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"deleteSuccess": "{deleted}개의 작업이 삭제되었습니다.",
"deleteFailed": "{failed}개의 작업 삭제에 실패했습니다.",
"deleteError": "작업 삭제 중 오류가 발생했습니다. 다시 시도해 주세요."
},
"auth": {
"email": "이메일",
"password": "비밀번호",
"currentPassword": "현재 비밀번호",
"newPassword": "비밀번호 (변경하지 않으려면 빈칸)",
"loginButton": "로그인",
"registerButton": "회원가입",
"updateButton": "업데이트",
"createAccount": "계정 생성",
"invalidCredentials": "잘못된 인증 정보입니다.",
"emailInUse": "이미 사용 중인 이메일입니다.",
"unauthorized": "인증되지 않음",
"cookiesRequired": "이 앱을 사용하려면 쿠키를 활성화해야 합니다.",
"noCookies": "인증 쿠키를 찾을 수 없습니다. 브라우저에서 쿠키를 차단하고 있을 수 있습니다."
},
"setup": {
"welcome": "ConvertX에 오신 것을 환영합니다!",
"createYourAccount": "계정 생성",
"reportIssues": "문제가 있으시면 보고해 주세요",
"github": "GitHub"
},
"errors": {
"jobNotFound": "작업을 찾을 수 없습니다.",
"fileNotFound": "파일을 찾을 수 없습니다.",
"uploadFailed": "업로드에 실패했습니다.",
"conversionFailed": "변환에 실패했습니다.",
"serverError": "서버 오류."
},
"upload": {
"success": "파일이 업로드되었습니다.",
"dragDrop": "여기에 파일을 놓으세요"
},
"file": {
"size": "{size} kB"
}
}

106
src/locales/zh-CN.json Normal file
View file

@ -0,0 +1,106 @@
{
"common": {
"appName": "ConvertX",
"poweredBy": "由",
"version": "v{version}",
"loading": "加载中...",
"error": "错误",
"success": "成功",
"confirm": "确认",
"cancel": "取消",
"close": "关闭",
"save": "保存",
"delete": "删除",
"remove": "移除",
"download": "下载",
"upload": "上传",
"submit": "提交",
"back": "返回",
"next": "下一步",
"yes": "是",
"no": "否"
},
"nav": {
"history": "历史记录",
"account": "账户",
"logout": "登出",
"login": "登录",
"register": "注册",
"language": "语言"
},
"convert": {
"title": "转换",
"titleWithType": "转换 .{fileType}",
"chooseFile": "选择文件",
"orDragHere": "或拖放到这里",
"searchConversions": "搜索转换格式",
"convertTo": "转换为",
"convertButton": "转换",
"uploading": "上传中...",
"processing": "处理中..."
},
"results": {
"title": "结果",
"convertedFileName": "转换后文件名",
"status": "状态",
"actions": "操作",
"downloadAll": "全部",
"downloadTar": "Tar",
"deleteJob": "删除",
"noFiles": "尚未转换任何文件。",
"statusPending": "处理中",
"statusCompleted": "已完成",
"statusFailed": "失败"
},
"history": {
"title": "结果",
"time": "时间",
"files": "文件数",
"filesDone": "已完成",
"status": "状态",
"actions": "操作",
"expandDetails": "展开详情",
"detailedFileInfo": "文件详细信息:",
"deleteSelected": "删除所选",
"selectAll": "全选",
"confirmDelete": "确定要删除 {count} 个任务吗?此操作无法撤销。",
"deleteSuccess": "已成功删除 {deleted} 个任务。",
"deleteFailed": "删除 {failed} 个任务失败。",
"deleteError": "删除任务时发生错误,请重试。"
},
"auth": {
"email": "电子邮箱",
"password": "密码",
"currentPassword": "当前密码",
"newPassword": "密码(如不更改请留空)",
"loginButton": "登录",
"registerButton": "注册",
"updateButton": "更新",
"createAccount": "创建账户",
"invalidCredentials": "凭证无效。",
"emailInUse": "电子邮箱已被使用。",
"unauthorized": "未授权",
"cookiesRequired": "使用此应用程序需要启用 Cookie。",
"noCookies": "无法获取验证 Cookie您的浏览器可能阻止了 Cookie。"
},
"setup": {
"welcome": "欢迎使用 ConvertX",
"createYourAccount": "创建您的账户",
"reportIssues": "在以下网址报告问题",
"github": "GitHub"
},
"errors": {
"jobNotFound": "找不到任务。",
"fileNotFound": "找不到文件。",
"uploadFailed": "上传失败。",
"conversionFailed": "转换失败。",
"serverError": "服务器错误。"
},
"upload": {
"success": "文件上传成功。",
"dragDrop": "将文件拖放至此"
},
"file": {
"size": "{size} kB"
}
}

106
src/locales/zh-TW.json Normal file
View file

@ -0,0 +1,106 @@
{
"common": {
"appName": "ConvertX",
"poweredBy": "由",
"version": "v{version}",
"loading": "載入中...",
"error": "錯誤",
"success": "成功",
"confirm": "確認",
"cancel": "取消",
"close": "關閉",
"save": "儲存",
"delete": "刪除",
"remove": "移除",
"download": "下載",
"upload": "上傳",
"submit": "提交",
"back": "返回",
"next": "下一步",
"yes": "是",
"no": "否"
},
"nav": {
"history": "歷史記錄",
"account": "帳戶",
"logout": "登出",
"login": "登入",
"register": "註冊",
"language": "語言"
},
"convert": {
"title": "轉換",
"titleWithType": "轉換 .{fileType}",
"chooseFile": "選擇檔案",
"orDragHere": "或拖曳到這裡",
"searchConversions": "搜尋轉換格式",
"convertTo": "轉換為",
"convertButton": "轉換",
"uploading": "上傳中...",
"processing": "處理中..."
},
"results": {
"title": "結果",
"convertedFileName": "轉換後檔名",
"status": "狀態",
"actions": "操作",
"downloadAll": "全部",
"downloadTar": "Tar",
"deleteJob": "刪除",
"noFiles": "尚未轉換任何檔案。",
"statusPending": "處理中",
"statusCompleted": "已完成",
"statusFailed": "失敗"
},
"history": {
"title": "結果",
"time": "時間",
"files": "檔案數",
"filesDone": "已完成",
"status": "狀態",
"actions": "操作",
"expandDetails": "展開詳情",
"detailedFileInfo": "檔案詳細資訊:",
"deleteSelected": "刪除選取",
"selectAll": "全選",
"confirmDelete": "確定要刪除 {count} 個任務嗎?此操作無法復原。",
"deleteSuccess": "已成功刪除 {deleted} 個任務。",
"deleteFailed": "刪除 {failed} 個任務失敗。",
"deleteError": "刪除任務時發生錯誤,請重試。"
},
"auth": {
"email": "電子郵件",
"password": "密碼",
"currentPassword": "目前密碼",
"newPassword": "密碼(如不變更請留空)",
"loginButton": "登入",
"registerButton": "註冊",
"updateButton": "更新",
"createAccount": "建立帳戶",
"invalidCredentials": "憑證無效。",
"emailInUse": "電子郵件已被使用。",
"unauthorized": "未授權",
"cookiesRequired": "使用此應用程式需要啟用 Cookie。",
"noCookies": "無法取得驗證 Cookie您的瀏覽器可能阻擋了 Cookie。"
},
"setup": {
"welcome": "歡迎使用 ConvertX",
"createYourAccount": "建立您的帳戶",
"reportIssues": "在以下網址回報問題",
"github": "GitHub"
},
"errors": {
"jobNotFound": "找不到任務。",
"fileNotFound": "找不到檔案。",
"uploadFailed": "上傳失敗。",
"conversionFailed": "轉換失敗。",
"serverError": "伺服器錯誤。"
},
"upload": {
"success": "檔案上傳成功。",
"dragDrop": "將檔案拖曳至此"
},
"file": {
"size": "{size} kB"
}
}

View file

@ -4,13 +4,14 @@ import { Header } from "../components/header";
import db from "../db/db";
import { Filename, Jobs } from "../db/types";
import { ALLOW_UNAUTHENTICATED, HIDE_HISTORY, LANGUAGE, TIMEZONE, WEBROOT } from "../helpers/env";
import { localeService } from "../i18n/service";
import { userService } from "./user";
import { EyeIcon } from "../icons/eye";
import { DeleteIcon } from "../icons/delete";
export const history = new Elysia().use(userService).get(
export const history = new Elysia().use(userService).use(localeService).get(
"/history",
async ({ redirect, user }) => {
async ({ redirect, user, locale, t }) => {
if (HIDE_HISTORY) {
return redirect(`${WEBROOT}/`, 302);
}
@ -32,13 +33,15 @@ export const history = new Elysia().use(userService).get(
userJobs = userJobs.filter((job) => job.num_files > 0);
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Results">
<BaseHtml webroot={WEBROOT} title="ConvertX | Results" locale={locale}>
<>
<Header
webroot={WEBROOT}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
locale={locale}
t={t}
/>
<main
class={`
@ -48,7 +51,7 @@ export const history = new Elysia().use(userService).get(
>
<article class="article">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl">Results</h1>
<h1 class="text-xl">{t("history", "title")}</h1>
<div id="delete-selected-container">
<button
id="delete-selected-btn"
@ -60,7 +63,7 @@ export const history = new Elysia().use(userService).get(
>
<DeleteIcon />{" "}
<span>
Delete Selected (<span id="selected-count">0</span>)
{t("history", "deleteSelected")} (<span id="selected-count">0</span>)
</span>
</button>
</div>
@ -84,7 +87,7 @@ export const history = new Elysia().use(userService).get(
type="checkbox"
id="select-all"
class="h-4 w-4 cursor-pointer"
title="Select all"
title={t("history", "selectAll")}
/>
</th>
<th
@ -93,7 +96,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4
`}
>
<span class="sr-only">Expand details</span>
<span class="sr-only">{t("history", "expandDetails")}</span>
</th>
<th
class={`
@ -101,7 +104,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4
`}
>
Time
{t("history", "time")}
</th>
<th
class={`
@ -109,7 +112,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4
`}
>
Files
{t("history", "files")}
</th>
<th
class={`
@ -118,7 +121,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4
`}
>
Files Done
{t("history", "filesDone")}
</th>
<th
class={`
@ -126,7 +129,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4
`}
>
Status
{t("history", "status")}
</th>
<th
class={`
@ -134,7 +137,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4
`}
>
Actions
{t("history", "actions")}
</th>
</tr>
</thead>
@ -199,7 +202,7 @@ export const history = new Elysia().use(userService).get(
<tr id={`details-${job.id}`} class="hidden">
<td colspan="7">
<div class="p-2 text-sm text-neutral-500">
<div class="mb-1 font-semibold">Detailed File Information:</div>
<div class="mb-1 font-semibold">{t("history", "detailedFileInfo")}</div>
{job.files_detailed.map((file: Filename) => (
<div class="flex items-center">
<span class="w-5/12 truncate" title={file.file_name} safe>

View file

@ -7,21 +7,25 @@ import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env";
import { DownloadIcon } from "../icons/download";
import { DeleteIcon } from "../icons/delete";
import { EyeIcon } from "../icons/eye";
import { type SupportedLocale, type Translator, createTranslator, defaultLocale } from "../i18n";
import { localeService } from "../i18n/service";
import { userService } from "./user";
function ResultsArticle({
job,
files,
outputPath,
t = createTranslator(defaultLocale),
}: {
job: Jobs;
files: Filename[];
outputPath: string;
t?: Translator;
}) {
return (
<article class="article">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl">Results</h1>
<h1 class="text-xl">{t("results", "title")}</h1>
<div class="flex flex-row gap-4">
<a
style={files.length !== job.num_files ? "pointer-events: none;" : ""}
@ -29,7 +33,7 @@ function ResultsArticle({
href={`${WEBROOT}/delete/${job.id}`}
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")}
>
<DeleteIcon /> <p>Delete</p>
<DeleteIcon /> <p>{t("common", "delete")}</p>
</a>
<a
style={files.length !== job.num_files ? "pointer-events: none;" : ""}
@ -38,10 +42,10 @@ function ResultsArticle({
class="flex btn-primary flex-row gap-2 text-contrast"
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")}
>
<DownloadIcon /> <p>Tar</p>
<DownloadIcon /> <p>{t("results", "downloadTar")}</p>
</a>
<button class="flex btn-primary flex-row gap-2 text-contrast" onclick="downloadAll()">
<DownloadIcon /> <p>All</p>
<DownloadIcon /> <p>{t("results", "downloadAll")}</p>
</button>
</div>
</div>
@ -72,7 +76,7 @@ function ResultsArticle({
sm:px-4
`}
>
Converted File Name
{t("results", "convertedFileName")}
</th>
<th
class={`
@ -80,7 +84,7 @@ function ResultsArticle({
sm:px-4
`}
>
Status
{t("results", "status")}
</th>
<th
class={`
@ -88,7 +92,7 @@ function ResultsArticle({
sm:px-4
`}
>
Actions
{t("results", "actions")}
</th>
</tr>
</thead>
@ -130,9 +134,10 @@ function ResultsArticle({
export const results = new Elysia()
.use(userService)
.use(localeService)
.get(
"/results/:jobId",
async ({ params, set, cookie: { job_id }, user }) => {
async ({ params, set, cookie: { job_id }, user, locale, t }) => {
if (job_id?.value) {
// Clear the job_id cookie since we are viewing the results
job_id.remove();
@ -146,7 +151,7 @@ export const results = new Elysia()
if (!job) {
set.status = 404;
return {
message: "Job not found.",
message: t("errors", "jobNotFound"),
};
}
@ -158,16 +163,16 @@ export const results = new Elysia()
.all(params.jobId);
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Result">
<BaseHtml webroot={WEBROOT} title="ConvertX | Result" locale={locale}>
<>
<Header webroot={WEBROOT} allowUnauthenticated={ALLOW_UNAUTHENTICATED} loggedIn />
<Header webroot={WEBROOT} allowUnauthenticated={ALLOW_UNAUTHENTICATED} loggedIn locale={locale} t={t} />
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<ResultsArticle job={job} files={files} outputPath={outputPath} />
<ResultsArticle job={job} files={files} outputPath={outputPath} t={t} />
</main>
<script src={`${WEBROOT}/results.js`} defer />
</>
@ -178,7 +183,7 @@ export const results = new Elysia()
)
.post(
"/progress/:jobId",
async ({ set, params, cookie: { job_id }, user }) => {
async ({ set, params, cookie: { job_id }, user, t }) => {
if (job_id?.value) {
// Clear the job_id cookie since we are viewing the results
job_id.remove();
@ -192,7 +197,7 @@ export const results = new Elysia()
if (!job) {
set.status = 404;
return {
message: "Job not found.",
message: t("errors", "jobNotFound"),
};
}
@ -203,7 +208,7 @@ export const results = new Elysia()
.as(Filename)
.all(params.jobId);
return <ResultsArticle job={job} files={files} outputPath={outputPath} />;
return <ResultsArticle job={job} files={files} outputPath={outputPath} t={t} />;
},
{ auth: true },
);

View file

@ -14,11 +14,12 @@ import {
UNAUTHENTICATED_USER_SHARING,
WEBROOT,
} from "../helpers/env";
import { localeService } from "../i18n/service";
import { FIRST_RUN, userService } from "./user";
export const root = new Elysia().use(userService).get(
export const root = new Elysia().use(userService).use(localeService).get(
"/",
async ({ jwt, redirect, cookie: { auth, jobId } }) => {
async ({ jwt, redirect, cookie: { auth, jobId }, locale, t }) => {
if (!ALLOW_UNAUTHENTICATED) {
if (FIRST_RUN) {
return redirect(`${WEBROOT}/setup`, 302);
@ -44,7 +45,7 @@ export const root = new Elysia().use(userService).get(
user = { id: newUserId };
if (!auth) {
return {
message: "No auth cookie, perhaps your browser is blocking cookies.",
message: t("auth", "noCookies"),
};
}
@ -91,7 +92,7 @@ export const root = new Elysia().use(userService).get(
.get(user.id) as { id: number };
if (!jobId) {
return { message: "Cookies should be enabled to use this app." };
return { message: t("auth", "cookiesRequired") };
}
jobId.set({
@ -105,7 +106,7 @@ export const root = new Elysia().use(userService).get(
console.log("jobId set to:", id);
return (
<BaseHtml webroot={WEBROOT}>
<BaseHtml webroot={WEBROOT} locale={locale}>
<>
<Header
webroot={WEBROOT}
@ -113,6 +114,8 @@ export const root = new Elysia().use(userService).get(
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
locale={locale}
t={t}
/>
<main
class={`
@ -121,7 +124,7 @@ export const root = new Elysia().use(userService).get(
`}
>
<article class="article">
<h1 class="mb-4 text-xl">Convert</h1>
<h1 class="mb-4 text-xl">{t("convert", "title")}</h1>
<div class="mb-4 scrollbar-thin max-h-[50vh] overflow-y-auto">
<table
id="file-list"
@ -142,7 +145,7 @@ export const root = new Elysia().use(userService).get(
`}
>
<span>
<b>Choose a file</b> or drag it here
<b>{t("convert", "chooseFile")}</b> {t("convert", "orDragHere")}
</span>
<input
type="file"
@ -162,7 +165,7 @@ export const root = new Elysia().use(userService).get(
<input
type="search"
name="convert_to_search"
placeholder="Search for conversions"
placeholder={t("convert", "searchConversions")}
autocomplete="off"
class="w-full rounded-sm bg-neutral-800 p-4"
/>
@ -208,9 +211,9 @@ export const root = new Elysia().use(userService).get(
</article>
{/* Hidden element which determines the format to convert the file too and the converter to use */}
<select name="convert_to" aria-label="Convert to" required hidden>
<select name="convert_to" aria-label={t("convert", "convertTo")} required hidden>
<option selected disabled value="">
Convert to
{t("convert", "convertTo")}
</option>
{Object.entries(getAllTargets()).map(([converter, targets]) => (
<optgroup label={converter}>
@ -230,7 +233,7 @@ export const root = new Elysia().use(userService).get(
disabled:cursor-not-allowed disabled:opacity-50
`}
type="submit"
value="Convert"
value={t("convert", "convertButton")}
disabled
/>
</form>
@ -244,6 +247,7 @@ export const root = new Elysia().use(userService).get(
cookie: t.Cookie({
auth: t.Optional(t.String()),
jobId: t.Optional(t.String()),
locale: t.Optional(t.String()),
}),
},
);

View file

@ -12,6 +12,7 @@ import {
HTTP_ALLOWED,
WEBROOT,
} from "../helpers/env";
import { localeService } from "../i18n/service";
export let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false;
@ -65,51 +66,52 @@ export const userService = new Elysia({ name: "user/service" })
export const user = new Elysia()
.use(userService)
.get("/setup", ({ redirect }) => {
.use(localeService)
.get("/setup", ({ redirect, locale, t }) => {
if (!FIRST_RUN) {
return redirect(`${WEBROOT}/login`, 302);
}
return (
<BaseHtml title="ConvertX | Setup" webroot={WEBROOT}>
<BaseHtml title="ConvertX | Setup" webroot={WEBROOT} locale={locale}>
<main
class={`
mx-auto w-full max-w-4xl flex-1 px-2
sm:px-4
`}
>
<h1 class="my-8 text-3xl">Welcome to ConvertX!</h1>
<h1 class="my-8 text-3xl">{t("setup", "welcome")}</h1>
<article class="article p-0">
<header class="w-full bg-neutral-800 p-4">Create your account</header>
<header class="w-full bg-neutral-800 p-4">{t("setup", "createYourAccount")}</header>
<form method="post" action={`${WEBROOT}/register`} class="p-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
{t("auth", "email")}
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
placeholder={t("auth", "email")}
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
{t("auth", "password")}
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
placeholder={t("auth", "password")}
autocomplete="current-password"
required
/>
</label>
</fieldset>
<input type="submit" value="Create account" class="btn-primary" />
<input type="submit" value={t("auth", "createAccount")} class="btn-primary" />
</form>
<footer class="p-4">
Report any issues on{" "}
{t("setup", "reportIssues")}{" "}
<a
class={`
text-accent-500 underline
@ -117,7 +119,7 @@ export const user = new Elysia()
`}
href="https://github.com/C4illin/ConvertX"
>
GitHub
{t("setup", "github")}
</a>
.
</footer>
@ -126,19 +128,21 @@ export const user = new Elysia()
</BaseHtml>
);
})
.get("/register", ({ redirect }) => {
.get("/register", ({ redirect, locale, t }) => {
if (!ACCOUNT_REGISTRATION) {
return redirect(`${WEBROOT}/login`, 302);
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Register">
<BaseHtml webroot={WEBROOT} title="ConvertX | Register" locale={locale}>
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
locale={locale}
t={t}
/>
<main
class={`
@ -150,29 +154,29 @@ export const user = new Elysia()
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
{t("auth", "email")}
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
placeholder={t("auth", "email")}
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
{t("auth", "password")}
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
placeholder={t("auth", "password")}
autocomplete="current-password"
required
/>
</label>
</fieldset>
<input type="submit" value="Register" class="w-full btn-primary" />
<input type="submit" value={t("auth", "registerButton")} class="w-full btn-primary" />
</form>
</article>
</main>
@ -237,7 +241,7 @@ export const user = new Elysia()
)
.get(
"/login",
async ({ jwt, redirect, cookie: { auth } }) => {
async ({ jwt, redirect, cookie: { auth }, locale, t }) => {
if (FIRST_RUN) {
return redirect(`${WEBROOT}/setup`, 302);
}
@ -254,13 +258,15 @@ export const user = new Elysia()
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Login">
<BaseHtml webroot={WEBROOT} title="ConvertX | Login" locale={locale}>
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
locale={locale}
t={t}
/>
<main
class={`
@ -272,23 +278,23 @@ export const user = new Elysia()
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
{t("auth", "email")}
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
placeholder={t("auth", "email")}
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
{t("auth", "password")}
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
placeholder={t("auth", "password")}
autocomplete="current-password"
required
/>
@ -301,10 +307,10 @@ export const user = new Elysia()
role="button"
class="w-full btn-secondary text-center"
>
Register
{t("nav", "register")}
</a>
) : null}
<input type="submit" value="Login" class="w-full btn-primary" />
<input type="submit" value={t("auth", "loginButton")} class="w-full btn-primary" />
</div>
</form>
</article>
@ -376,7 +382,7 @@ export const user = new Elysia()
})
.get(
"/account",
async ({ user, redirect }) => {
async ({ user, redirect, locale, t }) => {
if (!user) {
return redirect(`${WEBROOT}/`, 302);
}
@ -388,7 +394,7 @@ export const user = new Elysia()
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Account">
<BaseHtml webroot={WEBROOT} title="ConvertX | Account" locale={locale}>
<>
<Header
webroot={WEBROOT}
@ -396,6 +402,8 @@ export const user = new Elysia()
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
locale={locale}
t={t}
/>
<main
class={`
@ -407,41 +415,41 @@ export const user = new Elysia()
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
{t("auth", "email")}
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
placeholder={t("auth", "email")}
autocomplete="email"
value={userData.email}
required
/>
</label>
<label class="flex flex-col gap-1">
Password (leave blank for unchanged)
{t("auth", "newPassword")}
<input
type="password"
name="newPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
placeholder={t("auth", "password")}
autocomplete="new-password"
/>
</label>
<label class="flex flex-col gap-1">
Current Password
{t("auth", "currentPassword")}
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
placeholder={t("auth", "password")}
autocomplete="current-password"
required
/>
</label>
</fieldset>
<div role="group">
<input type="submit" value="Update" class="w-full btn-primary" />
<input type="submit" value={t("auth", "updateButton")} class="w-full btn-primary" />
</div>
</form>
</article>