const LANGS = [ { id: "vi", label: "Tiếng Việt", bg: "#fee2e2", ph: "Nhập văn bản tiếng Việt..." }, { id: "cvnss", label: "CVNSS4.0", bg: "#dbeafe", ph: "Nhập CVNSS hoặc xem mã hóa..." }, { id: "en", label: "English", bg: "#ccfbf1", ph: "Type here..." }, { id: "zh", label: "中文", bg: "#fef3c7", ph: "输入文本..." }, { id: "lo", label: "ພາສາລາວ", bg: "#dcfce7", ph: "ພິມຢູ່ທີ່ນີ້..." }, { id: "km", label: "ភាសាខ្មែរ", bg: "#f3e8ff", ph: "វាយអត្ថបទ..." }, ]; const API = "/api/translate"; const L2_KEY = "bila_tx_cache_v4"; const MAX_L2_ITEMS = 800; // Queue-safe debounce: CVNSS feels instant, but network translation is sent only // after the user pauses. This avoids creating upstream tasks for every keystroke. const DEBOUNCE_FAST = 550; const DEBOUNCE_TYPING = 900; const DEBOUNCE_LONG_TEXT = 1350; const LONG_TEXT_CHARS = 360; const FRONTEND_PARALLEL_TARGETS = 2; const grid = document.getElementById("grid"); const ui = { tx: {}, st: {}, ct: {} }; const netState = document.getElementById("netState"); const latencyEl = document.getElementById("latency"); const cacheState = document.getElementById("cacheState"); const liveToggle = document.getElementById("liveToggle"); let activeLang = "vi"; let currentRun = 0; let debounceTimer = null; let lastInputAt = 0; let controllers = []; let forceRefreshNext = false; let l2 = loadL2(); let channel = null; try { channel = new BroadcastChannel("bila-translator-sync"); } catch {} if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/static/sw.js").catch(() => {}); } grid.innerHTML = LANGS.map(l => `

${l.label}

