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:
parent
e792dfedf1
commit
33301033ae
21 changed files with 1205 additions and 89 deletions
|
|
@ -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={`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
92
src/components/languageSelector.tsx
Normal file
92
src/components/languageSelector.tsx
Normal 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
178
src/i18n/index.ts
Normal 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
44
src/i18n/service.ts
Normal 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
106
src/locales/en.json
Normal 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
106
src/locales/ja.json
Normal 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
106
src/locales/ko.json
Normal 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
106
src/locales/zh-CN.json
Normal 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
106
src/locales/zh-TW.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue