Download static/app.js from CVNSS/dich: direct link, hf CLI and curl.
- Browser
- Download file 9.85 kB
-
https://huggingface.co/spaces/CVNSS/dich/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/CVNSS/dich/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/CVNSS/dich/resolve/main/static/app.js
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")); | |