Bitcointalk translator with draggable modern panel
// ==UserScript==
// @name Bitcointalk Translator
// @namespace https://bitcointalk.org/
// @version 4.0.0
// @description Bitcointalk translator with draggable modern panel
// @author GhostOfBitcoin
// @match https://bitcointalk.org/*
// @icon https://bitcointalk.org/favicon.ico
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @connect translate.googleapis.com
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
/* =========================================================
CONFIG
========================================================= */
const CONFIG = {
lang:
GM_getValue('btc_lang', 'bn'),
side:
GM_getValue('btc_side', false),
auto:
GM_getValue('btc_auto', false),
panelOpen:
false
};
/* =========================================================
STYLES
========================================================= */
GM_addStyle(`
:root{
--btc-main:#5b8cff;
--btc-second:#7f5cff;
--btc-dark:#10131a;
--btc-dark2:#171b24;
--btc-text:#ffffff;
}
/* =====================================================
FLOAT BUTTON
===================================================== */
.btc-floating-btn{
position:fixed;
width:52px;
height:52px;
border-radius:50%;
bottom:20px;
right:20px;
z-index:999999;
background:
linear-gradient(
135deg,
var(--btc-main),
var(--btc-second)
);
display:flex;
align-items:center;
justify-content:center;
cursor:grab;
box-shadow:
0 10px 30px rgba(0,0,0,.35);
user-select:none;
transition:
transform .15s ease,
box-shadow .2s ease;
}
.btc-floating-btn:hover{
transform:scale(1.06);
}
.btc-floating-btn svg{
width:24px;
height:24px;
fill:white;
}
/* =====================================================
PANEL
===================================================== */
.btc-panel{
position:fixed;
width:290px;
background:
linear-gradient(
180deg,
rgba(20,24,35,.98),
rgba(15,18,28,.98)
);
backdrop-filter:blur(12px);
color:white;
border-radius:18px;
padding:18px;
z-index:999998;
box-shadow:
0 20px 50px rgba(0,0,0,.45);
border:
1px solid rgba(255,255,255,.06);
display:none;
animation:btcPanel .18s ease;
}
.btc-panel.active{
display:block;
}
@keyframes btcPanel{
from{
opacity:0;
transform:translateY(8px);
}
to{
opacity:1;
transform:translateY(0);
}
}
.btc-title{
font-size:16px;
font-weight:700;
margin-bottom:16px;
background:
linear-gradient(
90deg,
#8ab4ff,
#b388ff
);
-webkit-background-clip:text;
-webkit-text-fill-color:transparent;
}
.btc-panel select{
width:100%;
padding:10px 12px;
border:none;
border-radius:12px;
background:#1d2330;
color:white;
outline:none;
margin-bottom:14px;
font-size:14px;
}
.btc-switch{
display:flex;
align-items:center;
gap:8px;
margin-bottom:14px;
font-size:14px;
}
.btc-thread-btn{
width:100%;
padding:11px;
border:none;
border-radius:12px;
background:
linear-gradient(
135deg,
var(--btc-main),
var(--btc-second)
);
color:white;
font-weight:700;
cursor:pointer;
transition:.2s ease;
}
.btc-thread-btn:hover{
transform:translateY(-1px);
}
/* =====================================================
TRANSLATE BUTTON
===================================================== */
.btc-translate-btn{
display:inline-flex;
align-items:center;
gap:5px;
padding:5px 10px;
margin-top:7px;
border-radius:8px;
background:
rgba(91,140,255,.12);
border:
1px solid rgba(91,140,255,.22);
color:#8ab4ff;
font-size:12px;
font-weight:700;
cursor:pointer;
transition:.15s ease;
}
.btc-translate-btn:hover{
background:
rgba(91,140,255,.2);
}
/* =====================================================
TRANSLATION BOX
===================================================== */
.btc-translation{
margin-top:14px;
padding:16px;
border-radius:16px;
background:
rgba(255,255,255,.04);
border:
1px solid rgba(255,255,255,.06);
line-height:1.8;
font-size:14px;
animation:btcPanel .18s ease;
overflow-wrap:anywhere;
}
.btc-tools{
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:12px;
}
.btc-tool-buttons{
display:flex;
gap:7px;
}
.btc-tool{
border:none;
padding:5px 10px;
border-radius:8px;
background:#222a38;
color:white;
cursor:pointer;
font-size:12px;
}
.btc-ar{
direction:rtl;
text-align:right;
font-family:
Tahoma,
Arial,
sans-serif;
line-height:2.1;
}
@media(max-width:768px){
.btc-panel{
width:calc(100vw - 30px);
}
}
`);
/* =========================================================
CACHE
========================================================= */
class Cache{
constructor(){
this.prefix = 'btc_cache_';
}
hash(str){
let hash = 0;
for(let i=0;i<str.length;i++){
hash =
((hash << 5) - hash)
+ str.charCodeAt(i);
hash |= 0;
}
return hash;
}
get(key){
return localStorage.getItem(
this.prefix + key
);
}
set(key,val){
localStorage.setItem(
this.prefix + key,
val
);
}
}
const cache = new Cache();
/* =========================================================
ESCAPE
========================================================= */
function escapeHTML(str){
return str
.replace(/&/g,'&')
.replace(/</g,'<')
.replace(/>/g,'>');
}
/* =========================================================
TRANSLATOR
========================================================= */
async function translateText(text,target){
if(!text) return text;
const key =
cache.hash(text + target);
const cached =
cache.get(key);
if(cached){
return cached;
}
try{
const url =
`https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${target}&dt=t&dt=bd&dj=1&q=${encodeURIComponent(text)}`;
const res =
await fetch(url);
const data =
await res.json();
let translated = '';
if(data.sentences){
data.sentences.forEach(s=>{
translated += s.trans || '';
});
}
translated =
translated.trim();
if(!translated){
translated = text;
}
cache.set(key, translated);
return translated;
}catch(e){
console.error(e);
return text;
}
}
/* =========================================================
TRANSLATION UI
========================================================= */
function createTranslationBox(original,translated){
const box =
document.createElement('div');
box.className =
'btc-translation';
if(CONFIG.lang === 'ar'){
box.classList.add('btc-ar');
}
box.innerHTML = `
<div class="btc-tools">
<strong>
🌐 Translation
</strong>
<div class="btc-tool-buttons">
<button class="btc-tool btc-copy">
Copy
</button>
<button class="btc-tool btc-speak">
🔊
</button>
<button class="btc-tool btc-close">
✖
</button>
</div>
</div>
<div>
${
CONFIG.side
?
`
<div style="margin-bottom:15px;">
<b>Original:</b>
<div style="margin-top:6px;">
${escapeHTML(original)}
</div>
</div>
<div>
<b>Translated:</b>
<div style="margin-top:6px;">
${escapeHTML(translated)}
</div>
</div>
`
:
escapeHTML(translated)
}
</div>
`;
box.querySelector('.btc-copy')
.onclick = () => {
navigator.clipboard
.writeText(translated);
};
box.querySelector('.btc-speak')
.onclick = () => {
const speech =
new SpeechSynthesisUtterance(
translated
);
speech.lang =
CONFIG.lang;
speechSynthesis.speak(
speech
);
};
box.querySelector('.btc-close')
.onclick = () => {
box.remove();
};
return box;
}
/* =========================================================
PROCESS POSTS
========================================================= */
async function processPost(post){
if(post.dataset.btcDone){
return;
}
post.dataset.btcDone = '1';
const footer =
post.querySelector('.smalltext');
const content =
post.querySelector('.post');
if(!footer || !content){
return;
}
const btn =
document.createElement('div');
btn.className =
'btc-translate-btn';
btn.innerHTML =
'🌐 Translate';
footer.appendChild(btn);
btn.onclick = async () => {
const old =
post.querySelector(
'.btc-translation'
);
if(old){
old.remove();
return;
}
let text =
content.innerText.trim();
if(!text){
return;
}
btn.innerHTML =
'⏳ Translating';
const translated =
await translateText(
text,
CONFIG.lang
);
const box =
createTranslationBox(
text,
translated
);
content.appendChild(box);
btn.innerHTML =
'✅ Done';
setTimeout(()=>{
btn.innerHTML =
'🌐 Translate';
},1200);
};
if(CONFIG.auto){
btn.click();
}
}
/* =========================================================
OBSERVER
========================================================= */
function observe(){
const observer =
new MutationObserver(()=>{
const posts =
document.querySelectorAll(
'td.windowbg,td.windowbg2'
);
posts.forEach(
processPost
);
});
observer.observe(
document.body,
{
childList:true,
subtree:true
}
);
const posts =
document.querySelectorAll(
'td.windowbg,td.windowbg2'
);
posts.forEach(
processPost
);
}
/* =========================================================
FLOAT BUTTON
========================================================= */
function createFloatingUI(){
const btn =
document.createElement('div');
btn.className =
'btc-floating-btn';
btn.innerHTML = `
<svg viewBox="0 0 24 24">
<path d="M12 2a10 10 0 100 20 10 10 0 000-20zm6.93 9h-3.02a15.7 15.7 0 00-1.32-5.01A8.02 8.02 0 0118.93 11zm-6.93 9c-.83-1.2-1.53-3.02-1.86-5h3.72c-.33 1.98-1.03 3.8-1.86 5zm-2.14-7a13.7 13.7 0 010-2h4.28a13.7 13.7 0 010 2H9.86zm.28 2h3.72c-.33 1.98-1.03 3.8-1.86 5-.83-1.2-1.53-3.02-1.86-5zm-4.07-4a8.02 8.02 0 014.34-5.01A15.7 15.7 0 009.09 11H6.07zm0 2h3.02c.12 1.78.56 3.5 1.32 5.01A8.02 8.02 0 016.07 13zm8.84 5.01c.76-1.51 1.2-3.23 1.32-5.01h3.02a8.02 8.02 0 01-4.34 5.01z"/>
</svg>
`;
document.body.appendChild(btn);
const panel =
document.createElement('div');
panel.className =
'btc-panel';
panel.innerHTML = `
<div class="btc-title">
Bitcointalk Translator
</div>
<select id="btc-lang">
<option value="ar">العربية (Arabic)</option>
<option value="id">Indonesian</option>
<option value="es">Spanish</option>
<option value="zh-CN">Chinese</option>
<option value="hr">Croatian</option>
<option value="de">German</option>
<option value="el">Greek</option>
<option value="he">Hebrew</option>
<option value="fr">Français</option>
<option value="hi">India</option>
<option value="it">Italian</option>
<option value="ja">Japanese</option>
<option value="nl">Nederlands</option>
<option value="yo">Nigeria</option>
<option value="ko">Korean</option>
<option value="tl">Pilipinas</option>
<option value="pt">Portuguese</option>
<option value="ru">Russian</option>
<option value="ro">Romanian</option>
<option value="sv">Skandinavisk</option>
<option value="tr">Turkish</option>
<option value="ur">Pakistan</option>
<option value="bn">বাংলা</option>
</select>
<label class="btc-switch">
<input
type="checkbox"
id="btc-side"
${CONFIG.side ? 'checked':''}
>
Side by Side
</label>
<label class="btc-switch">
<input
type="checkbox"
id="btc-auto"
${CONFIG.auto ? 'checked':''}
>
Auto Translate
</label>
<button class="btc-thread-btn">
Translate Entire Thread
</button>
`;
document.body.appendChild(panel);
/* =====================================================
POSITION
===================================================== */
function updatePanelPosition(){
const rect =
btn.getBoundingClientRect();
panel.style.left =
(rect.left - 230) + 'px';
panel.style.top =
(rect.top - 10) + 'px';
}
updatePanelPosition();
/* =====================================================
TOGGLE PANEL
===================================================== */
btn.addEventListener('click', e => {
if(btn.dragging){
return;
}
CONFIG.panelOpen =
!CONFIG.panelOpen;
if(CONFIG.panelOpen){
updatePanelPosition();
panel.classList.add(
'active'
);
}else{
panel.classList.remove(
'active'
);
}
});
/* =====================================================
DRAG BUTTON
===================================================== */
let dragging = false;
let offsetX = 0;
let offsetY = 0;
btn.addEventListener('mousedown', e => {
dragging = true;
btn.dragging = false;
offsetX =
e.clientX
- btn.offsetLeft;
offsetY =
e.clientY
- btn.offsetTop;
panel.classList.remove(
'active'
);
document.body.style.userSelect =
'none';
});
document.addEventListener(
'mousemove',
e => {
if(!dragging){
return;
}
btn.dragging = true;
btn.style.left =
(e.clientX - offsetX)
+ 'px';
btn.style.top =
(e.clientY - offsetY)
+ 'px';
btn.style.right =
'auto';
btn.style.bottom =
'auto';
}
);
document.addEventListener(
'mouseup',
() => {
dragging = false;
document.body.style.userSelect =
'';
}
);
/* =====================================================
SETTINGS
===================================================== */
const lang =
panel.querySelector('#btc-lang');
lang.value =
CONFIG.lang;
lang.onchange = e => {
CONFIG.lang =
e.target.value;
GM_setValue(
'btc_lang',
e.target.value
);
};
panel.querySelector('#btc-side')
.onchange = e => {
CONFIG.side =
e.target.checked;
GM_setValue(
'btc_side',
e.target.checked
);
};
panel.querySelector('#btc-auto')
.onchange = e => {
CONFIG.auto =
e.target.checked;
GM_setValue(
'btc_auto',
e.target.checked
);
};
panel.querySelector('.btc-thread-btn')
.onclick = async () => {
const buttons =
document.querySelectorAll(
'.btc-translate-btn'
);
for(const b of buttons){
b.click();
await new Promise(r=>{
setTimeout(r,300);
});
}
};
}
/* =========================================================
SHORTCUT
========================================================= */
document.addEventListener(
'keydown',
e => {
if(
e.altKey
&&
e.key === 't'
){
const btn =
document.querySelector(
'.btc-translate-btn'
);
if(btn){
btn.click();
}
}
}
);
/* =========================================================
INIT
========================================================= */
function init(){
observe();
createFloatingUI();
console.log(
'Bitcointalk Translator V4 Loaded'
);
}
init();
})();