theseus/bundled-addons/translate/panel.html
Local Dev 2b8e3a7eb6 translate 0.1.1 → 0.1.2: fetch from the addon's Node side, not the panel
The panel was doing the HTTP call itself, which meant any mirror sitting
behind a Cloudflare-style anti-bot check returned "<!doctype html>…" for
a POST from Origin: file:// and JSON.parse choked on it. Moving the
fetch into index.js gets rid of the whole class of browser-context
interceptors (CORS preflights, captive portals, anti-bot pages) and
lets the addon look at the response body before trying to parse it —
an HTML body is now reported cleanly as "server returned an HTML page
instead of JSON".

While there, chain a small mirror list — translate.disroot.org,
translate.plausibility.cloud, lingva.ml — so a single mirror being down
does not take the feature with it. A user whose saved URL points at a
mirror that stopped resolving (translate.argosopentech.com is the
notable case) now transparently gets a translation from the next mirror
in line instead of a stack trace.

Verified end-to-end from Node against both a working URL and a dead
one; the dead one falls through to disroot as expected.
2026-09-21 03:34:14 +02:00

335 lines
15 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Translate</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--btn:#1c2432; --btn-h:#242e40; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#f0f3fa; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
--btn:#f0f3fa; --btn-h:#e3e8f2; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--ink);
font: 13px/1.55 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; flex-direction: column; }
header { display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--line);
background: var(--panel); }
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; }
header .t .em { font-size: 15px; }
header .m { color: var(--dim); font-size: 11.5px; }
header .m.err { color: #ff9081; }
header .m.ok { color: var(--acid); }
.langs { display: flex; gap: 6px; align-items: center;
padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel2); }
.langs select { flex: 1; min-width: 0; padding: 6px 8px; font: inherit;
background: var(--bg); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; }
.langs .swap { padding: 6px 8px; background: var(--btn); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font: inherit; }
.langs .swap:hover { background: var(--btn-h); }
.body { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.zone { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.zone + .zone { border-top: 1px solid var(--line); }
.zone label { color: var(--dim); font-size: 10.5px; letter-spacing: .18em;
text-transform: uppercase; padding: 8px 14px 4px; display: flex;
justify-content: space-between; align-items: baseline; }
.zone label .action { color: var(--mut); font-size: 10.5px; cursor: pointer;
letter-spacing: normal; text-transform: none; }
.zone label .action:hover { color: var(--acid); }
textarea, .output {
flex: 1; padding: 8px 14px 14px; border: none; outline: none; resize: none;
background: transparent; color: var(--ink);
font: 13.5px/1.55 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
overflow-y: auto; white-space: pre-wrap; word-wrap: break-word;
}
textarea::placeholder { color: var(--dim); }
.output.empty { color: var(--dim); font-style: italic; }
.actions { display: flex; gap: 6px; padding: 8px 12px;
border-top: 1px solid var(--line); background: var(--panel); }
.actions .btn {
flex: 1; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
}
.actions .btn.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
.actions .btn:hover:not(:disabled) { background: var(--btn-h); }
.actions .btn.primary:hover:not(:disabled) { filter: brightness(1.05); }
.actions .btn:disabled { opacity: .45; cursor: default; }
.actions .btn.gear { flex: 0 0 auto; padding: 8px 10px; }
.settings {
display: none; padding: 10px 14px; background: var(--panel2);
border-top: 1px solid var(--line); font-size: 12px; color: var(--mut);
flex-direction: column; gap: 8px;
}
.settings.open { display: flex; }
.settings label { display: flex; flex-direction: column; gap: 4px; color: var(--dim); font-size: 11px; }
.settings input, .settings select { padding: 6px 8px; font: inherit;
background: var(--bg); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; }
.settings .hint { color: var(--dim); font-size: 11px; line-height: 1.5; }
.settings .hint code { background: var(--bg); border: 1px solid var(--line);
padding: 1px 4px; border-radius: 3px; font-size: 10.5px; }
</style>
</head>
<body>
<header>
<div class="t"><span class="em">🌐</span> <span>Translate</span></div>
<div class="m" id="status">ready</div>
</header>
<div class="langs">
<select id="src" title="Source language"></select>
<button class="swap" id="swap" title="Swap languages"></button>
<select id="tgt" title="Target language"></select>
</div>
<div class="body">
<div class="zone">
<label>Source <span class="action" id="clearIn">clear</span></label>
<textarea id="in" placeholder="Right-click a selection on a page, or paste text here." spellcheck="false"></textarea>
</div>
<div class="zone">
<label>Translation <span class="action" id="copyOut">copy</span></label>
<div class="output empty" id="out">The translation will appear here.</div>
</div>
</div>
<div class="actions">
<button class="btn primary" id="go">Translate</button>
<button class="btn gear" id="gear" title="Settings"></button>
</div>
<div class="settings" id="settings">
<label>
Backend
<select id="backend">
<option value="libretranslate">LibreTranslate</option>
<option value="google">Google (unofficial free endpoint)</option>
</select>
</label>
<label id="ltUrlLabel">
LibreTranslate server URL
<input id="ltUrl" type="url" placeholder="https://translate.disroot.org" list="ltMirrors" />
<datalist id="ltMirrors">
<option value="https://translate.disroot.org"></option>
<option value="https://translate.plausibility.cloud"></option>
<option value="https://lingva.ml"></option>
</datalist>
</label>
<label id="ltKeyLabel">
LibreTranslate API key (optional)
<input id="ltKey" type="text" placeholder="only if your instance requires one" />
</label>
<div class="hint">
LibreTranslate is a free MIT-licensed engine. Public instances rate-limit — if you
hit "too many requests" or a mirror is down, pick another URL from the list or
self-host. Google (unofficial) uses
<code>translate.googleapis.com/translate_a/single</code> — no key, but unofficial
and Google can break it at any time.
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const inp = $("in"), out = $("out"), status = $("status");
const srcSel = $("src"), tgtSel = $("tgt");
const goBtn = $("go"), swapBtn = $("swap"), gearBtn = $("gear");
const settings = $("settings");
const backendSel = $("backend"), ltUrl = $("ltUrl"), ltKey = $("ltKey");
const ltUrlLabel = $("ltUrlLabel"), ltKeyLabel = $("ltKeyLabel");
// ISO-639-1 short list. First `auto` for source only. Users can add more
// via settings later; for v1 this covers ~99% of use.
const LANGS = [
["ar","Arabic"],["bg","Bulgarian"],["cs","Czech"],["da","Danish"],
["de","German"],["el","Greek"],["en","English"],["es","Spanish"],
["et","Estonian"],["fi","Finnish"],["fr","French"],["he","Hebrew"],
["hi","Hindi"],["hu","Hungarian"],["id","Indonesian"],["it","Italian"],
["ja","Japanese"],["ko","Korean"],["lt","Lithuanian"],["lv","Latvian"],
["nb","Norwegian"],["nl","Dutch"],["pl","Polish"],["pt","Portuguese"],
["ro","Romanian"],["ru","Russian"],["sk","Slovak"],["sl","Slovenian"],
["sv","Swedish"],["th","Thai"],["tr","Turkish"],["uk","Ukrainian"],
["vi","Vietnamese"],["zh","Chinese"],
];
function fillSelect(el, includeAuto) {
el.innerHTML = "";
if (includeAuto) el.appendChild(new Option("Auto-detect", "auto"));
for (const [code, label] of LANGS) el.appendChild(new Option(label, code));
}
fillSelect(srcSel, true);
fillSelect(tgtSel, false);
// Default target = user's browser language, falling back to English.
const browserLang = (navigator.language || "en").split("-")[0].toLowerCase();
const defaultTarget = LANGS.some(([c]) => c === browserLang) && browserLang !== "en" ? browserLang : "en";
const S = window.silentmode?.storage;
const DEFAULT_LT_URL = "https://translate.disroot.org";
// Old defaults that we now migrate off — argosopentech.com went offline.
// If a user's saved URL matches one of these, silently upgrade it to the
// current working default so the panel isn't broken out of the box.
const DEAD_LT_URLS = new Set(["https://translate.argosopentech.com"]);
const settingsState = { backend: "libretranslate", ltUrl: DEFAULT_LT_URL, ltKey: "" };
const uiState = { src: "auto", tgt: defaultTarget };
async function loadState() {
if (!S) return;
try {
const saved = await S.get("settings", null);
if (saved && typeof saved === "object") Object.assign(settingsState, saved);
const ui = await S.get("ui", null);
if (ui && typeof ui === "object") Object.assign(uiState, ui);
} catch (e) { console.warn("load state failed:", e); }
// One-shot migration off a dead default URL.
if (DEAD_LT_URLS.has(settingsState.ltUrl)) {
settingsState.ltUrl = DEFAULT_LT_URL;
if (S) try { await S.set("settings", settingsState); } catch {}
}
backendSel.value = settingsState.backend;
ltUrl.value = settingsState.ltUrl;
ltKey.value = settingsState.ltKey;
srcSel.value = uiState.src;
tgtSel.value = uiState.tgt;
onBackendChange();
}
async function saveSettings() {
settingsState.backend = backendSel.value;
settingsState.ltUrl = ltUrl.value.trim().replace(/\/+$/, "") || DEFAULT_LT_URL;
settingsState.ltKey = ltKey.value.trim();
if (S) try { await S.set("settings", settingsState); } catch {}
}
async function saveUi() {
uiState.src = srcSel.value;
uiState.tgt = tgtSel.value;
if (S) try { await S.set("ui", uiState); } catch {}
}
function onBackendChange() {
const isLt = backendSel.value === "libretranslate";
ltUrlLabel.style.display = isLt ? "" : "none";
ltKeyLabel.style.display = isLt ? "" : "none";
}
function setStatus(text, cls = "") {
status.textContent = text;
status.className = "m" + (cls ? " " + cls : "");
}
// The panel used to fetch the translation server directly, but a public
// LibreTranslate mirror sitting behind a Cloudflare-style anti-bot page
// returns HTML for a POST from `Origin: file://`, and JSON.parse chokes on
// "<!doctype …". Doing the fetch server-side (via the add-on's index.js)
// sidesteps that whole class of problem and lets index.js fall through a
// small mirror list if the user's URL is down.
async function doTranslate() {
const text = inp.value.trim();
if (!text) { setStatus("nothing to translate"); return; }
const source = srcSel.value, target = tgtSel.value;
if (source && source !== "auto" && source === target) {
setStatus("source = target", "err");
out.textContent = text;
out.classList.remove("empty");
return;
}
goBtn.disabled = true; const orig = goBtn.textContent;
goBtn.textContent = "Translating…";
setStatus("translating…");
out.classList.remove("empty");
try {
const res = await window.silentmode.invoke("translate", {
text, source, target,
backend: settingsState.backend,
ltUrl: settingsState.ltUrl,
ltKey: settingsState.ltKey,
});
out.textContent = res.text || "(empty response)";
const via = res.via || settingsState.backend;
setStatus(res.detected ? `${via} · detected ${res.detected}` : via, "ok");
} catch (e) {
out.textContent = "";
out.classList.add("empty");
out.textContent = "Failed: " + (e?.message || String(e));
setStatus("failed", "err");
} finally {
goBtn.disabled = false; goBtn.textContent = orig;
}
saveUi();
}
// Buttons ---------------------------------------------------------------
goBtn.addEventListener("click", doTranslate);
swapBtn.addEventListener("click", () => {
if (srcSel.value === "auto") return;
const a = srcSel.value, b = tgtSel.value;
srcSel.value = b; tgtSel.value = a;
// Swap the text too if there's already output.
if (out.textContent && !out.classList.contains("empty")) {
inp.value = out.textContent;
out.textContent = ""; out.classList.add("empty");
}
saveUi();
});
$("clearIn").addEventListener("click", () => {
inp.value = ""; out.textContent = "The translation will appear here.";
out.classList.add("empty");
setStatus("ready");
inp.focus();
});
$("copyOut").addEventListener("click", async () => {
if (out.classList.contains("empty") || !out.textContent) return;
try {
await navigator.clipboard.writeText(out.textContent);
setStatus("copied", "ok");
setTimeout(() => setStatus("ready"), 1200);
} catch (e) { setStatus("copy failed", "err"); }
});
gearBtn.addEventListener("click", () => settings.classList.toggle("open"));
backendSel.addEventListener("change", async () => { onBackendChange(); await saveSettings(); });
ltUrl.addEventListener("change", saveSettings);
ltKey.addEventListener("change", saveSettings);
srcSel.addEventListener("change", saveUi);
tgtSel.addEventListener("change", saveUi);
// Ctrl/Cmd+Enter to translate.
inp.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); doTranslate(); }
});
// Pending selection from a right-click. When the addon dispatches
// "context-menu", the addon's index.js writes __pending to storage and
// reveals the sidebar; we pull it here.
async function drainPending() {
if (!S) return;
let pending = null;
try { pending = await S.get("__pending", null); } catch {}
if (!pending || !pending.text) return;
const age = Date.now() - (pending.at || 0);
if (age > 5 * 60_000) { try { await S.set("__pending", null); } catch {}; return; } // stale
inp.value = pending.text;
try { await S.set("__pending", null); } catch {}
setStatus(pending.host ? `from ${pending.host}` : "from page selection");
doTranslate();
}
// First paint. loadState → drainPending, and re-drain whenever the panel
// becomes visible again (the sidebar preload dispatches a "sidebar-
// visibility" event on its window).
(async () => {
await loadState();
await drainPending();
inp.focus();
})();
window.addEventListener("visibilitychange", () => { if (!document.hidden) drainPending(); });
// Sidebar preload emits this custom event on the panel's window when the
// sidebar is shown/hidden — see main.js's send("sidebar-visibility").
// Not all preload versions expose it as an event; if not, visibilitychange
// above covers most cases.
</script>
</body>
</html>