Files
deeplx-app/script.js
T
xixu-me 9359811ccf Persist language selection using localStorage
Language dropdowns now restore the user's last selected source and target languages from localStorage, or fall back to defaults if none are saved. Selections are saved on change, improving user experience by remembering preferences across sessions.
2025-07-29 14:22:32 +08:00

758 lines
22 KiB
JavaScript

/**
* DeepLX Translation App
* A modern web app for DeepL translation using the DeepLX API
*
* Features:
* - Auto-translation with configurable delay
* - Translation history with local storage
* - RTL/LTR text direction support
* - Responsive design with modern UI
* - Keyboard shortcuts and accessibility
*
* @author Xi Xu
* @version 1.0.0
* @license MIT
*/
document.addEventListener("DOMContentLoaded", () => {
// Configuration constants
const CONFIG = {
MAX_CHARS: 5000,
DEFAULT_API_URL: "https://dplx.xi-xu.me/translate",
DEFAULT_DELAY: 1000,
MAX_HISTORY_ITEMS: 50,
CORS_PROXY: "https://corsproxy.io/?",
};
// Supported languages list - DeepL API compatible language codes
const languages = {
AR: "Arabic",
BG: "Bulgarian",
CS: "Czech",
DA: "Danish",
DE: "German",
EL: "Greek",
EN: "English",
ES: "Spanish",
ET: "Estonian",
FI: "Finnish",
FR: "French",
HU: "Hungarian",
ID: "Indonesian",
IT: "Italian",
JA: "Japanese",
KO: "Korean",
LT: "Lithuanian",
LV: "Latvian",
NB: "Norwegian (bokmål)",
NL: "Dutch",
PL: "Polish",
PT: "Portuguese",
RO: "Romanian",
RU: "Russian",
SK: "Slovak",
SL: "Slovenian",
SV: "Swedish",
TR: "Turkish",
UK: "Ukrainian",
ZH: "Chinese",
};
// Get DOM elements
// DOM element references - cached for performance
const elements = {
sourceLangSelect: document.getElementById("sourceLang"),
targetLangSelect: document.getElementById("targetLang"),
inputText: document.getElementById("inputText"),
outputText: document.getElementById("outputText"),
swapButton: document.getElementById("swapButton"),
copyButton: document.getElementById("copyButton"),
loadingSpinner: document.getElementById("loadingSpinner"),
statusMessage: document.getElementById("statusMessage"),
apiUrlInput: document.getElementById("apiUrlInput"),
historyButton: document.getElementById("historyButton"),
settingsButton: document.getElementById("settingsButton"),
historyPanel: document.getElementById("historyPanel"),
settingsPanel: document.getElementById("settingsPanel"),
closeHistoryButton: document.getElementById("closeHistoryButton"),
closeSettingsButton: document.getElementById("closeSettingsButton"),
historyList: document.getElementById("historyList"),
historyCount: document.getElementById("historyCount"),
clearHistoryButton: document.getElementById("clearHistoryButton"),
delayInput: document.getElementById("delayInput"),
autoTranslateToggle: document.getElementById("autoTranslateToggle"),
charCount: document.getElementById("charCount"),
};
// App state
let translateTimeout;
let translationHistory = [];
// Function to detect Arabic text
/**
* Utility Functions
*/
/**
* Detects if text contains Arabic characters
* @param {string} text - Text to analyze
* @returns {boolean} True if text contains Arabic characters
*/
function isArabicText(text) {
const arabicRegex =
/[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;
return arabicRegex.test(text);
}
/**
* Applies appropriate text direction (RTL/LTR) based on text content
* @param {HTMLElement} element - Element to apply direction to
* @param {string} text - Text content to analyze
*/
function applyTextDirection(element, text) {
if (isArabicText(text)) {
element.classList.add("rtl-text");
element.classList.remove("ltr-text");
} else {
element.classList.add("ltr-text");
element.classList.remove("rtl-text");
}
}
// Character counter function
/**
* UI Update Functions
*/
/**
* Updates character counter and input field styling
* Handles RTL/LTR text direction and placeholder updates
*/
function updateCharCount() {
const count = elements.inputText.value.length;
elements.charCount.textContent = count;
// Apply RTL/LTR direction based on input text
applyTextDirection(elements.inputText, elements.inputText.value);
// Update color based on character count
elements.charCount.className = "transition-colors duration-200";
if (count > 4500) {
elements.charCount.classList.add("char-danger");
} else if (count > 4000) {
elements.charCount.classList.add("char-warning");
}
// Update placeholder based on auto-translate status
if (elements.autoTranslateToggle.checked) {
elements.inputText.placeholder =
count === 0
? "Enter text to translate here... (Auto-translate enabled)"
: "Auto-translate will start after you stop typing...";
} else {
elements.inputText.placeholder =
"Enter text to translate here... (Auto-translate disabled)";
}
}
// Populate language dropdown menus
/**
* Language Management Functions
*/
/**
* Populates language dropdown menus with sorted language options
* Restores saved language selections or uses defaults
*/
function populateLanguages() {
elements.sourceLangSelect.innerHTML =
'<option value="AUTO">Detect language</option>';
// Sort languages alphabetically by name for better UX
const sortedLanguages = Object.entries(languages).sort(([, a], [, b]) =>
a.localeCompare(b)
);
sortedLanguages.forEach(([code, name]) => {
elements.sourceLangSelect.add(new Option(name, code));
});
elements.targetLangSelect.innerHTML = "";
sortedLanguages.forEach(([code, name]) => {
elements.targetLangSelect.add(new Option(name, code));
});
// Restore saved language selections or use defaults
const savedSourceLang = localStorage.getItem("deeplxSourceLang") || "AUTO";
const savedTargetLang = localStorage.getItem("deeplxTargetLang") || "EN";
elements.sourceLangSelect.value = savedSourceLang;
elements.targetLangSelect.value = savedTargetLang;
}
// Load and save settings
/**
* Settings Management Functions
*/
/**
* Initializes app settings from localStorage
* Sets up event listeners for settings changes
*/
function setupSettings() {
// Load saved settings with fallback defaults
const savedUrl = localStorage.getItem("deeplxApiUrl");
const savedDelay =
localStorage.getItem("autoTranslateDelay") ||
CONFIG.DEFAULT_DELAY.toString();
const savedAutoTranslate =
localStorage.getItem("autoTranslateEnabled") !== "false";
elements.apiUrlInput.value = savedUrl || CONFIG.DEFAULT_API_URL;
elements.delayInput.value = savedDelay;
elements.autoTranslateToggle.checked = savedAutoTranslate;
// Set up event listeners for settings persistence
elements.apiUrlInput.addEventListener("input", () => {
const urlToSave = elements.apiUrlInput.value.trim();
if (urlToSave) {
localStorage.setItem("deeplxApiUrl", urlToSave);
showStatus("API endpoint saved.", "success");
}
});
elements.delayInput.addEventListener("input", () => {
const delay = elements.delayInput.value;
localStorage.setItem("autoTranslateDelay", delay);
showStatus("Delay setting saved.", "success");
});
elements.autoTranslateToggle.addEventListener("change", () => {
localStorage.setItem(
"autoTranslateEnabled",
elements.autoTranslateToggle.checked
);
showStatus(
`Auto-translate ${
elements.autoTranslateToggle.checked ? "enabled" : "disabled"
}.`,
"success"
);
updateCharCount(); // Update placeholder when toggle changes
});
}
// Load translation history
/**
* History Management Functions
*/
/**
* Loads translation history from localStorage
*/
function loadHistory() {
const saved = localStorage.getItem("translationHistory");
if (saved) {
translationHistory = JSON.parse(saved);
}
updateHistoryDisplay();
}
/**
* Saves translation history to localStorage
*/
function saveHistory() {
localStorage.setItem(
"translationHistory",
JSON.stringify(translationHistory)
);
}
/**
* Adds a new translation to history
* @param {string} sourceText - Original text
* @param {string} targetText - Translated text
* @param {string} sourceLang - Source language code
* @param {string} targetLang - Target language code
*/
function addToHistory(sourceText, targetText, sourceLang, targetLang) {
const historyItem = {
id: Date.now(),
sourceText,
targetText,
sourceLang,
targetLang,
timestamp: new Date().toISOString(),
};
// Remove duplicate if exists
translationHistory = translationHistory.filter(
(item) =>
item.sourceText !== sourceText ||
item.sourceLang !== sourceLang ||
item.targetLang !== targetLang
);
// Add to beginning of array
translationHistory.unshift(historyItem);
// Keep only last items as per config
if (translationHistory.length > CONFIG.MAX_HISTORY_ITEMS) {
translationHistory = translationHistory.slice(
0,
CONFIG.MAX_HISTORY_ITEMS
);
}
saveHistory();
updateHistoryDisplay();
}
/**
* Updates the history display panel
*/
function updateHistoryDisplay() {
elements.historyCount.textContent = `${translationHistory.length} records`;
elements.historyList.innerHTML = "";
translationHistory.forEach((item) => {
const historyItem = document.createElement("div");
historyItem.className = "history-item";
const sourceTextClass = isArabicText(item.sourceText)
? "source-text rtl-text"
: "source-text";
const targetTextClass = isArabicText(item.targetText)
? "target-text rtl-text"
: "target-text";
historyItem.innerHTML = `
<div class="${sourceTextClass}">${item.sourceText.substring(0, 100)}${
item.sourceText.length > 100 ? "..." : ""
}</div>
<div class="${targetTextClass}">${item.targetText.substring(0, 100)}${
item.targetText.length > 100 ? "..." : ""
}</div>
<div class="lang-info">${
item.sourceLang === "AUTO"
? "Auto-detected"
: languages[item.sourceLang] || item.sourceLang
} → ${languages[item.targetLang] || item.targetLang}</div>
`;
historyItem.addEventListener("click", () => {
elements.inputText.value = item.sourceText;
elements.outputText.value = item.targetText;
elements.sourceLangSelect.value = item.sourceLang;
elements.targetLangSelect.value = item.targetLang;
// Apply text direction for loaded history items
applyTextDirection(elements.inputText, item.sourceText);
applyTextDirection(elements.outputText, item.targetText);
elements.historyPanel.classList.add("hidden");
showStatus("History item loaded.", "success");
});
elements.historyList.appendChild(historyItem);
});
}
// Display status message with enhanced animation
/**
* Status and Feedback Functions
*/
/**
* Displays status message with enhanced animation
* @param {string} message - Message to display
* @param {string} type - Message type ('error' or 'success')
*/
function showStatus(message, type = "error") {
elements.statusMessage.textContent = message;
elements.statusMessage.className = `text-sm h-5 text-right transition-all duration-300 ${
type === "error" ? "text-red-400" : "text-green-400"
}`;
// Add fade-in animation
elements.statusMessage.style.opacity = "0";
elements.statusMessage.style.transform = "translateY(10px)";
setTimeout(() => {
elements.statusMessage.style.opacity = "1";
elements.statusMessage.style.transform = "translateY(0)";
}, 50);
// Auto-hide message after 4 seconds
if (message) {
setTimeout(() => {
elements.statusMessage.style.opacity = "0";
setTimeout(() => {
elements.statusMessage.textContent = "";
elements.statusMessage.style.opacity = "1";
}, 300);
}, 4000);
}
}
// Translation function
/**
* Translation Functions
*/
/**
* Main translation function that handles API requests
* @param {boolean} isAutoTranslate - Whether this is an automatic translation
*/
async function translateText(isAutoTranslate = false) {
const originalApiUrl = elements.apiUrlInput.value.trim();
if (!originalApiUrl) {
showStatus("Please enter API endpoint.");
return;
}
// Using CORS proxy to handle POST requests correctly
const API_URL = `${CONFIG.CORS_PROXY}${encodeURIComponent(originalApiUrl)}`;
const text = elements.inputText.value.trim();
if (!text) {
if (!isAutoTranslate) {
showStatus("Please enter text to translate.");
}
elements.outputText.value = "";
return;
}
setLoading(true);
showStatus("", "success"); // Clear status
try {
const payload = {
text: text,
source_lang:
elements.sourceLangSelect.value === "AUTO"
? undefined
: elements.sourceLangSelect.value,
target_lang: elements.targetLangSelect.value,
};
const response = await fetch(API_URL, {
method: "POST",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
});
if (response.status === 404) {
throw new Error(
`Proxy cannot find API endpoint (404). Please check if URL is correct.`
);
}
if (!response.ok) {
throw new Error(
`Network or proxy error: ${response.status} ${response.statusText}`
);
}
const result = await response.json();
if (result.code === 200) {
elements.outputText.value = result.data;
// Apply RTL/LTR direction to output text
applyTextDirection(elements.outputText, result.data);
// Show detected source language when auto-detect is used
if (elements.sourceLangSelect.value === "AUTO" && result.source_lang) {
const detectedLangName =
languages[result.source_lang] || result.source_lang;
showStatus(`Detected language: ${detectedLangName}`, "success");
}
// Add to history if translation was successful
if (result.data && text) {
// Use detected language for history when auto-detect is used
const actualSourceLang =
elements.sourceLangSelect.value === "AUTO" && result.source_lang
? result.source_lang
: elements.sourceLangSelect.value;
addToHistory(
text,
result.data,
actualSourceLang,
elements.targetLangSelect.value
);
}
} else {
// Provide more specific error message from API response
throw new Error(`API error: ${result.message || "Unknown error"}`);
}
} catch (error) {
console.error("Translation request failed:", error);
let errorMessage = `Error: ${error.message}`;
if (error instanceof TypeError) {
// This often indicates a network-level failure
errorMessage =
"Error: Request failed. Please check network connection and browser console.";
}
showStatus(errorMessage);
elements.outputText.value = "";
} finally {
setLoading(false);
}
}
// Auto-translate with debounce
/**
* Auto-translation Functions
*/
/**
* Schedules auto-translation with debounce
*/
function scheduleAutoTranslate() {
if (!elements.autoTranslateToggle.checked) return;
clearTimeout(translateTimeout);
const delay = parseInt(elements.delayInput.value) || CONFIG.DEFAULT_DELAY;
translateTimeout = setTimeout(() => {
translateText(true);
}, delay);
}
/**
* UI State Management Functions
*/
/**
* Sets loading state for the app
* @param {boolean} isLoading - Whether app is in loading state
*/
function setLoading(isLoading) {
elements.loadingSpinner.classList.toggle("hidden", !isLoading);
}
/**
* Language Management Functions
*/
/**
* Swaps source and target languages
*/
function swapLanguages() {
const source = elements.sourceLangSelect.value;
const target = elements.targetLangSelect.value;
// Don't swap if source is auto-detect
if (source === "AUTO") {
return;
}
elements.sourceLangSelect.value = target;
elements.targetLangSelect.value = source;
const inputTextValue = elements.inputText.value;
elements.inputText.value = elements.outputText.value;
elements.outputText.value = inputTextValue;
// Apply text direction after swapping
applyTextDirection(elements.inputText, elements.inputText.value);
applyTextDirection(elements.outputText, elements.outputText.value);
showStatus("", "success");
}
/**
* Updates swap button state based on source language selection
*/
function updateSwapButtonState() {
const isAutoDetect = elements.sourceLangSelect.value === "AUTO";
elements.swapButton.disabled = isAutoDetect;
if (isAutoDetect) {
elements.swapButton.classList.add("opacity-50", "cursor-not-allowed");
elements.swapButton.classList.remove("hover:bg-gray-600");
elements.swapButton.title = "Cannot swap when using Auto Detect";
} else {
elements.swapButton.classList.remove("opacity-50", "cursor-not-allowed");
elements.swapButton.classList.add("hover:bg-gray-600");
elements.swapButton.title = "Swap languages";
}
}
/**
* Clipboard and UI Interaction Functions
*/
/**
* Copies translation result to clipboard with visual feedback
*/
function copyToClipboard() {
if (!elements.outputText.value) {
showStatus("No content to copy.");
return;
}
const textarea = document.createElement("textarea");
textarea.value = elements.outputText.value;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
showStatus("Copied to clipboard!", "success");
// Add pulse animation to copy button
elements.copyButton.classList.add("copy-success");
setTimeout(() => {
elements.copyButton.classList.remove("copy-success");
}, 300);
} catch (err) {
showStatus("Copy failed.");
console.error("Copy failed:", err);
}
document.body.removeChild(textarea);
}
/**
* Panel Management Functions
*/
/**
* Shows the history panel
*/
function showHistoryPanel() {
elements.historyPanel.classList.remove("hidden");
updateHistoryDisplay();
}
/**
* Hides the history panel
*/
function hideHistoryPanel() {
elements.historyPanel.classList.add("hidden");
}
/**
* Shows the settings panel
*/
function showSettingsPanel() {
elements.settingsPanel.classList.remove("hidden");
}
/**
* Hides the settings panel
*/
function hideSettingsPanel() {
elements.settingsPanel.classList.add("hidden");
}
/**
* Clears all translation history after confirmation
*/
function clearHistory() {
if (confirm("Are you sure you want to clear all translation history?")) {
translationHistory = [];
saveHistory();
updateHistoryDisplay();
showStatus("History cleared.", "success");
}
}
/**
* Animation and Visual Feedback Functions
*/
/**
* Adds visual feedback for successful operations
* @param {HTMLElement} element - Element to animate
*/
function addSuccessFeedback(element) {
element.style.transform = "scale(1.05)";
element.style.transition = "transform 0.2s ease";
setTimeout(() => {
element.style.transform = "scale(1)";
}, 200);
}
/**
* Enhanced swap function with animation
*/
function swapLanguagesWithAnimation() {
addSuccessFeedback(elements.swapButton);
swapLanguages();
}
// Initialize
/**
* App Initialization
*/
// Initialize core app components
populateLanguages();
setupSettings();
loadHistory();
updateCharCount(); // Initialize character counter
updateSwapButtonState(); // Initialize swap button state
lucide.createIcons(); // Initialize Lucide icons
// Initial auto-translate if there's existing text
if (elements.inputText.value.trim()) {
scheduleAutoTranslate();
}
/**
* Event Listeners Setup
*/
// Button event listeners
elements.swapButton.addEventListener("click", swapLanguagesWithAnimation);
elements.copyButton.addEventListener("click", copyToClipboard);
elements.historyButton.addEventListener("click", showHistoryPanel);
elements.settingsButton.addEventListener("click", showSettingsPanel);
elements.closeHistoryButton.addEventListener("click", hideHistoryPanel);
elements.closeSettingsButton.addEventListener("click", hideSettingsPanel);
elements.clearHistoryButton.addEventListener("click", clearHistory);
// Auto-translate triggers
elements.inputText.addEventListener("input", () => {
updateCharCount();
scheduleAutoTranslate();
});
elements.sourceLangSelect.addEventListener("change", () => {
updateSwapButtonState();
scheduleAutoTranslate();
// Save source language selection
localStorage.setItem("deeplxSourceLang", elements.sourceLangSelect.value);
});
elements.targetLangSelect.addEventListener("change", () => {
scheduleAutoTranslate();
// Save target language selection
localStorage.setItem("deeplxTargetLang", elements.targetLangSelect.value);
});
// Panel management - close when clicking outside
elements.historyPanel.addEventListener("click", (e) => {
if (e.target === elements.historyPanel) {
hideHistoryPanel();
}
});
elements.settingsPanel.addEventListener("click", (e) => {
if (e.target === elements.settingsPanel) {
hideSettingsPanel();
}
});
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.key === "Enter") {
translateText();
}
if (e.key === "Escape") {
hideHistoryPanel();
hideSettingsPanel();
}
});
});