theseus/bundled-addons/translate/panel.html
Local Dev 501c7cf273 feat(theseus/translate): new sidebar add-on — right-click Translate selection
Adds a bundled add-on `translate` with a sidebar panel + a right-click
"Translate selection" menu item. Two swappable backends:

- LibreTranslate (default) — free MIT engine; the panel's Settings tab
  lets the user point at any instance (public or self-hosted) and drop
  in an API key if one's required.
- Google (unofficial free endpoint at translate.googleapis.com/
  translate_a/single) — no key, wide coverage, but unofficial and
  Google can break it any time. Opt-in fallback.

Flow: user selects text on a page, right-clicks -> "Translate
selection". Add-on's context-menu handler stashes the selection under
storage.__pending and calls api.revealSidebar("main"); the panel
loads, drains __pending on first paint, and translates. Ctrl/Cmd+Enter
in the input textarea also translates. Source + target language
choices, browser-language default target, swap button, copy-to-
clipboard on the output, settings gear.

Depends on a new "context-menu-item" capability + api.revealSidebar
hook in addons-host.js / main.js. Those wiring changes are prepared
but not committed here — a parallel session is refactoring the same
functions concurrently, so the safe path is to land translate/ first
and let the wiring go in alongside the next host-facing commit. Until
the wiring lands, the manifest's "context-menu-item" cap is silently
dropped (per validateManifest's unknown-caps policy) and the sidebar
panel + the panel's translation UI still work standalone — the
right-click entry point is what's gated.
2026-09-20 17:50:06 +02:00

350 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.argosopentech.com" />
</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", pick another URL or self-host. Google (unofficial) uses
<code>translate.googleapis.com/translate_a/single</code> — free, 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 settingsState = { backend: "libretranslate", ltUrl: "https://translate.argosopentech.com", 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); }
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(/\/+$/, "") || "https://translate.argosopentech.com";
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 : "");
}
// --- backends ----------------------------------------------------------
async function translateLibre(text, source, target) {
const url = settingsState.ltUrl.replace(/\/+$/, "") + "/translate";
const body = { q: text, source: source === "auto" ? "auto" : source, target, format: "text" };
if (settingsState.ltKey) body.api_key = settingsState.ltKey;
const r = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) {
let msg = "HTTP " + r.status;
try { const j = await r.json(); if (j?.error) msg = j.error; } catch {}
throw new Error(msg);
}
const j = await r.json();
return {
text: String(j.translatedText || j.translated_text || ""),
detected: j.detectedLanguage?.language || null,
};
}
async function translateGoogle(text, source, target) {
// Unofficial free endpoint used by every browser translation extension.
// Google can break this at any time; it's an opt-in fallback.
const params = new URLSearchParams({
client: "gtx",
sl: source === "auto" ? "auto" : source,
tl: target,
dt: "t",
q: text,
});
const r = await fetch(`https://translate.googleapis.com/translate_a/single?${params}`);
if (!r.ok) throw new Error("HTTP " + r.status);
const j = await r.json();
// Response shape: [ [ [translated, original, null, null, ...], ... ], null, detectedLang ]
const chunks = Array.isArray(j?.[0]) ? j[0] : [];
const translated = chunks.map((c) => (Array.isArray(c) ? String(c[0] || "") : "")).join("");
const detected = typeof j?.[2] === "string" ? j[2] : null;
return { text: translated, detected };
}
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 fn = settingsState.backend === "google" ? translateGoogle : translateLibre;
const res = await fn(text, source, target);
out.textContent = res.text || "(empty response)";
const via = settingsState.backend === "google" ? "google" : "libretranslate";
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>