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,5 +1,31 @@
# Changelog # 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) ## [0.15.0](https://github.com/C4illin/ConvertX/compare/v0.14.1...v0.15.0) (2025-10-07)
### Features ### Features

View file

@ -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] > [!NOTE]

View file

@ -9,6 +9,7 @@
"@elysiajs/jwt": "^1.4.0", "@elysiajs/jwt": "^1.4.0",
"@elysiajs/static": "^1.4.6", "@elysiajs/static": "^1.4.6",
"@kitajs/html": "^4.2.11", "@kitajs/html": "^4.2.11",
"@sinclair/typebox": "^0.34.47",
"elysia": "^1.4.16", "elysia": "^1.4.16",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"tar": "^7.5.2", "tar": "^7.5.2",

View file

@ -1,6 +1,6 @@
{ {
"name": "convertx-frontend", "name": "convertx-frontend",
"version": "0.17.0", "version": "0.1.3",
"scripts": { "scripts": {
"dev": "bun run --watch src/index.tsx", "dev": "bun run --watch src/index.tsx",
"hot": "bun run --hot src/index.tsx", "hot": "bun run --hot src/index.tsx",
@ -20,6 +20,7 @@
"@elysiajs/jwt": "^1.4.0", "@elysiajs/jwt": "^1.4.0",
"@elysiajs/static": "^1.4.6", "@elysiajs/static": "^1.4.6",
"@kitajs/html": "^4.2.11", "@kitajs/html": "^4.2.11",
"@sinclair/typebox": "^0.34.47",
"elysia": "^1.4.16", "elysia": "^1.4.16",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"tar": "^7.5.2" "tar": "^7.5.2"

143
public/i18n.js Normal file
View file

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

View file

@ -7,6 +7,28 @@ let fileType;
let pendingFiles = 0; let pendingFiles = 0;
let formatSelected = false; 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) => { dropZone.addEventListener("dragover", (e) => {
e.preventDefault(); e.preventDefault();
dropZone.classList.add("dragover"); dropZone.classList.add("dragover");
@ -36,13 +58,14 @@ dropZone.addEventListener("drop", (e) => {
// Extracted handleFile function for reusability in drag-and-drop and file input // Extracted handleFile function for reusability in drag-and-drop and file input
function handleFile(file) { function handleFile(file) {
const fileList = document.querySelector("#file-list"); const fileList = document.querySelector("#file-list");
const removeText = getTranslation('common', 'remove');
const row = document.createElement("tr"); const row = document.createElement("tr");
row.innerHTML = ` row.innerHTML = `
<td>${file.name}</td> <td>${file.name}</td>
<td><progress max="100" class="inline-block h-2 appearance-none overflow-hidden rounded-full border-0 bg-neutral-700 bg-none text-accent-500 accent-accent-500 [&::-moz-progress-bar]:bg-accent-500 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:[background:none] [&[value]::-webkit-progress-value]:bg-accent-500 [&[value]::-webkit-progress-value]:transition-[inline-size]"></progress></td> <td><progress max="100" class="inline-block h-2 appearance-none overflow-hidden rounded-full border-0 bg-neutral-700 bg-none text-accent-500 accent-accent-500 [&::-moz-progress-bar]:bg-accent-500 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:[background:none] [&[value]::-webkit-progress-value]:bg-accent-500 [&[value]::-webkit-progress-value]:transition-[inline-size]"></progress></td>
<td>${(file.size / 1024).toFixed(2)} kB</td> <td>${(file.size / 1024).toFixed(2)} kB</td>
<td><a onclick="deleteRow(this)">Remove</a></td> <td><a onclick="deleteRow(this)">${removeText}</a></td>
`; `;
if (!fileType) { if (!fileType) {
@ -162,7 +185,11 @@ fileInput.addEventListener("change", (e) => {
const setTitle = () => { const setTitle = () => {
const title = document.querySelector("h1"); const title = document.querySelector("h1");
title.textContent = `Convert ${fileType ? `.${fileType}` : ""}`; if (fileType) {
title.textContent = getTranslation('convert', 'titleWithType', { fileType });
} else {
title.textContent = getTranslation('convert', 'title');
}
}; };
// Add a onclick for the delete button // Add a onclick for the delete button
@ -198,7 +225,7 @@ const deleteRow = (target) => {
const uploadFile = (file) => { const uploadFile = (file) => {
convertButton.disabled = true; convertButton.disabled = true;
convertButton.textContent = "Uploading..."; convertButton.value = getTranslation('convert', 'uploading');
pendingFiles += 1; pendingFiles += 1;
const formData = new FormData(); const formData = new FormData();
@ -216,7 +243,7 @@ const uploadFile = (file) => {
if (formatSelected) { if (formatSelected) {
convertButton.disabled = false; convertButton.disabled = false;
} }
convertButton.textContent = "Convert"; convertButton.value = getTranslation('convert', 'convertButton');
} }
//Remove the progress bar when upload is done //Remove the progress bar when upload is done

View file

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

View file

@ -1,20 +1,27 @@
import { type SupportedLocale, type Translator, createTranslator, defaultLocale } from "../i18n";
import { LanguageSelector } from "./languageSelector";
export const Header = ({ export const Header = ({
loggedIn, loggedIn,
accountRegistration, accountRegistration,
allowUnauthenticated, allowUnauthenticated,
hideHistory, hideHistory,
webroot = "", webroot = "",
locale = defaultLocale,
t = createTranslator(defaultLocale),
}: { }: {
loggedIn?: boolean; loggedIn?: boolean;
accountRegistration?: boolean; accountRegistration?: boolean;
allowUnauthenticated?: boolean; allowUnauthenticated?: boolean;
hideHistory?: boolean; hideHistory?: boolean;
webroot?: string; webroot?: string;
locale?: SupportedLocale;
t?: Translator;
}) => { }) => {
let rightNav: JSX.Element; let rightNav: JSX.Element;
if (loggedIn) { if (loggedIn) {
rightNav = ( rightNav = (
<ul class="flex gap-4"> <ul class="flex items-center gap-4">
{!hideHistory && ( {!hideHistory && (
<li> <li>
<a <a
@ -24,7 +31,7 @@ export const Header = ({
`} `}
href={`${webroot}/history`} href={`${webroot}/history`}
> >
History {t("nav", "history")}
</a> </a>
</li> </li>
)} )}
@ -37,7 +44,7 @@ export const Header = ({
`} `}
href={`${webroot}/account`} href={`${webroot}/account`}
> >
Account {t("nav", "account")}
</a> </a>
</li> </li>
) : null} ) : null}
@ -50,15 +57,18 @@ export const Header = ({
`} `}
href={`${webroot}/logoff`} href={`${webroot}/logoff`}
> >
Logout {t("nav", "logout")}
</a> </a>
</li> </li>
) : null} ) : null}
<li>
<LanguageSelector currentLocale={locale} webroot={webroot} t={t} />
</li>
</ul> </ul>
); );
} else { } else {
rightNav = ( rightNav = (
<ul class="flex gap-4"> <ul class="flex items-center gap-4">
<li> <li>
<a <a
class={` class={`
@ -67,7 +77,7 @@ export const Header = ({
`} `}
href={`${webroot}/login`} href={`${webroot}/login`}
> >
Login {t("nav", "login")}
</a> </a>
</li> </li>
{accountRegistration ? ( {accountRegistration ? (
@ -79,10 +89,13 @@ export const Header = ({
`} `}
href={`${webroot}/register`} href={`${webroot}/register`}
> >
Register {t("nav", "register")}
</a> </a>
</li> </li>
) : null} ) : null}
<li>
<LanguageSelector currentLocale={locale} webroot={webroot} t={t} />
</li>
</ul> </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 db from "../db/db";
import { Filename, Jobs } from "../db/types"; import { Filename, Jobs } from "../db/types";
import { ALLOW_UNAUTHENTICATED, HIDE_HISTORY, LANGUAGE, TIMEZONE, WEBROOT } from "../helpers/env"; import { ALLOW_UNAUTHENTICATED, HIDE_HISTORY, LANGUAGE, TIMEZONE, WEBROOT } from "../helpers/env";
import { localeService } from "../i18n/service";
import { userService } from "./user"; import { userService } from "./user";
import { EyeIcon } from "../icons/eye"; import { EyeIcon } from "../icons/eye";
import { DeleteIcon } from "../icons/delete"; import { DeleteIcon } from "../icons/delete";
export const history = new Elysia().use(userService).get( export const history = new Elysia().use(userService).use(localeService).get(
"/history", "/history",
async ({ redirect, user }) => { async ({ redirect, user, locale, t }) => {
if (HIDE_HISTORY) { if (HIDE_HISTORY) {
return redirect(`${WEBROOT}/`, 302); return redirect(`${WEBROOT}/`, 302);
} }
@ -32,13 +33,15 @@ export const history = new Elysia().use(userService).get(
userJobs = userJobs.filter((job) => job.num_files > 0); userJobs = userJobs.filter((job) => job.num_files > 0);
return ( return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Results"> <BaseHtml webroot={WEBROOT} title="ConvertX | Results" locale={locale}>
<> <>
<Header <Header
webroot={WEBROOT} webroot={WEBROOT}
allowUnauthenticated={ALLOW_UNAUTHENTICATED} allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY} hideHistory={HIDE_HISTORY}
loggedIn loggedIn
locale={locale}
t={t}
/> />
<main <main
class={` class={`
@ -48,7 +51,7 @@ export const history = new Elysia().use(userService).get(
> >
<article class="article"> <article class="article">
<div class="mb-4 flex items-center justify-between"> <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"> <div id="delete-selected-container">
<button <button
id="delete-selected-btn" id="delete-selected-btn"
@ -60,7 +63,7 @@ export const history = new Elysia().use(userService).get(
> >
<DeleteIcon />{" "} <DeleteIcon />{" "}
<span> <span>
Delete Selected (<span id="selected-count">0</span>) {t("history", "deleteSelected")} (<span id="selected-count">0</span>)
</span> </span>
</button> </button>
</div> </div>
@ -84,7 +87,7 @@ export const history = new Elysia().use(userService).get(
type="checkbox" type="checkbox"
id="select-all" id="select-all"
class="h-4 w-4 cursor-pointer" class="h-4 w-4 cursor-pointer"
title="Select all" title={t("history", "selectAll")}
/> />
</th> </th>
<th <th
@ -93,7 +96,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4 sm:px-4
`} `}
> >
<span class="sr-only">Expand details</span> <span class="sr-only">{t("history", "expandDetails")}</span>
</th> </th>
<th <th
class={` class={`
@ -101,7 +104,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4 sm:px-4
`} `}
> >
Time {t("history", "time")}
</th> </th>
<th <th
class={` class={`
@ -109,7 +112,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4 sm:px-4
`} `}
> >
Files {t("history", "files")}
</th> </th>
<th <th
class={` class={`
@ -118,7 +121,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4 sm:px-4
`} `}
> >
Files Done {t("history", "filesDone")}
</th> </th>
<th <th
class={` class={`
@ -126,7 +129,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4 sm:px-4
`} `}
> >
Status {t("history", "status")}
</th> </th>
<th <th
class={` class={`
@ -134,7 +137,7 @@ export const history = new Elysia().use(userService).get(
sm:px-4 sm:px-4
`} `}
> >
Actions {t("history", "actions")}
</th> </th>
</tr> </tr>
</thead> </thead>
@ -199,7 +202,7 @@ export const history = new Elysia().use(userService).get(
<tr id={`details-${job.id}`} class="hidden"> <tr id={`details-${job.id}`} class="hidden">
<td colspan="7"> <td colspan="7">
<div class="p-2 text-sm text-neutral-500"> <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) => ( {job.files_detailed.map((file: Filename) => (
<div class="flex items-center"> <div class="flex items-center">
<span class="w-5/12 truncate" title={file.file_name} safe> <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 { DownloadIcon } from "../icons/download";
import { DeleteIcon } from "../icons/delete"; import { DeleteIcon } from "../icons/delete";
import { EyeIcon } from "../icons/eye"; import { EyeIcon } from "../icons/eye";
import { type SupportedLocale, type Translator, createTranslator, defaultLocale } from "../i18n";
import { localeService } from "../i18n/service";
import { userService } from "./user"; import { userService } from "./user";
function ResultsArticle({ function ResultsArticle({
job, job,
files, files,
outputPath, outputPath,
t = createTranslator(defaultLocale),
}: { }: {
job: Jobs; job: Jobs;
files: Filename[]; files: Filename[];
outputPath: string; outputPath: string;
t?: Translator;
}) { }) {
return ( return (
<article class="article"> <article class="article">
<div class="mb-4 flex items-center justify-between"> <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"> <div class="flex flex-row gap-4">
<a <a
style={files.length !== job.num_files ? "pointer-events: none;" : ""} style={files.length !== job.num_files ? "pointer-events: none;" : ""}
@ -29,7 +33,7 @@ function ResultsArticle({
href={`${WEBROOT}/delete/${job.id}`} href={`${WEBROOT}/delete/${job.id}`}
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")} {...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")}
> >
<DeleteIcon /> <p>Delete</p> <DeleteIcon /> <p>{t("common", "delete")}</p>
</a> </a>
<a <a
style={files.length !== job.num_files ? "pointer-events: none;" : ""} 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" class="flex btn-primary flex-row gap-2 text-contrast"
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")} {...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")}
> >
<DownloadIcon /> <p>Tar</p> <DownloadIcon /> <p>{t("results", "downloadTar")}</p>
</a> </a>
<button class="flex btn-primary flex-row gap-2 text-contrast" onclick="downloadAll()"> <button class="flex btn-primary flex-row gap-2 text-contrast" onclick="downloadAll()">
<DownloadIcon /> <p>All</p> <DownloadIcon /> <p>{t("results", "downloadAll")}</p>
</button> </button>
</div> </div>
</div> </div>
@ -72,7 +76,7 @@ function ResultsArticle({
sm:px-4 sm:px-4
`} `}
> >
Converted File Name {t("results", "convertedFileName")}
</th> </th>
<th <th
class={` class={`
@ -80,7 +84,7 @@ function ResultsArticle({
sm:px-4 sm:px-4
`} `}
> >
Status {t("results", "status")}
</th> </th>
<th <th
class={` class={`
@ -88,7 +92,7 @@ function ResultsArticle({
sm:px-4 sm:px-4
`} `}
> >
Actions {t("results", "actions")}
</th> </th>
</tr> </tr>
</thead> </thead>
@ -130,9 +134,10 @@ function ResultsArticle({
export const results = new Elysia() export const results = new Elysia()
.use(userService) .use(userService)
.use(localeService)
.get( .get(
"/results/:jobId", "/results/:jobId",
async ({ params, set, cookie: { job_id }, user }) => { async ({ params, set, cookie: { job_id }, user, locale, t }) => {
if (job_id?.value) { if (job_id?.value) {
// Clear the job_id cookie since we are viewing the results // Clear the job_id cookie since we are viewing the results
job_id.remove(); job_id.remove();
@ -146,7 +151,7 @@ export const results = new Elysia()
if (!job) { if (!job) {
set.status = 404; set.status = 404;
return { return {
message: "Job not found.", message: t("errors", "jobNotFound"),
}; };
} }
@ -158,16 +163,16 @@ export const results = new Elysia()
.all(params.jobId); .all(params.jobId);
return ( 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 <main
class={` class={`
w-full flex-1 px-2 w-full flex-1 px-2
sm:px-4 sm:px-4
`} `}
> >
<ResultsArticle job={job} files={files} outputPath={outputPath} /> <ResultsArticle job={job} files={files} outputPath={outputPath} t={t} />
</main> </main>
<script src={`${WEBROOT}/results.js`} defer /> <script src={`${WEBROOT}/results.js`} defer />
</> </>
@ -178,7 +183,7 @@ export const results = new Elysia()
) )
.post( .post(
"/progress/:jobId", "/progress/:jobId",
async ({ set, params, cookie: { job_id }, user }) => { async ({ set, params, cookie: { job_id }, user, t }) => {
if (job_id?.value) { if (job_id?.value) {
// Clear the job_id cookie since we are viewing the results // Clear the job_id cookie since we are viewing the results
job_id.remove(); job_id.remove();
@ -192,7 +197,7 @@ export const results = new Elysia()
if (!job) { if (!job) {
set.status = 404; set.status = 404;
return { return {
message: "Job not found.", message: t("errors", "jobNotFound"),
}; };
} }
@ -203,7 +208,7 @@ export const results = new Elysia()
.as(Filename) .as(Filename)
.all(params.jobId); .all(params.jobId);
return <ResultsArticle job={job} files={files} outputPath={outputPath} />; return <ResultsArticle job={job} files={files} outputPath={outputPath} t={t} />;
}, },
{ auth: true }, { auth: true },
); );

View file

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

View file

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

View file

@ -29,6 +29,6 @@
"esModuleInterop": true "esModuleInterop": true
// "noImplicitReturns": true // "noImplicitReturns": true
}, },
"include": ["src", "package.json"], "include": ["src", "src/locales/*.json", "package.json"],
"exclude": ["dist", "node_modules"] "exclude": ["dist", "node_modules"]
} }