Self-host the site translation instead of the Google widget
The widget needed inline-script CSP we refuse to grant, so: a /api/public/translate endpoint feeds misses through the chatbot's OpenRouter model and caches every string forever on the volume (translations.json); the client walks text nodes, swaps them in place, and a MutationObserver keeps late-rendered panels covered. CSP goes back to fully tight. 21 languages; choice sticks in localStorage; level names and POL stay untranslated.
This commit is contained in:
+87
-26
@@ -1,47 +1,108 @@
|
||||
// Floating language picker — lazy-loads the Google Translate element on first
|
||||
// tap so pages pay zero cost until someone actually wants a translation. The
|
||||
// googtrans cookie carries the chosen language across every page after that.
|
||||
// Floating language picker — self-hosted translation. Text nodes are collected,
|
||||
// translated through /api/public/translate (server-cached forever per string),
|
||||
// and swapped in place. A MutationObserver keeps late-rendered dashboard panels
|
||||
// translated too. No third-party scripts touch the page.
|
||||
(function(){
|
||||
var STYLE = ''+
|
||||
var LANGS=[['es','Español'],['pt','Português'],['fr','Français'],['de','Deutsch'],['it','Italiano'],
|
||||
['nl','Nederlands'],['pl','Polski'],['ro','Română'],['ru','Русский'],['uk','Українська'],
|
||||
['tr','Türkçe'],['ar','العربية'],['hi','हिन्दी'],['fil','Filipino'],['vi','Tiếng Việt'],
|
||||
['id','Bahasa Indonesia'],['th','ไทย'],['zh','中文'],['ja','日本語'],['ko','한국어'],['sw','Kiswahili']];
|
||||
var STYLE=''+
|
||||
'#rmcTrBtn{position:fixed;bottom:18px;left:18px;z-index:9998;display:flex;align-items:center;gap:7px;'+
|
||||
'background:#0d2236;border:1px solid #24425d;color:#f7f9fc;border-radius:999px;padding:9px 14px;'+
|
||||
'font:700 13px/1 Inter,system-ui,sans-serif;cursor:pointer;box-shadow:0 6px 24px rgba(0,0,0,.35)}'+
|
||||
'#rmcTrBtn:hover{border-color:#4ed6cb}'+
|
||||
'#rmcTrPanel{position:fixed;bottom:64px;left:18px;z-index:9999;background:#0d2236;border:1px solid #24425d;'+
|
||||
'border-radius:14px;padding:14px;display:none;box-shadow:0 10px 34px rgba(0,0,0,.45)}'+
|
||||
'border-radius:14px;padding:12px;display:none;box-shadow:0 10px 34px rgba(0,0,0,.45);max-height:60vh;overflow:auto;width:230px}'+
|
||||
'#rmcTrPanel.on{display:block}'+
|
||||
'#rmcTrPanel .t{color:#aebdca;font:600 12px/1.4 Inter,system-ui,sans-serif;margin:0 0 8px}'+
|
||||
/* suppress Google's top banner + body shift */
|
||||
'.goog-te-banner-frame,.skiptranslate iframe{display:none!important}'+
|
||||
'body{top:0!important}'+
|
||||
'#google_translate_element select{background:#071726;color:#f7f9fc;border:1px solid #24425d;border-radius:8px;padding:8px;font-size:14px;max-width:230px}'+
|
||||
'#google_translate_element .goog-te-gadget{color:#aebdca;font-size:11px}'+
|
||||
'#google_translate_element .goog-logo-link{color:#aebdca!important}';
|
||||
var loaded=false;
|
||||
'#rmcTrPanel button{display:block;width:100%;text-align:left;background:none;border:none;color:#f7f9fc;'+
|
||||
'padding:8px 10px;border-radius:8px;font:600 14px/1 Inter,system-ui,sans-serif;cursor:pointer}'+
|
||||
'#rmcTrPanel button:hover{background:#123049}'+
|
||||
'#rmcTrPanel button.on{background:#123049;color:#4ed6cb}';
|
||||
var lang=null;
|
||||
try{ lang=localStorage.getItem('rmc.lang')||null; }catch(e){}
|
||||
var cacheKey=function(){ return 'rmc.tr.'+lang; };
|
||||
var memo={};
|
||||
function loadMemo(){ memo={}; try{ memo=JSON.parse(sessionStorage.getItem(cacheKey())||'{}'); }catch(e){} }
|
||||
function saveMemo(){ try{ var k=Object.keys(memo); if(k.length>900){memo={};} sessionStorage.setItem(cacheKey(),JSON.stringify(memo)); }catch(e){} }
|
||||
|
||||
var SKIP={SCRIPT:1,STYLE:1,NOSCRIPT:1,CODE:1,TEXTAREA:1,SELECT:1};
|
||||
function collect(root){
|
||||
var nodes=[],w=document.createTreeWalker(root,NodeFilter.SHOW_TEXT,{acceptNode:function(n){
|
||||
var p=n.parentNode; if(!p||SKIP[p.nodeName])return NodeFilter.FILTER_REJECT;
|
||||
if(p.closest&&p.closest('#rmcTrPanel,#rmcTrBtn'))return NodeFilter.FILTER_REJECT;
|
||||
var t=n.nodeValue; if(!t||!t.trim()||t.trim().length<2)return NodeFilter.FILTER_REJECT;
|
||||
if(!/[A-Za-z]{2}/.test(t))return NodeFilter.FILTER_REJECT;
|
||||
if(n.__rmcTr===lang)return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}});
|
||||
var n; while((n=w.nextNode()))nodes.push(n);
|
||||
return nodes;
|
||||
}
|
||||
var busy=false,queued=false;
|
||||
function translatePage(root){
|
||||
if(!lang)return;
|
||||
if(busy){queued=true;return}
|
||||
busy=true;
|
||||
var nodes=collect(root||document.body);
|
||||
var uniq=[],idx={};
|
||||
nodes.forEach(function(n){ var t=n.nodeValue.trim(); if(!(t in idx)&&memo[t]==null){ idx[t]=uniq.length; uniq.push(t);} });
|
||||
var chunks=[]; for(var i=0;i<uniq.length;i+=45)chunks.push(uniq.slice(i,i+45));
|
||||
var p=Promise.resolve();
|
||||
chunks.forEach(function(ch){
|
||||
p=p.then(function(){
|
||||
return fetch('/api/public/translate',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({tl:lang,texts:ch})}).then(function(r){return r.json()}).then(function(d){
|
||||
if(d&&Array.isArray(d.t))ch.forEach(function(t,j){ memo[t]=d.t[j]; });
|
||||
}).catch(function(){});
|
||||
});
|
||||
});
|
||||
p.then(function(){
|
||||
saveMemo();
|
||||
nodes.forEach(function(n){
|
||||
var raw=n.nodeValue,t=raw.trim(),tr=memo[t];
|
||||
if(tr&&tr!==t){ n.nodeValue=raw.replace(t,tr); }
|
||||
n.__rmcTr=lang;
|
||||
});
|
||||
busy=false;
|
||||
if(queued){queued=false;translatePage();}
|
||||
});
|
||||
}
|
||||
var moTimer=null;
|
||||
var mo=new MutationObserver(function(){
|
||||
if(!lang)return;
|
||||
clearTimeout(moTimer); moTimer=setTimeout(function(){translatePage();},700);
|
||||
});
|
||||
|
||||
function setLang(code){
|
||||
if(code){ lang=code; try{localStorage.setItem('rmc.lang',code)}catch(e){} loadMemo(); translatePage(); }
|
||||
else{ lang=null; try{localStorage.removeItem('rmc.lang')}catch(e){} location.reload(); }
|
||||
}
|
||||
function mount(){
|
||||
var st=document.createElement('style');st.textContent=STYLE;document.head.appendChild(st);
|
||||
var btn=document.createElement('button');btn.id='rmcTrBtn';btn.type='button';
|
||||
btn.innerHTML='🌐 <span>Translate</span>';
|
||||
btn.setAttribute('aria-label','Translate this page');
|
||||
var panel=document.createElement('div');panel.id='rmcTrPanel';
|
||||
panel.innerHTML='<p class="t">Choose your language — it sticks on every page.</p><div id="google_translate_element"></div>';
|
||||
var htmlBits=['<p class="t">Choose your language — it sticks on every page.</p>',
|
||||
'<button data-l="">English (original)</button>'];
|
||||
LANGS.forEach(function(L){ htmlBits.push('<button data-l="'+L[0]+'"'+(lang===L[0]?' class="on"':'')+'>'+L[1]+'</button>'); });
|
||||
panel.innerHTML=htmlBits.join('');
|
||||
document.body.appendChild(btn);document.body.appendChild(panel);
|
||||
btn.addEventListener('click',function(){
|
||||
panel.classList.toggle('on');
|
||||
if(!loaded){
|
||||
loaded=true;
|
||||
window.googleTranslateElementInit=function(){
|
||||
new google.translate.TranslateElement({pageLanguage:'en',autoDisplay:false},'google_translate_element');
|
||||
};
|
||||
var s=document.createElement('script');
|
||||
s.src='https://translate.google.com/translate_a/element.js?cb=googleTranslateElementInit';
|
||||
s.onerror=function(){document.getElementById('google_translate_element').innerHTML='<p class="t">Translation is unavailable right now — your browser’s own “Translate page” option also works on this site.</p>';};
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
btn.addEventListener('click',function(){ panel.classList.toggle('on'); });
|
||||
panel.addEventListener('click',function(e){
|
||||
var b=e.target.closest('button[data-l]'); if(!b)return;
|
||||
panel.querySelectorAll('button').forEach(function(x){x.classList.remove('on')});
|
||||
if(b.dataset.l)b.classList.add('on');
|
||||
panel.classList.remove('on');
|
||||
setLang(b.dataset.l||null);
|
||||
});
|
||||
document.addEventListener('click',function(e){
|
||||
if(panel.classList.contains('on')&&!panel.contains(e.target)&&e.target!==btn&&!btn.contains(e.target))panel.classList.remove('on');
|
||||
if(panel.classList.contains('on')&&!panel.contains(e.target)&&!btn.contains(e.target))panel.classList.remove('on');
|
||||
});
|
||||
mo.observe(document.body,{childList:true,subtree:true});
|
||||
if(lang){ loadMemo(); translatePage(); }
|
||||
}
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',mount);else mount();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user