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:
Your Name 2026-01-20 12:16:13 +08:00
parent 8089fa0853
commit 569c572e62
16 changed files with 621 additions and 508 deletions

View file

@ -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 # 隱藏歷史紀錄頁面

View file

@ -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文件轉換
- TexLiveLaTeX 支援) - TexLiveLaTeX 支援)
- 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: 檔案大小有限制嗎?
預設無硬性限制,但大檔案: 預設無硬性限制,但大檔案:
- 上傳需要更長時間 - 上傳需要更長時間
- 轉換可能耗用大量記憶體 - 轉換可能耗用大量記憶體

View file

@ -35,6 +35,7 @@ docker run -d \
``` ```
開啟瀏覽器訪問 `http://localhost:3000` 開啟瀏覽器訪問 `http://localhost:3000`
1. 點擊右上角 **Register** 註冊帳號 1. 點擊右上角 **Register** 註冊帳號
2. 輸入 Email 和密碼 2. 輸入 Email 和密碼
3. 完成!開始使用 3. 完成!開始使用

View file

@ -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",
], ],
}, },
], ],

View file

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

View file

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

View file

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

View file

@ -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 = ({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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