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

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