feat(i18n): add multi-language UI support with 5 languages

- Add i18n framework with locale detection and cookie persistence
- Support English, Traditional Chinese (zh-TW), Simplified Chinese (zh-CN), Japanese (ja), Korean (ko)
- Add LanguageSelector component in navigation header
- Create locale JSON files with full translations
- Update all page components (root, user, results, history) with i18n support
- Add client-side translation helpers for dynamic content
- Auto-detect user's preferred language from browser settings
- Bump version to v0.1.3
This commit is contained in:
Your Name 2026-01-20 10:01:55 +08:00
parent e792dfedf1
commit 33301033ae
21 changed files with 1205 additions and 89 deletions

143
public/i18n.js Normal file
View file

@ -0,0 +1,143 @@
// Client-side i18n helper
(function () {
const LOCALE_COOKIE_NAME = "locale";
const LOCALE_EXPIRY_DAYS = 365;
// Get current locale from meta tag or cookie
function getCurrentLocale() {
const metaLocale = document.querySelector("meta[name='locale']")?.content;
if (metaLocale) return metaLocale;
const cookieLocale = getCookie(LOCALE_COOKIE_NAME);
if (cookieLocale) return cookieLocale;
return "en";
}
// Get cookie value
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(";").shift();
return null;
}
// Set cookie value
function setCookie(name, value, days) {
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Strict`;
}
// Get translation from window.__TRANSLATIONS__
function t(category, key, params) {
const translations = window.__TRANSLATIONS__ || {};
let text = translations[category]?.[key];
if (!text) {
console.warn(`Missing translation: ${category}.${key}`);
return `${category}.${key}`;
}
// Interpolate params
if (params) {
text = text.replace(/\{(\w+)\}/g, (match, paramKey) => {
return params[paramKey] !== undefined ? String(params[paramKey]) : match;
});
}
return text;
}
// Expose t function globally
window.t = t;
window.getCurrentLocale = getCurrentLocale;
// Language selector functionality
document.addEventListener("DOMContentLoaded", () => {
const toggle = document.getElementById("language-toggle");
const dropdown = document.getElementById("language-dropdown");
if (!toggle || !dropdown) return;
// Toggle dropdown
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = !dropdown.classList.contains("hidden");
if (isOpen) {
dropdown.classList.add("hidden");
dropdown.classList.remove("flex");
toggle.setAttribute("aria-expanded", "false");
} else {
dropdown.classList.remove("hidden");
dropdown.classList.add("flex");
toggle.setAttribute("aria-expanded", "true");
}
});
// Close dropdown when clicking outside
document.addEventListener("click", (e) => {
if (!toggle.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.classList.add("hidden");
dropdown.classList.remove("flex");
toggle.setAttribute("aria-expanded", "false");
}
});
// Handle language selection
const options = document.querySelectorAll(".language-option");
options.forEach((option) => {
option.addEventListener("click", () => {
const locale = option.dataset.locale;
const webroot = option.dataset.webroot || "";
// Save to cookie
setCookie(LOCALE_COOKIE_NAME, locale, LOCALE_EXPIRY_DAYS);
// Reload the page to apply the new locale
window.location.reload();
});
});
// Keyboard navigation for dropdown
toggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle.click();
}
});
dropdown.addEventListener("keydown", (e) => {
const items = [...dropdown.querySelectorAll(".language-option")];
const currentIndex = items.indexOf(document.activeElement);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (currentIndex < items.length - 1) {
items[currentIndex + 1].focus();
}
break;
case "ArrowUp":
e.preventDefault();
if (currentIndex > 0) {
items[currentIndex - 1].focus();
}
break;
case "Escape":
dropdown.classList.add("hidden");
dropdown.classList.remove("flex");
toggle.setAttribute("aria-expanded", "false");
toggle.focus();
break;
case "Enter":
case " ":
if (document.activeElement.classList.contains("language-option")) {
document.activeElement.click();
}
break;
}
});
});
})();

View file

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