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>
);
};