idle
0 ký tự
`).join(""); LANGS.forEach(l => { ui.tx[l.id] = document.getElementById(`tx-${l.id}`); ui.st[l.id] = document.getElementById(`st-${l.id}`); ui.ct[l.id] = document.getElementById(`ct-${l.id}`); ui.tx[l.id].addEventListener("input", e => onInput(l.id, e.target.value)); }); document.querySelectorAll("[data-copy]").forEach(btn => { btn.addEventListener("click", () => copyText(btn.dataset.copy)); }); document.getElementById("clearBtn").addEventListener("click", clearAll); document.getElementById("refreshBtn").addEventListener("click", () => { forceRefreshNext = true; const val = ui.tx[activeLang]?.value?.trim(); if (val) triggerSync(activeLang, val); }); if (channel) { channel.onmessage = ev => { if (!ev.data || ev.data.type !== "cache") return; l2[ev.data.key] = ev.data.val; persistL2(); }; } function onInput(lang, value) { activeLang = lang; ui.ct[lang].textContent = `${value.length} ký tự`; // Mark all previous network results as stale immediately. Do not wait for the // next debounce tick, otherwise an old translation may update the UI while the // user is still typing. currentRun++; abortAll(); // Local-only preview: Vietnamese <-> CVNSS remains effectively offline-speed. updateLocalPreview(lang, value); if (!liveToggle.checked) return; if (!value.trim()) { clearTargets(lang); return; } clearTimeout(debounceTimer); const now = performance.now(); const gap = now - lastInputAt; lastInputAt = now; const delay = value.length > LONG_TEXT_CHARS ? DEBOUNCE_LONG_TEXT : (gap > 420 ? DEBOUNCE_FAST : DEBOUNCE_TYPING); netState.textContent = `Chờ dừng gõ ${delay} ms`; debounceTimer = setTimeout(() => triggerSync(lang, value.trim()), delay); } function updateLocalPreview(lang, value) { try { if (lang === "vi") { const out = value.trim() ? window.CVNSSConverter.toCVN(value) : ""; ui.tx.cvnss.value = out; ui.ct.cvnss.textContent = `${out.length} ký tự`; setStatus("cvnss", out ? "done" : "", out ? "local" : "idle"); } else if (lang === "cvnss") { const out = value.trim() ? window.CVNSSConverter.toVI(value) : ""; ui.tx.vi.value = out; ui.ct.vi.textContent = `${out.length} ký tự`; setStatus("vi", out ? "done" : "", out ? "local" : "idle"); } } catch {} } function setStatus(id, state, label) { const el = ui.st[id]; el.className = `badge ${state || ""}`.trim(); el.textContent = label || (state === "loading" ? "dịch" : state === "error" ? "lỗi" : state === "done" ? "xong" : "idle"); } function clearTargets(except) { currentRun++; clearTimeout(debounceTimer); abortAll(); LANGS.filter(l => l.id !== except).forEach(l => { ui.tx[l.id].value = ""; ui.ct[l.id].textContent = "0 ký tự"; setStatus(l.id, "", "idle"); }); netState.textContent = "Sẵn sàng"; } function clearAll() { currentRun++; clearTimeout(debounceTimer); abortAll(); LANGS.forEach(l => { ui.tx[l.id].value = ""; ui.ct[l.id].textContent = "0 ký tự"; setStatus(l.id, "", "idle"); }); toast("Đã làm sạch"); } async function triggerSync(srcLang, text) { const runId = ++currentRun; abortAll(); const started = performance.now(); const targets = LANGS.filter(l => l.id !== srcLang); targets.forEach(t => setStatus(t.id, "loading")); netState.textContent = "Đang đồng bộ"; let viText = text; try { if (srcLang === "cvnss") viText = window.CVNSSConverter.toVI(text); else if (srcLang !== "vi") viText = await translateCached(text, srcLang, "vi", runId); if (runId !== currentRun) return; } catch (err) { if (runId !== currentRun) return; targets.forEach(t => setStatus(t.id, "error")); netState.textContent = "Lỗi pivot"; return; } await mapLimit(targets, FRONTEND_PARALLEL_TARGETS, async target => { try { let out = ""; if (target.id === "vi") out = viText; else if (target.id === "cvnss") out = window.CVNSSConverter.toCVN(viText); else out = await translateCached(viText, "vi", target.id, runId); if (runId !== currentRun) return; ui.tx[target.id].value = out; ui.ct[target.id].textContent = `${out.length} ký tự`; setStatus(target.id, "done", out ? "xong" : "trống"); } catch (err) { if (runId !== currentRun) return; setStatus(target.id, "error", "lỗi"); } }); if (runId !== currentRun) return; const ms = Math.round(performance.now() - started); latencyEl.textContent = `${ms} ms`; netState.textContent = "Hoàn tất"; forceRefreshNext = false; } async function mapLimit(items, limit, worker) { const queue = items.slice(); const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => { while (queue.length) { const item = queue.shift(); await worker(item); } }); await Promise.allSettled(runners); } async function translateCached(text, sourceLang, targetLang, runId) { if (!text || sourceLang === targetLang) return text; const key = makeKey(sourceLang, targetLang, text); if (!forceRefreshNext && l2[key]) { cacheState.textContent = "L2 hit"; return l2[key]; } const ctrl = new AbortController(); controllers.push(ctrl); const res = await fetch(API, { method: "POST", headers: { "Content-Type": "application/json" }, signal: ctrl.signal, body: JSON.stringify({ sourceText: text, sourceLang, targetLang, forceRefresh: forceRefreshNext }) }); if (runId !== currentRun) throw new Error("stale"); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`); if (data.cached) cacheState.textContent = "Server hit"; else cacheState.textContent = "Fresh"; if (data.ms != null) latencyEl.textContent = `${data.ms} ms`; l2[key] = data.text || ""; trimL2(); persistL2(); if (channel) channel.postMessage({ type: "cache", key, val: l2[key] }); return l2[key]; } function abortAll() { controllers.forEach(c => c.abort()); controllers = []; } function makeKey(sourceLang, targetLang, text) { return `${sourceLang}|${targetLang}|${simpleHash(text)}|${text.length}`; } function simpleHash(str) { let h1 = 0xdeadbeef, h2 = 0x41c6ce57; for (let i = 0, ch; i < str.length; i++) { ch = str.charCodeAt(i); h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36); } function loadL2() { try { return JSON.parse(localStorage.getItem(L2_KEY) || "{}"); } catch { return {}; } } function persistL2() { try { localStorage.setItem(L2_KEY, JSON.stringify(l2)); } catch {} } function trimL2() { const keys = Object.keys(l2); if (keys.length <= MAX_L2_ITEMS) return; keys.slice(0, keys.length - MAX_L2_ITEMS).forEach(k => delete l2[k]); } function copyText(id) { const val = ui.tx[id].value; if (!val) return; navigator.clipboard.writeText(val).then(() => toast("Đã sao chép")).catch(() => { ui.tx[id].select(); document.execCommand("copy"); toast("Đã sao chép"); }); } function toast(msg) { const t = document.getElementById("toast"); t.textContent = msg; t.classList.add("show"); clearTimeout(t.timer); t.timer = setTimeout(() => t.classList.remove("show"), 1400); } LANGS.forEach(l => setStatus(l.id, "", "idle"));