dich / static /app.js
CVNSS's picture
Upload 14 files
34d3166 verified
Raw History Blame Contribute Delete
9.85 kB
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 => `
<article class="card" style="background:${l.bg}">
<div class="cardInner">
<div class="cardTop">
<h2 class="name">${l.label}</h2>
<span class="badge" id="st-${l.id}">idle</span>
</div>
<textarea id="tx-${l.id}" spellcheck="false" placeholder="${l.ph}" autocomplete="off"></textarea>
<div class="cardFoot">
<span class="small" id="ct-${l.id}">0 ký tự</span>
<div class="actions">
<button class="copy" type="button" data-copy="${l.id}">Sao chép</button>
</div>
</div>
</div>
</article>
`).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"));