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
|
|
@ -5,6 +5,7 @@
|
|||
### Q: 如何註冊帳號?
|
||||
|
||||
首次訪問 ConvertX-CN 時:
|
||||
|
||||
1. 開啟 `http://localhost:3000`
|
||||
2. 點擊右上角 **Register**
|
||||
3. 輸入 Email 和密碼
|
||||
|
|
@ -26,6 +27,7 @@ environment:
|
|||
### Q: 忘記密碼怎麼辦?
|
||||
|
||||
目前版本尚未提供密碼重設功能。您可以:
|
||||
|
||||
1. 刪除 `data/convertx.db`
|
||||
2. 重新啟動容器
|
||||
3. 重新註冊
|
||||
|
|
@ -39,6 +41,7 @@ environment:
|
|||
### Q: 為什麼 Image 這麼大(4-6 GB)?
|
||||
|
||||
ConvertX-CN 是「完整版」,內建:
|
||||
|
||||
- LibreOffice(文件轉換)
|
||||
- TexLive(LaTeX 支援)
|
||||
- FFmpeg(影音轉換)
|
||||
|
|
@ -46,11 +49,13 @@ ConvertX-CN 是「完整版」,內建:
|
|||
- CJK 字型
|
||||
|
||||
如果您只需要基本功能,可使用原作者的輕量版:
|
||||
|
||||
- `ghcr.io/c4illin/convertx:latest`
|
||||
|
||||
### Q: Docker 啟動失敗?
|
||||
|
||||
常見原因:
|
||||
|
||||
1. **Port 被占用**:改用其他 port,如 `-p 3001:3000`
|
||||
2. **磁碟空間不足**:Image 需約 6GB
|
||||
3. **權限問題**:確保 `./data` 資料夾有寫入權限
|
||||
|
|
@ -62,6 +67,7 @@ chmod -R 777 ./data
|
|||
### Q: 資料存在哪裡?
|
||||
|
||||
所有資料存放在掛載的 `/app/data` 目錄:
|
||||
|
||||
- `convertx.db` - SQLite 資料庫
|
||||
- `uploads/` - 上傳的原始檔案
|
||||
- `output/` - 轉換後的檔案
|
||||
|
|
@ -97,6 +103,7 @@ chmod -R 777 ./data
|
|||
### Q: 支援哪些格式?
|
||||
|
||||
ConvertX-CN 支援數百種格式,包括:
|
||||
|
||||
- **影音**:MP4, AVI, MKV, MP3, WAV 等
|
||||
- **圖片**:PNG, JPG, WEBP, SVG, PDF 等
|
||||
- **文件**:DOCX, PDF, XLSX, PPTX 等
|
||||
|
|
@ -116,6 +123,7 @@ ConvertX-CN 支援數百種格式,包括:
|
|||
### Q: 檔案大小有限制嗎?
|
||||
|
||||
預設無硬性限制,但大檔案:
|
||||
|
||||
- 上傳需要更長時間
|
||||
- 轉換可能耗用大量記憶體
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ docker run -d \
|
|||
```
|
||||
|
||||
開啟瀏覽器訪問 `http://localhost:3000`:
|
||||
|
||||
1. 點擊右上角 **Register** 註冊帳號
|
||||
2. 輸入 Email 和密碼
|
||||
3. 完成!開始使用
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ export default tseslint.config(
|
|||
"target",
|
||||
"convert_to_target",
|
||||
"job-details-toggle",
|
||||
"language-selector",
|
||||
"language-option",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
{
|
||||
"$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"],
|
||||
"tailwind": {
|
||||
"entry": ["src/main.css"]
|
||||
}
|
||||
},
|
||||
"ignore": ["src/i18n/**"],
|
||||
"ignoreDependencies": [],
|
||||
"ignoreExportsUsedInFile": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@
|
|||
options.forEach((option) => {
|
||||
option.addEventListener("click", () => {
|
||||
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
|
||||
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="16x16" href={`${webroot}/favicon-16x16.png`} />
|
||||
<link rel="manifest" href={`${webroot}/site.webmanifest`} />
|
||||
<script>
|
||||
{`window.__TRANSLATIONS__ = ${JSON.stringify(getTranslations(locale))};`}
|
||||
</script>
|
||||
<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`}>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{t("nav", "language")}</span>
|
||||
<span
|
||||
class={`
|
||||
hidden
|
||||
sm:inline
|
||||
`}
|
||||
>
|
||||
{t("nav", "language")}
|
||||
</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
|
|
@ -51,8 +58,8 @@ export const LanguageSelector = ({
|
|||
<ul
|
||||
id="language-dropdown"
|
||||
class={`
|
||||
absolute right-0 top-full z-50 mt-2 hidden min-w-[160px] max-h-[320px] flex-col overflow-y-auto rounded
|
||||
border border-neutral-700 bg-neutral-800 shadow-lg
|
||||
absolute top-full right-0 z-50 mt-2 hidden max-h-[320px] min-w-[160px] flex-col
|
||||
overflow-y-auto rounded border border-neutral-700 bg-neutral-800 shadow-lg
|
||||
`}
|
||||
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
|
||||
transition-colors
|
||||
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-webroot={webroot}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// 預設開放註冊(開箱即用),管理者可設為 false 來關閉
|
||||
export const ACCOUNT_REGISTRATION =
|
||||
process.env.ACCOUNT_REGISTRATION?.toLowerCase() !== "false";
|
||||
export const ACCOUNT_REGISTRATION = process.env.ACCOUNT_REGISTRATION?.toLowerCase() !== "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";
|
||||
|
||||
export type SupportedLocale =
|
||||
| "en" | "zh-TW" | "zh-CN" | "ja" | "ko"
|
||||
| "de" | "fr" | "es" | "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";
|
||||
| "en"
|
||||
| "zh-TW"
|
||||
| "zh-CN"
|
||||
| "ja"
|
||||
| "ko"
|
||||
| "de"
|
||||
| "fr"
|
||||
| "es"
|
||||
| "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 {
|
||||
code: SupportedLocale;
|
||||
|
|
@ -278,8 +334,7 @@ export function t<T extends TranslationKey>(
|
|||
|
||||
if (typeof translation !== "string") {
|
||||
// Fallback to English
|
||||
const fallback =
|
||||
translations[fallbackLocale]?.[category]?.[key as keyof TranslationData[T]];
|
||||
const fallback = translations[fallbackLocale]?.[category]?.[key as keyof TranslationData[T]];
|
||||
if (typeof fallback !== "string") {
|
||||
return `${category}.${String(key)}`;
|
||||
}
|
||||
|
|
@ -326,9 +381,7 @@ export function detectLocale(acceptLanguage?: string): SupportedLocale {
|
|||
// Try to find a match
|
||||
for (const lang of languages) {
|
||||
// Direct match (case-insensitive)
|
||||
const directMatch = supportedLocales.find(
|
||||
(l) => l.code.toLowerCase() === lang.code
|
||||
);
|
||||
const directMatch = supportedLocales.find((l) => l.code.toLowerCase() === lang.code);
|
||||
if (directMatch) {
|
||||
return directMatch.code;
|
||||
}
|
||||
|
|
@ -347,7 +400,7 @@ export function detectLocale(acceptLanguage?: string): SupportedLocale {
|
|||
const baseCode = lang.code.split("-")[0];
|
||||
if (baseCode) {
|
||||
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) {
|
||||
return baseMatch.code;
|
||||
|
|
@ -364,9 +417,7 @@ export function detectLocale(acceptLanguage?: string): SupportedLocale {
|
|||
* @returns Whether the locale is valid
|
||||
*/
|
||||
export function isValidLocale(locale: string): locale is SupportedLocale {
|
||||
return supportedLocales.some(
|
||||
(l) => l.code.toLowerCase() === locale.toLowerCase(),
|
||||
);
|
||||
return supportedLocales.some((l) => l.code.toLowerCase() === locale.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
import { Elysia } from "elysia";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
createTranslator,
|
||||
detectLocale,
|
||||
getLocale,
|
||||
} from "./index";
|
||||
import { type SupportedLocale, createTranslator, detectLocale, getLocale } from "./index";
|
||||
|
||||
/**
|
||||
* Elysia plugin for i18n/locale handling
|
||||
* Adds locale detection and translator to the context
|
||||
*/
|
||||
export const localeService = new Elysia({ name: "locale/service" })
|
||||
.derive({ as: "global" }, ({ request }) => {
|
||||
export const localeService = new Elysia({ name: "locale/service" }).derive(
|
||||
{ as: "global" },
|
||||
({ request }) => {
|
||||
// Get locale from cookie (parsed from headers) or detect from Accept-Language header
|
||||
const cookieHeader = request.headers.get("cookie") ?? "";
|
||||
const acceptLanguage = request.headers.get("accept-language") ?? undefined;
|
||||
|
|
@ -37,7 +33,8 @@ export const localeService = new Elysia({ name: "locale/service" })
|
|||
locale,
|
||||
t: translator,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export type LocaleContext = {
|
||||
locale: SupportedLocale;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { userService } from "./user";
|
|||
import { EyeIcon } from "../icons/eye";
|
||||
import { DeleteIcon } from "../icons/delete";
|
||||
|
||||
export const history = new Elysia().use(userService).use(localeService).get(
|
||||
export const history = new Elysia()
|
||||
.use(userService)
|
||||
.use(localeService)
|
||||
.get(
|
||||
"/history",
|
||||
async ({ redirect, user, locale, t }) => {
|
||||
if (HIDE_HISTORY) {
|
||||
|
|
@ -20,10 +23,17 @@ export const history = new Elysia().use(userService).use(localeService).get(
|
|||
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) {
|
||||
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.files_detailed = files;
|
||||
|
|
@ -202,7 +212,9 @@ export const history = new Elysia().use(userService).use(localeService).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">{t("history", "detailedFileInfo")}</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,7 +7,7 @@ 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/index";
|
||||
import { type Translator, createTranslator, defaultLocale } from "../i18n/index";
|
||||
import { localeService } from "../i18n/service";
|
||||
import { userService } from "./user";
|
||||
|
||||
|
|
@ -165,7 +165,13 @@ export const results = new Elysia()
|
|||
return (
|
||||
<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
|
||||
class={`
|
||||
w-full flex-1 px-2
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ import {
|
|||
import { localeService } from "../i18n/service";
|
||||
import { FIRST_RUN, userService } from "./user";
|
||||
|
||||
export const root = new Elysia().use(userService).use(localeService).get(
|
||||
export const root = new Elysia()
|
||||
.use(userService)
|
||||
.use(localeService)
|
||||
.get(
|
||||
"/",
|
||||
async ({ jwt, redirect, cookie: { auth, jobId }, locale, t }) => {
|
||||
if (!ALLOW_UNAUTHENTICATED) {
|
||||
|
|
@ -138,8 +141,8 @@ export const root = new Elysia().use(userService).use(localeService).get(
|
|||
<div
|
||||
id="dropzone"
|
||||
class={`
|
||||
relative flex h-48 w-full items-center justify-center rounded border border-dashed
|
||||
border-neutral-700 transition-all
|
||||
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
|
||||
`}
|
||||
|
|
@ -172,8 +175,8 @@ export const root = new Elysia().use(userService).use(localeService).get(
|
|||
<div class="select_container relative">
|
||||
<article
|
||||
class={`
|
||||
convert_to_popup absolute z-2 m-0 hidden h-[30vh] max-h-[50vh] w-full flex-col
|
||||
overflow-x-hidden overflow-y-auto rounded bg-neutral-800
|
||||
convert_to_popup absolute z-2 m-0 hidden h-[30vh] max-h-[50vh] w-full
|
||||
flex-col overflow-x-hidden overflow-y-auto rounded bg-neutral-800
|
||||
sm:h-[30vh]
|
||||
`}
|
||||
>
|
||||
|
|
@ -211,7 +214,12 @@ export const root = new Elysia().use(userService).use(localeService).get(
|
|||
</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>
|
||||
<select
|
||||
name="convert_to"
|
||||
aria-label={t("convert", "convertTo")}
|
||||
required
|
||||
hidden
|
||||
>
|
||||
<option selected disabled value="">
|
||||
{t("convert", "convertTo")}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -173,7 +173,11 @@ export const user = new Elysia()
|
|||
/>
|
||||
</label>
|
||||
</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>
|
||||
</article>
|
||||
</main>
|
||||
|
|
@ -302,7 +306,11 @@ export const user = new Elysia()
|
|||
>
|
||||
{t("nav", "register")}
|
||||
</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>
|
||||
</form>
|
||||
</article>
|
||||
|
|
@ -441,7 +449,11 @@ export const user = new Elysia()
|
|||
</label>
|
||||
</fieldset>
|
||||
<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>
|
||||
</form>
|
||||
</article>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue