feat(i18n): add multi-language UI support with 5 languages
- Add i18n framework with locale detection and cookie persistence - Support English, Traditional Chinese (zh-TW), Simplified Chinese (zh-CN), Japanese (ja), Korean (ko) - Add LanguageSelector component in navigation header - Create locale JSON files with full translations - Update all page components (root, user, results, history) with i18n support - Add client-side translation helpers for dynamic content - Auto-detect user's preferred language from browser settings - Bump version to v0.1.3
This commit is contained in:
parent
e792dfedf1
commit
33301033ae
21 changed files with 1205 additions and 89 deletions
143
public/i18n.js
Normal file
143
public/i18n.js
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue