fix(ci): resolve all lint errors for GitHub Actions
- Remove unused SupportedLocale import in results.tsx - Remove unused webroot variable in public/i18n.js - Configure knip.json to ignore i18n public API exports - Add language-selector and language-option to ESLint ignore list - Auto-fix Prettier formatting across all files - Fix better-tailwindcss line wrapping issues
This commit is contained in:
parent
8089fa0853
commit
569c572e62
16 changed files with 621 additions and 508 deletions
20
compose.yaml
20
compose.yaml
|
|
@ -1,6 +1,6 @@
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# ConvertX-CN 部署範例(docker-compose.yml / compose.yaml)
|
# ConvertX-CN 部署範例(docker-compose.yml / compose.yaml)
|
||||||
#
|
#
|
||||||
# 🎉 這是完整版 image,已內建所有轉換依賴:
|
# 🎉 這是完整版 image,已內建所有轉換依賴:
|
||||||
# - LibreOffice (headless)
|
# - LibreOffice (headless)
|
||||||
# - TexLive Full
|
# - TexLive Full
|
||||||
|
|
@ -23,19 +23,19 @@ services:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
# === 帳號設定 ===
|
# === 帳號設定 ===
|
||||||
- ACCOUNT_REGISTRATION=false # 是否允許註冊新帳號(首次帳號不受此限制)
|
- ACCOUNT_REGISTRATION=false # 是否允許註冊新帳號(首次帳號不受此限制)
|
||||||
- JWT_SECRET=請更換為一個長且隨機的字串 # 若不設定則使用 randomUUID()
|
- JWT_SECRET=請更換為一個長且隨機的字串 # 若不設定則使用 randomUUID()
|
||||||
|
|
||||||
# === 安全設定 ===
|
# === 安全設定 ===
|
||||||
- HTTP_ALLOWED=false # 是否允許非 HTTPS 連線(僅本地測試時設為 true)
|
- HTTP_ALLOWED=false # 是否允許非 HTTPS 連線(僅本地測試時設為 true)
|
||||||
- ALLOW_UNAUTHENTICATED=false # 是否允許未登入使用(僅本地測試時設為 true)
|
- ALLOW_UNAUTHENTICATED=false # 是否允許未登入使用(僅本地測試時設為 true)
|
||||||
|
|
||||||
# === 檔案管理 ===
|
# === 檔案管理 ===
|
||||||
- AUTO_DELETE_EVERY_N_HOURS=24 # 自動刪除超過 N 小時的檔案(0 = 停用)
|
- AUTO_DELETE_EVERY_N_HOURS=24 # 自動刪除超過 N 小時的檔案(0 = 停用)
|
||||||
|
|
||||||
# === 時區設定 ===
|
# === 時區設定 ===
|
||||||
- TZ=Asia/Taipei
|
- TZ=Asia/Taipei
|
||||||
|
|
||||||
# === 可選設定 ===
|
# === 可選設定 ===
|
||||||
# - WEBROOT=/convertx # 子路徑部署(例如 example.com/convertx/)
|
# - WEBROOT=/convertx # 子路徑部署(例如 example.com/convertx/)
|
||||||
# - HIDE_HISTORY=true # 隱藏歷史紀錄頁面
|
# - HIDE_HISTORY=true # 隱藏歷史紀錄頁面
|
||||||
|
|
|
||||||
10
docs/faq.md
10
docs/faq.md
|
|
@ -5,8 +5,9 @@
|
||||||
### Q: 如何註冊帳號?
|
### Q: 如何註冊帳號?
|
||||||
|
|
||||||
首次訪問 ConvertX-CN 時:
|
首次訪問 ConvertX-CN 時:
|
||||||
|
|
||||||
1. 開啟 `http://localhost:3000`
|
1. 開啟 `http://localhost:3000`
|
||||||
2. 點擊右上角 **Register**
|
2. 點擊右上角 **Register**
|
||||||
3. 輸入 Email 和密碼
|
3. 輸入 Email 和密碼
|
||||||
4. 完成!系統會自動登入
|
4. 完成!系統會自動登入
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@ environment:
|
||||||
### Q: 忘記密碼怎麼辦?
|
### Q: 忘記密碼怎麼辦?
|
||||||
|
|
||||||
目前版本尚未提供密碼重設功能。您可以:
|
目前版本尚未提供密碼重設功能。您可以:
|
||||||
|
|
||||||
1. 刪除 `data/convertx.db`
|
1. 刪除 `data/convertx.db`
|
||||||
2. 重新啟動容器
|
2. 重新啟動容器
|
||||||
3. 重新註冊
|
3. 重新註冊
|
||||||
|
|
@ -39,6 +41,7 @@ environment:
|
||||||
### Q: 為什麼 Image 這麼大(4-6 GB)?
|
### Q: 為什麼 Image 這麼大(4-6 GB)?
|
||||||
|
|
||||||
ConvertX-CN 是「完整版」,內建:
|
ConvertX-CN 是「完整版」,內建:
|
||||||
|
|
||||||
- LibreOffice(文件轉換)
|
- LibreOffice(文件轉換)
|
||||||
- TexLive(LaTeX 支援)
|
- TexLive(LaTeX 支援)
|
||||||
- FFmpeg(影音轉換)
|
- FFmpeg(影音轉換)
|
||||||
|
|
@ -46,11 +49,13 @@ ConvertX-CN 是「完整版」,內建:
|
||||||
- CJK 字型
|
- CJK 字型
|
||||||
|
|
||||||
如果您只需要基本功能,可使用原作者的輕量版:
|
如果您只需要基本功能,可使用原作者的輕量版:
|
||||||
|
|
||||||
- `ghcr.io/c4illin/convertx:latest`
|
- `ghcr.io/c4illin/convertx:latest`
|
||||||
|
|
||||||
### Q: Docker 啟動失敗?
|
### Q: Docker 啟動失敗?
|
||||||
|
|
||||||
常見原因:
|
常見原因:
|
||||||
|
|
||||||
1. **Port 被占用**:改用其他 port,如 `-p 3001:3000`
|
1. **Port 被占用**:改用其他 port,如 `-p 3001:3000`
|
||||||
2. **磁碟空間不足**:Image 需約 6GB
|
2. **磁碟空間不足**:Image 需約 6GB
|
||||||
3. **權限問題**:確保 `./data` 資料夾有寫入權限
|
3. **權限問題**:確保 `./data` 資料夾有寫入權限
|
||||||
|
|
@ -62,6 +67,7 @@ chmod -R 777 ./data
|
||||||
### Q: 資料存在哪裡?
|
### Q: 資料存在哪裡?
|
||||||
|
|
||||||
所有資料存放在掛載的 `/app/data` 目錄:
|
所有資料存放在掛載的 `/app/data` 目錄:
|
||||||
|
|
||||||
- `convertx.db` - SQLite 資料庫
|
- `convertx.db` - SQLite 資料庫
|
||||||
- `uploads/` - 上傳的原始檔案
|
- `uploads/` - 上傳的原始檔案
|
||||||
- `output/` - 轉換後的檔案
|
- `output/` - 轉換後的檔案
|
||||||
|
|
@ -97,6 +103,7 @@ chmod -R 777 ./data
|
||||||
### Q: 支援哪些格式?
|
### Q: 支援哪些格式?
|
||||||
|
|
||||||
ConvertX-CN 支援數百種格式,包括:
|
ConvertX-CN 支援數百種格式,包括:
|
||||||
|
|
||||||
- **影音**:MP4, AVI, MKV, MP3, WAV 等
|
- **影音**:MP4, AVI, MKV, MP3, WAV 等
|
||||||
- **圖片**:PNG, JPG, WEBP, SVG, PDF 等
|
- **圖片**:PNG, JPG, WEBP, SVG, PDF 等
|
||||||
- **文件**:DOCX, PDF, XLSX, PPTX 等
|
- **文件**:DOCX, PDF, XLSX, PPTX 等
|
||||||
|
|
@ -116,6 +123,7 @@ ConvertX-CN 支援數百種格式,包括:
|
||||||
### Q: 檔案大小有限制嗎?
|
### Q: 檔案大小有限制嗎?
|
||||||
|
|
||||||
預設無硬性限制,但大檔案:
|
預設無硬性限制,但大檔案:
|
||||||
|
|
||||||
- 上傳需要更長時間
|
- 上傳需要更長時間
|
||||||
- 轉換可能耗用大量記憶體
|
- 轉換可能耗用大量記憶體
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ docker run -d \
|
||||||
```
|
```
|
||||||
|
|
||||||
開啟瀏覽器訪問 `http://localhost:3000`:
|
開啟瀏覽器訪問 `http://localhost:3000`:
|
||||||
|
|
||||||
1. 點擊右上角 **Register** 註冊帳號
|
1. 點擊右上角 **Register** 註冊帳號
|
||||||
2. 輸入 Email 和密碼
|
2. 輸入 Email 和密碼
|
||||||
3. 完成!開始使用
|
3. 完成!開始使用
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ export default tseslint.config(
|
||||||
"target",
|
"target",
|
||||||
"convert_to_target",
|
"convert_to_target",
|
||||||
"job-details-toggle",
|
"job-details-toggle",
|
||||||
|
"language-selector",
|
||||||
|
"language-option",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||||
"entry": ["tests/**/*.test.ts"],
|
"entry": ["tests/**/*.test.ts", "src/i18n/index.ts"],
|
||||||
"project": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"],
|
"project": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"],
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"entry": ["src/main.css"]
|
"entry": ["src/main.css"]
|
||||||
}
|
},
|
||||||
|
"ignore": ["src/i18n/**"],
|
||||||
|
"ignoreDependencies": [],
|
||||||
|
"ignoreExportsUsedInFile": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@
|
||||||
options.forEach((option) => {
|
options.forEach((option) => {
|
||||||
option.addEventListener("click", () => {
|
option.addEventListener("click", () => {
|
||||||
const locale = option.dataset.locale;
|
const locale = option.dataset.locale;
|
||||||
const webroot = option.dataset.webroot || "";
|
// webroot is available in option.dataset.webroot if needed for future use
|
||||||
|
|
||||||
// Save to cookie
|
// Save to cookie
|
||||||
setCookie(LOCALE_COOKIE_NAME, locale, LOCALE_EXPIRY_DAYS);
|
setCookie(LOCALE_COOKIE_NAME, locale, LOCALE_EXPIRY_DAYS);
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,7 @@ export const BaseHtml = ({
|
||||||
<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>
|
<script>{`window.__TRANSLATIONS__ = ${JSON.stringify(getTranslations(locale))};`}</script>
|
||||||
{`window.__TRANSLATIONS__ = ${JSON.stringify(getTranslations(locale))};`}
|
|
||||||
</script>
|
|
||||||
<script src={`${webroot}/i18n.js`} defer />
|
<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`}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { type SupportedLocale, type Translator, createTranslator, defaultLocale } from "../i18n/index";
|
import {
|
||||||
|
type SupportedLocale,
|
||||||
|
type Translator,
|
||||||
|
createTranslator,
|
||||||
|
defaultLocale,
|
||||||
|
} from "../i18n/index";
|
||||||
import { LanguageSelector } from "./languageSelector";
|
import { LanguageSelector } from "./languageSelector";
|
||||||
|
|
||||||
export const Header = ({
|
export const Header = ({
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,14 @@ export const LanguageSelector = ({
|
||||||
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"
|
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>
|
</svg>
|
||||||
<span class="hidden sm:inline">{t("nav", "language")}</span>
|
<span
|
||||||
|
class={`
|
||||||
|
hidden
|
||||||
|
sm:inline
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{t("nav", "language")}
|
||||||
|
</span>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|
@ -51,8 +58,8 @@ export const LanguageSelector = ({
|
||||||
<ul
|
<ul
|
||||||
id="language-dropdown"
|
id="language-dropdown"
|
||||||
class={`
|
class={`
|
||||||
absolute right-0 top-full z-50 mt-2 hidden min-w-[160px] max-h-[320px] flex-col overflow-y-auto rounded
|
absolute top-full right-0 z-50 mt-2 hidden max-h-[320px] min-w-[160px] flex-col
|
||||||
border border-neutral-700 bg-neutral-800 shadow-lg
|
overflow-y-auto rounded border border-neutral-700 bg-neutral-800 shadow-lg
|
||||||
`}
|
`}
|
||||||
role="menu"
|
role="menu"
|
||||||
>
|
>
|
||||||
|
|
@ -65,7 +72,11 @@ export const LanguageSelector = ({
|
||||||
language-option flex w-full items-center gap-2 px-4 py-2 text-left text-sm
|
language-option flex w-full items-center gap-2 px-4 py-2 text-left text-sm
|
||||||
transition-colors
|
transition-colors
|
||||||
hover:bg-neutral-700
|
hover:bg-neutral-700
|
||||||
${locale.code === currentLocale ? "bg-neutral-700 text-accent-500" : "text-neutral-200"}
|
${
|
||||||
|
locale.code === currentLocale
|
||||||
|
? "bg-neutral-700 text-accent-500"
|
||||||
|
: `text-neutral-200`
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
data-locale={locale.code}
|
data-locale={locale.code}
|
||||||
data-webroot={webroot}
|
data-webroot={webroot}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
// 預設開放註冊(開箱即用),管理者可設為 false 來關閉
|
// 預設開放註冊(開箱即用),管理者可設為 false 來關閉
|
||||||
export const ACCOUNT_REGISTRATION =
|
export const ACCOUNT_REGISTRATION = process.env.ACCOUNT_REGISTRATION?.toLowerCase() !== "false";
|
||||||
process.env.ACCOUNT_REGISTRATION?.toLowerCase() !== "false";
|
|
||||||
|
|
||||||
export const HTTP_ALLOWED = process.env.HTTP_ALLOWED?.toLowerCase() === "true" || false;
|
export const HTTP_ALLOWED = process.env.HTTP_ALLOWED?.toLowerCase() === "true" || false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,15 +76,71 @@ import am from "../locales/am.json";
|
||||||
import zu from "../locales/zu.json";
|
import zu from "../locales/zu.json";
|
||||||
|
|
||||||
export type SupportedLocale =
|
export type SupportedLocale =
|
||||||
| "en" | "zh-TW" | "zh-CN" | "ja" | "ko"
|
| "en"
|
||||||
| "de" | "fr" | "es" | "it" | "pt" | "ru" | "nl" | "pl" | "uk"
|
| "zh-TW"
|
||||||
| "cs" | "sv" | "da" | "fi" | "no" | "el" | "hu" | "ro" | "bg"
|
| "zh-CN"
|
||||||
| "hr" | "sk" | "sl" | "lt" | "lv" | "et" | "sr" | "ca" | "eu"
|
| "ja"
|
||||||
| "gl" | "is" | "ga" | "cy" | "mt" | "mk" | "sq"
|
| "ko"
|
||||||
| "ar" | "he" | "fa" | "tr"
|
| "de"
|
||||||
| "hi" | "bn" | "ta" | "te" | "mr" | "gu" | "kn" | "ml" | "ne" | "si"
|
| "fr"
|
||||||
| "th" | "vi" | "id" | "ms" | "fil" | "my" | "km" | "lo"
|
| "es"
|
||||||
| "af" | "sw" | "am" | "zu";
|
| "it"
|
||||||
|
| "pt"
|
||||||
|
| "ru"
|
||||||
|
| "nl"
|
||||||
|
| "pl"
|
||||||
|
| "uk"
|
||||||
|
| "cs"
|
||||||
|
| "sv"
|
||||||
|
| "da"
|
||||||
|
| "fi"
|
||||||
|
| "no"
|
||||||
|
| "el"
|
||||||
|
| "hu"
|
||||||
|
| "ro"
|
||||||
|
| "bg"
|
||||||
|
| "hr"
|
||||||
|
| "sk"
|
||||||
|
| "sl"
|
||||||
|
| "lt"
|
||||||
|
| "lv"
|
||||||
|
| "et"
|
||||||
|
| "sr"
|
||||||
|
| "ca"
|
||||||
|
| "eu"
|
||||||
|
| "gl"
|
||||||
|
| "is"
|
||||||
|
| "ga"
|
||||||
|
| "cy"
|
||||||
|
| "mt"
|
||||||
|
| "mk"
|
||||||
|
| "sq"
|
||||||
|
| "ar"
|
||||||
|
| "he"
|
||||||
|
| "fa"
|
||||||
|
| "tr"
|
||||||
|
| "hi"
|
||||||
|
| "bn"
|
||||||
|
| "ta"
|
||||||
|
| "te"
|
||||||
|
| "mr"
|
||||||
|
| "gu"
|
||||||
|
| "kn"
|
||||||
|
| "ml"
|
||||||
|
| "ne"
|
||||||
|
| "si"
|
||||||
|
| "th"
|
||||||
|
| "vi"
|
||||||
|
| "id"
|
||||||
|
| "ms"
|
||||||
|
| "fil"
|
||||||
|
| "my"
|
||||||
|
| "km"
|
||||||
|
| "lo"
|
||||||
|
| "af"
|
||||||
|
| "sw"
|
||||||
|
| "am"
|
||||||
|
| "zu";
|
||||||
|
|
||||||
export interface LocaleConfig {
|
export interface LocaleConfig {
|
||||||
code: SupportedLocale;
|
code: SupportedLocale;
|
||||||
|
|
@ -99,7 +155,7 @@ export const supportedLocales: LocaleConfig[] = [
|
||||||
{ code: "en", name: "English", nativeName: "English" },
|
{ code: "en", name: "English", nativeName: "English" },
|
||||||
{ code: "ja", name: "Japanese", nativeName: "日本語" },
|
{ code: "ja", name: "Japanese", nativeName: "日本語" },
|
||||||
{ code: "ko", name: "Korean", nativeName: "한국어" },
|
{ code: "ko", name: "Korean", nativeName: "한국어" },
|
||||||
|
|
||||||
// European languages
|
// European languages
|
||||||
{ code: "de", name: "German", nativeName: "Deutsch" },
|
{ code: "de", name: "German", nativeName: "Deutsch" },
|
||||||
{ code: "fr", name: "French", nativeName: "Français" },
|
{ code: "fr", name: "French", nativeName: "Français" },
|
||||||
|
|
@ -135,13 +191,13 @@ export const supportedLocales: LocaleConfig[] = [
|
||||||
{ code: "mt", name: "Maltese", nativeName: "Malti" },
|
{ code: "mt", name: "Maltese", nativeName: "Malti" },
|
||||||
{ code: "mk", name: "Macedonian", nativeName: "Македонски" },
|
{ code: "mk", name: "Macedonian", nativeName: "Македонски" },
|
||||||
{ code: "sq", name: "Albanian", nativeName: "Shqip" },
|
{ code: "sq", name: "Albanian", nativeName: "Shqip" },
|
||||||
|
|
||||||
// Middle East & Central Asian languages
|
// Middle East & Central Asian languages
|
||||||
{ code: "ar", name: "Arabic", nativeName: "العربية" },
|
{ code: "ar", name: "Arabic", nativeName: "العربية" },
|
||||||
{ code: "he", name: "Hebrew", nativeName: "עברית" },
|
{ code: "he", name: "Hebrew", nativeName: "עברית" },
|
||||||
{ code: "fa", name: "Persian", nativeName: "فارسی" },
|
{ code: "fa", name: "Persian", nativeName: "فارسی" },
|
||||||
{ code: "tr", name: "Turkish", nativeName: "Türkçe" },
|
{ code: "tr", name: "Turkish", nativeName: "Türkçe" },
|
||||||
|
|
||||||
// South Asian languages
|
// South Asian languages
|
||||||
{ code: "hi", name: "Hindi", nativeName: "हिन्दी" },
|
{ code: "hi", name: "Hindi", nativeName: "हिन्दी" },
|
||||||
{ code: "bn", name: "Bengali", nativeName: "বাংলা" },
|
{ code: "bn", name: "Bengali", nativeName: "বাংলা" },
|
||||||
|
|
@ -153,7 +209,7 @@ export const supportedLocales: LocaleConfig[] = [
|
||||||
{ code: "ml", name: "Malayalam", nativeName: "മലയാളം" },
|
{ code: "ml", name: "Malayalam", nativeName: "മലയാളം" },
|
||||||
{ code: "ne", name: "Nepali", nativeName: "नेपाली" },
|
{ code: "ne", name: "Nepali", nativeName: "नेपाली" },
|
||||||
{ code: "si", name: "Sinhala", nativeName: "සිංහල" },
|
{ code: "si", name: "Sinhala", nativeName: "සිංහල" },
|
||||||
|
|
||||||
// Southeast Asian languages
|
// Southeast Asian languages
|
||||||
{ code: "th", name: "Thai", nativeName: "ไทย" },
|
{ code: "th", name: "Thai", nativeName: "ไทย" },
|
||||||
{ code: "vi", name: "Vietnamese", nativeName: "Tiếng Việt" },
|
{ code: "vi", name: "Vietnamese", nativeName: "Tiếng Việt" },
|
||||||
|
|
@ -163,7 +219,7 @@ export const supportedLocales: LocaleConfig[] = [
|
||||||
{ code: "my", name: "Burmese", nativeName: "မြန်မာ" },
|
{ code: "my", name: "Burmese", nativeName: "မြန်မာ" },
|
||||||
{ code: "km", name: "Khmer", nativeName: "ខ្មែរ" },
|
{ code: "km", name: "Khmer", nativeName: "ខ្មែរ" },
|
||||||
{ code: "lo", name: "Lao", nativeName: "ລາວ" },
|
{ code: "lo", name: "Lao", nativeName: "ລາວ" },
|
||||||
|
|
||||||
// African languages
|
// African languages
|
||||||
{ code: "af", name: "Afrikaans", nativeName: "Afrikaans" },
|
{ code: "af", name: "Afrikaans", nativeName: "Afrikaans" },
|
||||||
{ code: "sw", name: "Swahili", nativeName: "Kiswahili" },
|
{ code: "sw", name: "Swahili", nativeName: "Kiswahili" },
|
||||||
|
|
@ -187,7 +243,7 @@ const translations: Record<SupportedLocale, TranslationData> = {
|
||||||
"zh-CN": zhCN as TranslationData,
|
"zh-CN": zhCN as TranslationData,
|
||||||
ja: ja as TranslationData,
|
ja: ja as TranslationData,
|
||||||
ko: ko as TranslationData,
|
ko: ko as TranslationData,
|
||||||
|
|
||||||
// European languages
|
// European languages
|
||||||
de: de as TranslationData,
|
de: de as TranslationData,
|
||||||
fr: fr as TranslationData,
|
fr: fr as TranslationData,
|
||||||
|
|
@ -223,13 +279,13 @@ const translations: Record<SupportedLocale, TranslationData> = {
|
||||||
mt: mt as TranslationData,
|
mt: mt as TranslationData,
|
||||||
mk: mk as TranslationData,
|
mk: mk as TranslationData,
|
||||||
sq: sq as TranslationData,
|
sq: sq as TranslationData,
|
||||||
|
|
||||||
// Middle East & Central Asian languages
|
// Middle East & Central Asian languages
|
||||||
ar: ar as TranslationData,
|
ar: ar as TranslationData,
|
||||||
he: he as TranslationData,
|
he: he as TranslationData,
|
||||||
fa: fa as TranslationData,
|
fa: fa as TranslationData,
|
||||||
tr: tr as TranslationData,
|
tr: tr as TranslationData,
|
||||||
|
|
||||||
// South Asian languages
|
// South Asian languages
|
||||||
hi: hi as TranslationData,
|
hi: hi as TranslationData,
|
||||||
bn: bn as TranslationData,
|
bn: bn as TranslationData,
|
||||||
|
|
@ -241,7 +297,7 @@ const translations: Record<SupportedLocale, TranslationData> = {
|
||||||
ml: ml as TranslationData,
|
ml: ml as TranslationData,
|
||||||
ne: ne as TranslationData,
|
ne: ne as TranslationData,
|
||||||
si: si as TranslationData,
|
si: si as TranslationData,
|
||||||
|
|
||||||
// Southeast Asian languages
|
// Southeast Asian languages
|
||||||
th: th as TranslationData,
|
th: th as TranslationData,
|
||||||
vi: vi as TranslationData,
|
vi: vi as TranslationData,
|
||||||
|
|
@ -251,7 +307,7 @@ const translations: Record<SupportedLocale, TranslationData> = {
|
||||||
my: my as TranslationData,
|
my: my as TranslationData,
|
||||||
km: km as TranslationData,
|
km: km as TranslationData,
|
||||||
lo: lo as TranslationData,
|
lo: lo as TranslationData,
|
||||||
|
|
||||||
// African languages
|
// African languages
|
||||||
af: af as TranslationData,
|
af: af as TranslationData,
|
||||||
sw: sw as TranslationData,
|
sw: sw as TranslationData,
|
||||||
|
|
@ -278,8 +334,7 @@ export function t<T extends TranslationKey>(
|
||||||
|
|
||||||
if (typeof translation !== "string") {
|
if (typeof translation !== "string") {
|
||||||
// Fallback to English
|
// Fallback to English
|
||||||
const fallback =
|
const fallback = translations[fallbackLocale]?.[category]?.[key as keyof TranslationData[T]];
|
||||||
translations[fallbackLocale]?.[category]?.[key as keyof TranslationData[T]];
|
|
||||||
if (typeof fallback !== "string") {
|
if (typeof fallback !== "string") {
|
||||||
return `${category}.${String(key)}`;
|
return `${category}.${String(key)}`;
|
||||||
}
|
}
|
||||||
|
|
@ -326,9 +381,7 @@ export function detectLocale(acceptLanguage?: string): SupportedLocale {
|
||||||
// Try to find a match
|
// Try to find a match
|
||||||
for (const lang of languages) {
|
for (const lang of languages) {
|
||||||
// Direct match (case-insensitive)
|
// Direct match (case-insensitive)
|
||||||
const directMatch = supportedLocales.find(
|
const directMatch = supportedLocales.find((l) => l.code.toLowerCase() === lang.code);
|
||||||
(l) => l.code.toLowerCase() === lang.code
|
|
||||||
);
|
|
||||||
if (directMatch) {
|
if (directMatch) {
|
||||||
return directMatch.code;
|
return directMatch.code;
|
||||||
}
|
}
|
||||||
|
|
@ -347,7 +400,7 @@ export function detectLocale(acceptLanguage?: string): SupportedLocale {
|
||||||
const baseCode = lang.code.split("-")[0];
|
const baseCode = lang.code.split("-")[0];
|
||||||
if (baseCode) {
|
if (baseCode) {
|
||||||
const baseMatch = supportedLocales.find(
|
const baseMatch = supportedLocales.find(
|
||||||
(l) => l.code.toLowerCase() === baseCode || l.code.toLowerCase().startsWith(baseCode + "-")
|
(l) => l.code.toLowerCase() === baseCode || l.code.toLowerCase().startsWith(baseCode + "-"),
|
||||||
);
|
);
|
||||||
if (baseMatch) {
|
if (baseMatch) {
|
||||||
return baseMatch.code;
|
return baseMatch.code;
|
||||||
|
|
@ -364,9 +417,7 @@ export function detectLocale(acceptLanguage?: string): SupportedLocale {
|
||||||
* @returns Whether the locale is valid
|
* @returns Whether the locale is valid
|
||||||
*/
|
*/
|
||||||
export function isValidLocale(locale: string): locale is SupportedLocale {
|
export function isValidLocale(locale: string): locale is SupportedLocale {
|
||||||
return supportedLocales.some(
|
return supportedLocales.some((l) => l.code.toLowerCase() === locale.toLowerCase());
|
||||||
(l) => l.code.toLowerCase() === locale.toLowerCase(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -376,11 +427,11 @@ export function isValidLocale(locale: string): locale is SupportedLocale {
|
||||||
*/
|
*/
|
||||||
export function getLocale(locale?: string): SupportedLocale {
|
export function getLocale(locale?: string): SupportedLocale {
|
||||||
if (!locale) return defaultLocale;
|
if (!locale) return defaultLocale;
|
||||||
|
|
||||||
// Handle case-insensitive matching
|
// Handle case-insensitive matching
|
||||||
const normalized = locale.toLowerCase();
|
const normalized = locale.toLowerCase();
|
||||||
const found = supportedLocales.find((l) => l.code.toLowerCase() === normalized);
|
const found = supportedLocales.find((l) => l.code.toLowerCase() === normalized);
|
||||||
|
|
||||||
return found?.code ?? defaultLocale;
|
return found?.code ?? defaultLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import {
|
import { type SupportedLocale, createTranslator, detectLocale, getLocale } from "./index";
|
||||||
type SupportedLocale,
|
|
||||||
createTranslator,
|
|
||||||
detectLocale,
|
|
||||||
getLocale,
|
|
||||||
} from "./index";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Elysia plugin for i18n/locale handling
|
* Elysia plugin for i18n/locale handling
|
||||||
* Adds locale detection and translator to the context
|
* Adds locale detection and translator to the context
|
||||||
*/
|
*/
|
||||||
export const localeService = new Elysia({ name: "locale/service" })
|
export const localeService = new Elysia({ name: "locale/service" }).derive(
|
||||||
.derive({ as: "global" }, ({ request }) => {
|
{ as: "global" },
|
||||||
|
({ request }) => {
|
||||||
// Get locale from cookie (parsed from headers) or detect from Accept-Language header
|
// Get locale from cookie (parsed from headers) or detect from Accept-Language header
|
||||||
const cookieHeader = request.headers.get("cookie") ?? "";
|
const cookieHeader = request.headers.get("cookie") ?? "";
|
||||||
const acceptLanguage = request.headers.get("accept-language") ?? undefined;
|
const acceptLanguage = request.headers.get("accept-language") ?? undefined;
|
||||||
|
|
@ -37,7 +33,8 @@ export const localeService = new Elysia({ name: "locale/service" })
|
||||||
locale,
|
locale,
|
||||||
t: translator,
|
t: translator,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export type LocaleContext = {
|
export type LocaleContext = {
|
||||||
locale: SupportedLocale;
|
locale: SupportedLocale;
|
||||||
|
|
|
||||||
|
|
@ -9,233 +9,245 @@ 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).use(localeService).get(
|
export const history = new Elysia()
|
||||||
"/history",
|
.use(userService)
|
||||||
async ({ redirect, user, locale, t }) => {
|
.use(localeService)
|
||||||
if (HIDE_HISTORY) {
|
.get(
|
||||||
return redirect(`${WEBROOT}/`, 302);
|
"/history",
|
||||||
}
|
async ({ redirect, user, locale, t }) => {
|
||||||
|
if (HIDE_HISTORY) {
|
||||||
|
return redirect(`${WEBROOT}/`, 302);
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return redirect(`${WEBROOT}/login`, 302);
|
return redirect(`${WEBROOT}/login`, 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
let userJobs = db.query("SELECT * FROM jobs WHERE user_id = ?").as(Jobs).all(user.id).reverse();
|
let userJobs = db
|
||||||
|
.query("SELECT * FROM jobs WHERE user_id = ?")
|
||||||
|
.as(Jobs)
|
||||||
|
.all(user.id)
|
||||||
|
.reverse();
|
||||||
|
|
||||||
for (const job of userJobs) {
|
for (const job of userJobs) {
|
||||||
const files = db.query("SELECT * FROM file_names WHERE job_id = ?").as(Filename).all(job.id);
|
const files = db
|
||||||
|
.query("SELECT * FROM file_names WHERE job_id = ?")
|
||||||
|
.as(Filename)
|
||||||
|
.all(job.id);
|
||||||
|
|
||||||
job.finished_files = files.length;
|
job.finished_files = files.length;
|
||||||
job.files_detailed = files;
|
job.files_detailed = files;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out jobs with no files
|
// Filter out jobs with no files
|
||||||
userJobs = userJobs.filter((job) => job.num_files > 0);
|
userJobs = userJobs.filter((job) => job.num_files > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseHtml webroot={WEBROOT} title="ConvertX-CN | Results" locale={locale}>
|
<BaseHtml webroot={WEBROOT} title="ConvertX-CN | 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}
|
locale={locale}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
<main
|
<main
|
||||||
class={`
|
class={`
|
||||||
w-full flex-1 px-2
|
w-full flex-1 px-2
|
||||||
sm:px-4
|
sm:px-4
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<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">{t("history", "title")}</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"
|
||||||
class={`
|
class={`
|
||||||
flex btn-secondary flex-row gap-2 text-contrast
|
flex btn-secondary flex-row gap-2 text-contrast
|
||||||
disabled:cursor-not-allowed disabled:opacity-50
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
`}
|
`}
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<DeleteIcon />{" "}
|
<DeleteIcon />{" "}
|
||||||
<span>
|
<span>
|
||||||
{t("history", "deleteSelected")} (<span id="selected-count">0</span>)
|
{t("history", "deleteSelected")} (<span id="selected-count">0</span>)
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<table
|
||||||
<table
|
class={`
|
||||||
class={`
|
w-full table-auto overflow-y-auto rounded bg-neutral-900 text-left
|
||||||
w-full table-auto overflow-y-auto rounded bg-neutral-900 text-left
|
[&_td]:p-4
|
||||||
[&_td]:p-4
|
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
|
||||||
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
|
`}
|
||||||
`}
|
>
|
||||||
>
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th
|
||||||
<th
|
class={`
|
||||||
class={`
|
px-2 py-2
|
||||||
px-2 py-2
|
sm:px-4
|
||||||
sm:px-4
|
`}
|
||||||
`}
|
>
|
||||||
>
|
<input
|
||||||
<input
|
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={t("history", "selectAll")}
|
||||||
title={t("history", "selectAll")}
|
/>
|
||||||
/>
|
</th>
|
||||||
</th>
|
<th
|
||||||
<th
|
class={`
|
||||||
class={`
|
px-2 py-2
|
||||||
px-2 py-2
|
sm:px-4
|
||||||
sm:px-4
|
`}
|
||||||
`}
|
>
|
||||||
>
|
<span class="sr-only">{t("history", "expandDetails")}</span>
|
||||||
<span class="sr-only">{t("history", "expandDetails")}</span>
|
</th>
|
||||||
</th>
|
<th
|
||||||
<th
|
class={`
|
||||||
class={`
|
px-2 py-2
|
||||||
px-2 py-2
|
sm:px-4
|
||||||
sm:px-4
|
`}
|
||||||
`}
|
>
|
||||||
>
|
{t("history", "time")}
|
||||||
{t("history", "time")}
|
</th>
|
||||||
</th>
|
<th
|
||||||
<th
|
class={`
|
||||||
class={`
|
px-2 py-2
|
||||||
px-2 py-2
|
sm:px-4
|
||||||
sm:px-4
|
`}
|
||||||
`}
|
>
|
||||||
>
|
{t("history", "files")}
|
||||||
{t("history", "files")}
|
</th>
|
||||||
</th>
|
<th
|
||||||
<th
|
class={`
|
||||||
class={`
|
px-2 py-2
|
||||||
px-2 py-2
|
max-sm:hidden
|
||||||
max-sm:hidden
|
sm:px-4
|
||||||
sm:px-4
|
`}
|
||||||
`}
|
>
|
||||||
>
|
{t("history", "filesDone")}
|
||||||
{t("history", "filesDone")}
|
</th>
|
||||||
</th>
|
<th
|
||||||
<th
|
class={`
|
||||||
class={`
|
px-2 py-2
|
||||||
px-2 py-2
|
sm:px-4
|
||||||
sm:px-4
|
`}
|
||||||
`}
|
>
|
||||||
>
|
{t("history", "status")}
|
||||||
{t("history", "status")}
|
</th>
|
||||||
</th>
|
<th
|
||||||
<th
|
class={`
|
||||||
class={`
|
px-2 py-2
|
||||||
px-2 py-2
|
sm:px-4
|
||||||
sm:px-4
|
`}
|
||||||
`}
|
>
|
||||||
>
|
{t("history", "actions")}
|
||||||
{t("history", "actions")}
|
</th>
|
||||||
</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{userJobs.map((job) => (
|
||||||
{userJobs.map((job) => (
|
<>
|
||||||
<>
|
<tr id={`job-row-${job.id}`}>
|
||||||
<tr id={`job-row-${job.id}`}>
|
<td>
|
||||||
<td>
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
class="h-4 w-4 cursor-pointer"
|
||||||
class="h-4 w-4 cursor-pointer"
|
data-checkbox-type="job"
|
||||||
data-checkbox-type="job"
|
data-job-id={job.id}
|
||||||
data-job-id={job.id}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td class="job-details-toggle cursor-pointer" data-job-id={job.id}>
|
|
||||||
<svg
|
|
||||||
id={`arrow-${job.id}`}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="inline-block h-4 w-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</td>
|
||||||
</td>
|
<td class="job-details-toggle cursor-pointer" data-job-id={job.id}>
|
||||||
<td safe>
|
<svg
|
||||||
{new Date(job.date_created).toLocaleTimeString(LANGUAGE, {
|
id={`arrow-${job.id}`}
|
||||||
timeZone: TIMEZONE,
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
})}
|
fill="none"
|
||||||
</td>
|
viewBox="0 0 24 24"
|
||||||
<td>{job.num_files}</td>
|
stroke-width="1.5"
|
||||||
<td class="max-sm:hidden">{job.finished_files}</td>
|
stroke="currentColor"
|
||||||
<td safe>{job.status}</td>
|
class="inline-block h-4 w-4"
|
||||||
<td class="flex flex-row gap-4">
|
>
|
||||||
<a
|
<path
|
||||||
class={`
|
stroke-linecap="round"
|
||||||
text-accent-500 underline
|
stroke-linejoin="round"
|
||||||
hover:text-accent-400
|
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||||
`}
|
/>
|
||||||
href={`${WEBROOT}/results/${job.id}`}
|
</svg>
|
||||||
>
|
</td>
|
||||||
<EyeIcon />
|
<td safe>
|
||||||
</a>
|
{new Date(job.date_created).toLocaleTimeString(LANGUAGE, {
|
||||||
<a
|
timeZone: TIMEZONE,
|
||||||
class={`
|
})}
|
||||||
text-accent-500 underline
|
</td>
|
||||||
hover:text-accent-400
|
<td>{job.num_files}</td>
|
||||||
`}
|
<td class="max-sm:hidden">{job.finished_files}</td>
|
||||||
href={`${WEBROOT}/delete/${job.id}`}
|
<td safe>{job.status}</td>
|
||||||
>
|
<td class="flex flex-row gap-4">
|
||||||
<DeleteIcon />
|
<a
|
||||||
</a>
|
class={`
|
||||||
</td>
|
text-accent-500 underline
|
||||||
</tr>
|
hover:text-accent-400
|
||||||
<tr id={`details-${job.id}`} class="hidden">
|
`}
|
||||||
<td colspan="7">
|
href={`${WEBROOT}/results/${job.id}`}
|
||||||
<div class="p-2 text-sm text-neutral-500">
|
>
|
||||||
<div class="mb-1 font-semibold">{t("history", "detailedFileInfo")}</div>
|
<EyeIcon />
|
||||||
{job.files_detailed.map((file: Filename) => (
|
</a>
|
||||||
<div class="flex items-center">
|
<a
|
||||||
<span class="w-5/12 truncate" title={file.file_name} safe>
|
class={`
|
||||||
{file.file_name}
|
text-accent-500 underline
|
||||||
</span>
|
hover:text-accent-400
|
||||||
<svg
|
`}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
href={`${WEBROOT}/delete/${job.id}`}
|
||||||
viewBox="0 0 20 20"
|
>
|
||||||
fill="currentColor"
|
<DeleteIcon />
|
||||||
class={`mx-2 inline-block h-4 w-4 text-neutral-500`}
|
</a>
|
||||||
>
|
</td>
|
||||||
<path
|
</tr>
|
||||||
fill-rule="evenodd"
|
<tr id={`details-${job.id}`} class="hidden">
|
||||||
d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z"
|
<td colspan="7">
|
||||||
clip-rule="evenodd"
|
<div class="p-2 text-sm text-neutral-500">
|
||||||
/>
|
<div class="mb-1 font-semibold">
|
||||||
</svg>
|
{t("history", "detailedFileInfo")}
|
||||||
<span class="w-5/12 truncate" title={file.output_file_name} safe>
|
|
||||||
{file.output_file_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
{job.files_detailed.map((file: Filename) => (
|
||||||
</div>
|
<div class="flex items-center">
|
||||||
</td>
|
<span class="w-5/12 truncate" title={file.file_name} safe>
|
||||||
</tr>
|
{file.file_name}
|
||||||
</>
|
</span>
|
||||||
))}
|
<svg
|
||||||
</tbody>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</table>
|
viewBox="0 0 20 20"
|
||||||
</article>
|
fill="currentColor"
|
||||||
</main>
|
class={`mx-2 inline-block h-4 w-4 text-neutral-500`}
|
||||||
<script>
|
>
|
||||||
{`
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="w-5/12 truncate" title={file.output_file_name} safe>
|
||||||
|
{file.output_file_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
{`
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Expand/collapse job details
|
// Expand/collapse job details
|
||||||
const toggles = document.querySelectorAll('.job-details-toggle');
|
const toggles = document.querySelectorAll('.job-details-toggle');
|
||||||
|
|
@ -330,12 +342,12 @@ export const history = new Elysia().use(userService).use(localeService).get(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
`}
|
`}
|
||||||
</script>
|
</script>
|
||||||
</>
|
</>
|
||||||
</BaseHtml>
|
</BaseHtml>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ 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/index";
|
import { type Translator, createTranslator, defaultLocale } from "../i18n/index";
|
||||||
import { localeService } from "../i18n/service";
|
import { localeService } from "../i18n/service";
|
||||||
import { userService } from "./user";
|
import { userService } from "./user";
|
||||||
|
|
||||||
|
|
@ -165,7 +165,13 @@ export const results = new Elysia()
|
||||||
return (
|
return (
|
||||||
<BaseHtml webroot={WEBROOT} title="ConvertX-CN | Result" locale={locale}>
|
<BaseHtml webroot={WEBROOT} title="ConvertX-CN | Result" locale={locale}>
|
||||||
<>
|
<>
|
||||||
<Header webroot={WEBROOT} allowUnauthenticated={ALLOW_UNAUTHENTICATED} loggedIn locale={locale} t={t} />
|
<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
|
||||||
|
|
|
||||||
|
|
@ -17,237 +17,245 @@ import {
|
||||||
import { localeService } from "../i18n/service";
|
import { localeService } from "../i18n/service";
|
||||||
import { FIRST_RUN, userService } from "./user";
|
import { FIRST_RUN, userService } from "./user";
|
||||||
|
|
||||||
export const root = new Elysia().use(userService).use(localeService).get(
|
export const root = new Elysia()
|
||||||
"/",
|
.use(userService)
|
||||||
async ({ jwt, redirect, cookie: { auth, jobId }, locale, t }) => {
|
.use(localeService)
|
||||||
if (!ALLOW_UNAUTHENTICATED) {
|
.get(
|
||||||
if (FIRST_RUN) {
|
"/",
|
||||||
return redirect(`${WEBROOT}/setup`, 302);
|
async ({ jwt, redirect, cookie: { auth, jobId }, locale, t }) => {
|
||||||
|
if (!ALLOW_UNAUTHENTICATED) {
|
||||||
|
if (FIRST_RUN) {
|
||||||
|
return redirect(`${WEBROOT}/setup`, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth?.value) {
|
||||||
|
return redirect(`${WEBROOT}/login`, 302);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth?.value) {
|
// validate jwt
|
||||||
|
let user: ({ id: string } & JWTPayloadSpec) | false = false;
|
||||||
|
if (ALLOW_UNAUTHENTICATED) {
|
||||||
|
const newUserId = String(
|
||||||
|
UNAUTHENTICATED_USER_SHARING
|
||||||
|
? 0
|
||||||
|
: randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)),
|
||||||
|
);
|
||||||
|
const accessToken = await jwt.sign({
|
||||||
|
id: newUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
user = { id: newUserId };
|
||||||
|
if (!auth) {
|
||||||
|
return {
|
||||||
|
message: t("auth", "noCookies"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// set cookie
|
||||||
|
auth.set({
|
||||||
|
value: accessToken,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !HTTP_ALLOWED,
|
||||||
|
maxAge: 24 * 60 * 60,
|
||||||
|
sameSite: "strict",
|
||||||
|
});
|
||||||
|
} else if (auth?.value) {
|
||||||
|
user = await jwt.verify(auth.value);
|
||||||
|
|
||||||
|
if (
|
||||||
|
user !== false &&
|
||||||
|
user.id &&
|
||||||
|
(Number.parseInt(user.id) < 2 ** 24 || !ALLOW_UNAUTHENTICATED)
|
||||||
|
) {
|
||||||
|
// Make sure user exists in db
|
||||||
|
const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
if (auth?.value) {
|
||||||
|
auth.remove();
|
||||||
|
}
|
||||||
|
return redirect(`${WEBROOT}/login`, 302);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
return redirect(`${WEBROOT}/login`, 302);
|
return redirect(`${WEBROOT}/login`, 302);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// validate jwt
|
// create a new job
|
||||||
let user: ({ id: string } & JWTPayloadSpec) | false = false;
|
db.query("INSERT INTO jobs (user_id, date_created) VALUES (?, ?)").run(
|
||||||
if (ALLOW_UNAUTHENTICATED) {
|
user.id,
|
||||||
const newUserId = String(
|
new Date().toISOString(),
|
||||||
UNAUTHENTICATED_USER_SHARING
|
|
||||||
? 0
|
|
||||||
: randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)),
|
|
||||||
);
|
);
|
||||||
const accessToken = await jwt.sign({
|
|
||||||
id: newUserId,
|
|
||||||
});
|
|
||||||
|
|
||||||
user = { id: newUserId };
|
const { id } = db
|
||||||
if (!auth) {
|
.query("SELECT id FROM jobs WHERE user_id = ? ORDER BY id DESC")
|
||||||
return {
|
.get(user.id) as { id: number };
|
||||||
message: t("auth", "noCookies"),
|
|
||||||
};
|
if (!jobId) {
|
||||||
|
return { message: t("auth", "cookiesRequired") };
|
||||||
}
|
}
|
||||||
|
|
||||||
// set cookie
|
jobId.set({
|
||||||
auth.set({
|
value: id,
|
||||||
value: accessToken,
|
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: !HTTP_ALLOWED,
|
secure: !HTTP_ALLOWED,
|
||||||
maxAge: 24 * 60 * 60,
|
maxAge: 24 * 60 * 60,
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
});
|
});
|
||||||
} else if (auth?.value) {
|
|
||||||
user = await jwt.verify(auth.value);
|
|
||||||
|
|
||||||
if (
|
console.log("jobId set to:", id);
|
||||||
user !== false &&
|
|
||||||
user.id &&
|
|
||||||
(Number.parseInt(user.id) < 2 ** 24 || !ALLOW_UNAUTHENTICATED)
|
|
||||||
) {
|
|
||||||
// Make sure user exists in db
|
|
||||||
const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
|
|
||||||
|
|
||||||
if (!existingUser) {
|
return (
|
||||||
if (auth?.value) {
|
<BaseHtml webroot={WEBROOT} locale={locale}>
|
||||||
auth.remove();
|
<>
|
||||||
}
|
<Header
|
||||||
return redirect(`${WEBROOT}/login`, 302);
|
webroot={WEBROOT}
|
||||||
}
|
accountRegistration={ACCOUNT_REGISTRATION}
|
||||||
}
|
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
||||||
}
|
hideHistory={HIDE_HISTORY}
|
||||||
|
loggedIn
|
||||||
if (!user) {
|
locale={locale}
|
||||||
return redirect(`${WEBROOT}/login`, 302);
|
t={t}
|
||||||
}
|
/>
|
||||||
|
<main
|
||||||
// create a new job
|
class={`
|
||||||
db.query("INSERT INTO jobs (user_id, date_created) VALUES (?, ?)").run(
|
w-full flex-1 px-2
|
||||||
user.id,
|
sm:px-4
|
||||||
new Date().toISOString(),
|
`}
|
||||||
);
|
|
||||||
|
|
||||||
const { id } = db
|
|
||||||
.query("SELECT id FROM jobs WHERE user_id = ? ORDER BY id DESC")
|
|
||||||
.get(user.id) as { id: number };
|
|
||||||
|
|
||||||
if (!jobId) {
|
|
||||||
return { message: t("auth", "cookiesRequired") };
|
|
||||||
}
|
|
||||||
|
|
||||||
jobId.set({
|
|
||||||
value: id,
|
|
||||||
httpOnly: true,
|
|
||||||
secure: !HTTP_ALLOWED,
|
|
||||||
maxAge: 24 * 60 * 60,
|
|
||||||
sameSite: "strict",
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("jobId set to:", id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseHtml webroot={WEBROOT} locale={locale}>
|
|
||||||
<>
|
|
||||||
<Header
|
|
||||||
webroot={WEBROOT}
|
|
||||||
accountRegistration={ACCOUNT_REGISTRATION}
|
|
||||||
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
|
||||||
hideHistory={HIDE_HISTORY}
|
|
||||||
loggedIn
|
|
||||||
locale={locale}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
<main
|
|
||||||
class={`
|
|
||||||
w-full flex-1 px-2
|
|
||||||
sm:px-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<article class="article">
|
|
||||||
<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"
|
|
||||||
class={`
|
|
||||||
w-full table-auto rounded bg-neutral-900
|
|
||||||
[&_td]:p-4 [&_td]:first:max-w-[30vw] [&_td]:first:truncate
|
|
||||||
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
id="dropzone"
|
|
||||||
class={`
|
|
||||||
relative flex h-48 w-full items-center justify-center rounded border border-dashed
|
|
||||||
border-neutral-700 transition-all
|
|
||||||
hover:border-neutral-600
|
|
||||||
[&.dragover]:border-4 [&.dragover]:border-neutral-500
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<b>{t("convert", "chooseFile")}</b> {t("convert", "orDragHere")}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
name="file"
|
|
||||||
multiple
|
|
||||||
class="absolute inset-0 size-full cursor-pointer opacity-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<form
|
|
||||||
method="post"
|
|
||||||
action={`${WEBROOT}/convert`}
|
|
||||||
class="relative mx-auto mb-[35vh] w-full max-w-4xl"
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="file_names" id="file_names" />
|
<article class="article">
|
||||||
<article class="article w-full">
|
<h1 class="mb-4 text-xl">{t("convert", "title")}</h1>
|
||||||
<input
|
<div class="mb-4 scrollbar-thin max-h-[50vh] overflow-y-auto">
|
||||||
type="search"
|
<table
|
||||||
name="convert_to_search"
|
id="file-list"
|
||||||
placeholder={t("convert", "searchConversions")}
|
|
||||||
autocomplete="off"
|
|
||||||
class="w-full rounded-sm bg-neutral-800 p-4"
|
|
||||||
/>
|
|
||||||
<div class="select_container relative">
|
|
||||||
<article
|
|
||||||
class={`
|
class={`
|
||||||
convert_to_popup absolute z-2 m-0 hidden h-[30vh] max-h-[50vh] w-full flex-col
|
w-full table-auto rounded bg-neutral-900
|
||||||
overflow-x-hidden overflow-y-auto rounded bg-neutral-800
|
[&_td]:p-4 [&_td]:first:max-w-[30vw] [&_td]:first:truncate
|
||||||
sm:h-[30vh]
|
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
|
||||||
`}
|
`}
|
||||||
>
|
/>
|
||||||
{Object.entries(getAllTargets()).map(([converter, targets]) => (
|
</div>
|
||||||
<article
|
<div
|
||||||
class={`
|
id="dropzone"
|
||||||
convert_to_group flex w-full flex-col border-b border-neutral-700 p-4
|
class={`
|
||||||
`}
|
relative flex h-48 w-full items-center justify-center rounded border
|
||||||
data-converter={converter}
|
border-dashed border-neutral-700 transition-all
|
||||||
>
|
hover:border-neutral-600
|
||||||
<header class="mb-2 w-full text-xl font-bold" safe>
|
[&.dragover]:border-4 [&.dragover]:border-neutral-500
|
||||||
{converter}
|
`}
|
||||||
</header>
|
>
|
||||||
<ul class={`convert_to_target flex flex-row flex-wrap gap-1`}>
|
<span>
|
||||||
{targets.map((target) => (
|
<b>{t("convert", "chooseFile")}</b> {t("convert", "orDragHere")}
|
||||||
<button
|
</span>
|
||||||
// https://stackoverflow.com/questions/121499/when-a-blur-event-occurs-how-can-i-find-out-which-element-focus-went-to#comment82388679_33325953
|
<input
|
||||||
tabindex={0}
|
type="file"
|
||||||
class={`
|
name="file"
|
||||||
target rounded bg-neutral-700 p-1 text-base
|
multiple
|
||||||
hover:bg-neutral-600
|
class="absolute inset-0 size-full cursor-pointer opacity-0"
|
||||||
`}
|
/>
|
||||||
data-value={`${target},${converter}`}
|
|
||||||
data-target={target}
|
|
||||||
data-converter={converter}
|
|
||||||
type="button"
|
|
||||||
safe
|
|
||||||
>
|
|
||||||
{target}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
{/* Hidden element which determines the format to convert the file too and the converter to use */}
|
|
||||||
<select name="convert_to" aria-label={t("convert", "convertTo")} required hidden>
|
|
||||||
<option selected disabled value="">
|
|
||||||
{t("convert", "convertTo")}
|
|
||||||
</option>
|
|
||||||
{Object.entries(getAllTargets()).map(([converter, targets]) => (
|
|
||||||
<optgroup label={converter}>
|
|
||||||
{targets.map((target) => (
|
|
||||||
<option value={`${target},${converter}`} safe>
|
|
||||||
{target}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<input
|
<form
|
||||||
class={`
|
method="post"
|
||||||
w-full btn-primary opacity-100
|
action={`${WEBROOT}/convert`}
|
||||||
disabled:cursor-not-allowed disabled:opacity-50
|
class="relative mx-auto mb-[35vh] w-full max-w-4xl"
|
||||||
`}
|
>
|
||||||
type="submit"
|
<input type="hidden" name="file_names" id="file_names" />
|
||||||
value={t("convert", "convertButton")}
|
<article class="article w-full">
|
||||||
disabled
|
<input
|
||||||
/>
|
type="search"
|
||||||
</form>
|
name="convert_to_search"
|
||||||
</main>
|
placeholder={t("convert", "searchConversions")}
|
||||||
<script src="script.js" defer />
|
autocomplete="off"
|
||||||
</>
|
class="w-full rounded-sm bg-neutral-800 p-4"
|
||||||
</BaseHtml>
|
/>
|
||||||
);
|
<div class="select_container relative">
|
||||||
},
|
<article
|
||||||
{
|
class={`
|
||||||
cookie: t.Cookie({
|
convert_to_popup absolute z-2 m-0 hidden h-[30vh] max-h-[50vh] w-full
|
||||||
auth: t.Optional(t.String()),
|
flex-col overflow-x-hidden overflow-y-auto rounded bg-neutral-800
|
||||||
jobId: t.Optional(t.String()),
|
sm:h-[30vh]
|
||||||
locale: t.Optional(t.String()),
|
`}
|
||||||
}),
|
>
|
||||||
},
|
{Object.entries(getAllTargets()).map(([converter, targets]) => (
|
||||||
);
|
<article
|
||||||
|
class={`
|
||||||
|
convert_to_group flex w-full flex-col border-b border-neutral-700 p-4
|
||||||
|
`}
|
||||||
|
data-converter={converter}
|
||||||
|
>
|
||||||
|
<header class="mb-2 w-full text-xl font-bold" safe>
|
||||||
|
{converter}
|
||||||
|
</header>
|
||||||
|
<ul class={`convert_to_target flex flex-row flex-wrap gap-1`}>
|
||||||
|
{targets.map((target) => (
|
||||||
|
<button
|
||||||
|
// https://stackoverflow.com/questions/121499/when-a-blur-event-occurs-how-can-i-find-out-which-element-focus-went-to#comment82388679_33325953
|
||||||
|
tabindex={0}
|
||||||
|
class={`
|
||||||
|
target rounded bg-neutral-700 p-1 text-base
|
||||||
|
hover:bg-neutral-600
|
||||||
|
`}
|
||||||
|
data-value={`${target},${converter}`}
|
||||||
|
data-target={target}
|
||||||
|
data-converter={converter}
|
||||||
|
type="button"
|
||||||
|
safe
|
||||||
|
>
|
||||||
|
{target}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Hidden element which determines the format to convert the file too and the converter to use */}
|
||||||
|
<select
|
||||||
|
name="convert_to"
|
||||||
|
aria-label={t("convert", "convertTo")}
|
||||||
|
required
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<option selected disabled value="">
|
||||||
|
{t("convert", "convertTo")}
|
||||||
|
</option>
|
||||||
|
{Object.entries(getAllTargets()).map(([converter, targets]) => (
|
||||||
|
<optgroup label={converter}>
|
||||||
|
{targets.map((target) => (
|
||||||
|
<option value={`${target},${converter}`} safe>
|
||||||
|
{target}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<input
|
||||||
|
class={`
|
||||||
|
w-full btn-primary opacity-100
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
`}
|
||||||
|
type="submit"
|
||||||
|
value={t("convert", "convertButton")}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script src="script.js" defer />
|
||||||
|
</>
|
||||||
|
</BaseHtml>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cookie: t.Cookie({
|
||||||
|
auth: t.Optional(t.String()),
|
||||||
|
jobId: t.Optional(t.String()),
|
||||||
|
locale: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,11 @@ export const user = new Elysia()
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<input type="submit" value={t("auth", "registerButton")} class="w-full btn-primary" />
|
<input
|
||||||
|
type="submit"
|
||||||
|
value={t("auth", "registerButton")}
|
||||||
|
class="w-full btn-primary"
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -302,7 +306,11 @@ export const user = new Elysia()
|
||||||
>
|
>
|
||||||
{t("nav", "register")}
|
{t("nav", "register")}
|
||||||
</a>
|
</a>
|
||||||
<input type="submit" value={t("auth", "loginButton")} class="w-full btn-primary" />
|
<input
|
||||||
|
type="submit"
|
||||||
|
value={t("auth", "loginButton")}
|
||||||
|
class="w-full btn-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -441,7 +449,11 @@ export const user = new Elysia()
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div role="group">
|
<div role="group">
|
||||||
<input type="submit" value={t("auth", "updateButton")} class="w-full btn-primary" />
|
<input
|
||||||
|
type="submit"
|
||||||
|
value={t("auth", "updateButton")}
|
||||||
|
class={`w-full btn-primary`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue