WebOrbiton
v3.0.0.2

FlatlyPage

663 lines · 19.9 KB
  1. (function() {
  2. 'use strict';
  3. ​
  4. let lastSourceLang = localStorage.getItem('translator_source') || 'pl';
  5. let lastTargetLang = localStorage.getItem('translator_target') || 'en';
  6. let selectedAPI = localStorage.getItem('translator_api') || 'mymemory';
  7. let apiConfig = null;
  8. ​
  9. const LANGUAGES = {
  10. 'en': 'English', 'pl': 'Polish', 'de': 'German', 'es': 'Spanish',
  11. 'fr': 'French', 'it': 'Italian', 'pt': 'Portuguese', 'ru': 'Russian',
  12. 'ja': 'Japanese', 'zh': 'Chinese', 'ar': 'Arabic', 'nl': 'Dutch',
  13. 'sv': 'Swedish', 'no': 'Norwegian', 'da': 'Danish', 'fi': 'Finnish',
  14. 'cs': 'Czech', 'sk': 'Slovak', 'hu': 'Hungarian', 'ro': 'Romanian',
  15. 'tr': 'Turkish', 'uk': 'Ukrainian', 'ko': 'Korean'
  16. };
  17. ​
  18. const ICONS = {
  19. translate: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>',
  20. globe: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>',
  21. search: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>',
  22. settings: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6m5.2-14.8L13.4 7.6m-2.8 8.8-3.8 3.8M23 12h-6m-6 0H1m17.8 5.2-3.8-3.8m-8.8-2.8L1.2 6.8"/></svg>'
  23. };
  24. ​
  25. const style = document.createElement('style');
  26. style.textContent = `
  27. .translate-btn {
  28. position: fixed;
  29. top: 0;
  30. left: 0;
  31. z-index: 150;
  32. display: none;
  33. align-items: center;
  34. gap: 6px;
  35. height: 28px;
  36. padding: 0 12px;
  37. background: var(--bg-elevated);
  38. border: 1px solid var(--border-focus);
  39. color: var(--text);
  40. border-radius: 8px;
  41. font-family: inherit;
  42. font-size: 0.6875rem;
  43. font-weight: 600;
  44. cursor: pointer;
  45. box-shadow: var(--shadow-md);
  46. transition: border-color var(--transition-base), background var(--transition-base);
  47. }
  48. ​
  49. .translate-btn.visible {
  50. display: inline-flex;
  51. }
  52. ​
  53. .translate-btn svg {
  54. flex-shrink: 0;
  55. }
  56. ​
  57. .translate-btn:hover {
  58. border-color: var(--accent);
  59. background: var(--bg-hover);
  60. }
  61. ​
  62. .translate-btn:disabled {
  63. cursor: progress;
  64. opacity: 0.8;
  65. }
  66. ​
  67. .tr-overlay {
  68. position: fixed;
  69. inset: 0;
  70. background: rgba(0, 0, 0, 0.75);
  71. backdrop-filter: blur(12px);
  72. display: flex;
  73. align-items: center;
  74. justify-content: center;
  75. z-index: 1000;
  76. padding: 20px;
  77. animation: fadeIn var(--transition-base) ease-out;
  78. }
  79. ​
  80. [data-theme="light"] .tr-overlay {
  81. background: rgba(15, 23, 42, 0.5);
  82. }
  83. ​
  84. .tr-modal {
  85. background: var(--bg-elevated);
  86. border: 1px solid var(--border);
  87. border-radius: var(--radius);
  88. width: 100%;
  89. max-width: 580px;
  90. padding: 24px;
  91. box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
  92. font-family: var(--font-interface, 'Space Grotesk'), -apple-system, BlinkMacSystemFont, sans-serif;
  93. color: var(--text);
  94. animation: modalSlideIn var(--transition-smooth) cubic-bezier(0.16, 1, 0.3, 1);
  95. }
  96. ​
  97. [data-theme="light"] .tr-modal {
  98. box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
  99. }
  100. ​
  101. .tr-title {
  102. font-size: 1rem;
  103. font-weight: 600;
  104. margin: 0 0 20px 0;
  105. display: flex;
  106. align-items: center;
  107. justify-content: space-between;
  108. }
  109. ​
  110. .tr-title-left {
  111. display: flex;
  112. align-items: center;
  113. gap: 10px;
  114. }
  115. ​
  116. .tr-title svg {
  117. color: var(--blue);
  118. }
  119. ​
  120. .tr-search {
  121. width: 100%;
  122. background: var(--bg-card);
  123. border: 1px solid var(--border);
  124. color: var(--text);
  125. padding: 12px 12px 12px 40px;
  126. border-radius: var(--radius-sm);
  127. margin-bottom: 20px;
  128. outline: none;
  129. transition: border-color var(--transition-base);
  130. font-size: 0.875rem;
  131. }
  132. ​
  133. .tr-search-wrapper {
  134. position: relative;
  135. }
  136. ​
  137. .tr-search-icon {
  138. position: absolute;
  139. left: 12px;
  140. top: 22px;
  141. transform: translateY(-50%);
  142. color: var(--text-muted);
  143. pointer-events: none;
  144. z-index: 1;
  145. }
  146. ​
  147. .tr-search:focus {
  148. border-color: var(--border-focus);
  149. }
  150. ​
  151. .tr-label {
  152. font-size: 0.6875rem;
  153. font-weight: 600;
  154. color: var(--text-subtle);
  155. text-transform: uppercase;
  156. letter-spacing: 0.05em;
  157. margin-bottom: 10px;
  158. display: block;
  159. }
  160. ​
  161. .tr-api-selector {
  162. display: flex;
  163. gap: 8px;
  164. margin-bottom: 20px;
  165. padding: 4px;
  166. background: var(--bg-card);
  167. border-radius: var(--radius-sm);
  168. }
  169. ​
  170. .tr-api-option {
  171. flex: 1;
  172. padding: 10px;
  173. border-radius: var(--radius-sm);
  174. cursor: pointer;
  175. text-align: center;
  176. font-size: 0.8125rem;
  177. color: var(--text-muted);
  178. transition: all var(--transition-base);
  179. border: 1px solid transparent;
  180. display: flex;
  181. flex-direction: column;
  182. align-items: center;
  183. gap: 4px;
  184. }
  185. ​
  186. .tr-api-option:hover {
  187. color: var(--text);
  188. background: var(--bg-hover);
  189. }
  190. ​
  191. .tr-api-option.selected {
  192. background: rgba(59, 130, 246, 0.1);
  193. border-color: var(--blue);
  194. color: var(--text);
  195. font-weight: 600;
  196. }
  197. ​
  198. .tr-api-badge {
  199. display: inline-block;
  200. font-size: 0.625rem;
  201. padding: 2px 6px;
  202. background: var(--bg-elevated);
  203. border-radius: 4px;
  204. margin-left: 0;
  205. }
  206. ​
  207. .tr-api-badge.free {
  208. background: var(--success);
  209. color: white;
  210. }
  211. ​
  212. .tr-grid {
  213. display: grid;
  214. grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
  215. gap: 8px;
  216. max-height: 140px;
  217. overflow-y: auto;
  218. padding-right: 5px;
  219. margin-bottom: 20px;
  220. }
  221. ​
  222. .tr-grid::-webkit-scrollbar {
  223. width: 4px;
  224. }
  225. ​
  226. .tr-grid::-webkit-scrollbar-thumb {
  227. background: var(--border);
  228. border-radius: 10px;
  229. }
  230. ​
  231. .tr-grid::-webkit-scrollbar-thumb:hover {
  232. background: var(--text-muted);
  233. }
  234. ​
  235. .tr-opt {
  236. background: var(--bg-card);
  237. border: 1px solid var(--border);
  238. padding: 10px;
  239. border-radius: var(--radius-sm);
  240. cursor: pointer;
  241. text-align: center;
  242. font-size: 0.8125rem;
  243. color: var(--text-muted);
  244. transition: all var(--transition-base);
  245. }
  246. ​
  247. .tr-opt:hover {
  248. background: var(--bg-hover);
  249. color: var(--text);
  250. border-color: var(--border-focus);
  251. }
  252. ​
  253. .tr-opt.selected {
  254. background: rgba(59, 130, 246, 0.1);
  255. border-color: var(--blue);
  256. color: var(--text);
  257. font-weight: 500;
  258. box-shadow: inset 0 0 10px rgba(59, 130, 246, 0.1);
  259. }
  260. ​
  261. .tr-footer {
  262. display: flex;
  263. justify-content: flex-end;
  264. gap: 12px;
  265. margin-top: 24px;
  266. }
  267. ​
  268. .tr-btn-main {
  269. padding: 12px 24px;
  270. border-radius: var(--radius-sm);
  271. border: none;
  272. font-weight: 600;
  273. cursor: pointer;
  274. transition: all var(--transition-base);
  275. font-size: 0.875rem;
  276. }
  277. ​
  278. .tr-btn-cancel {
  279. background: transparent;
  280. color: var(--text-muted);
  281. }
  282. ​
  283. .tr-btn-cancel:hover {
  284. color: var(--text);
  285. }
  286. ​
  287. .tr-btn-confirm {
  288. background: var(--blue);
  289. color: white;
  290. box-shadow: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
  291. }
  292. ​
  293. .tr-btn-confirm:hover {
  294. transform: translateY(-1px);
  295. box-shadow: 0 6px 20px rgba(59, 130, 246, 0.23);
  296. }
  297. ​
  298. .tr-btn-confirm:active {
  299. transform: scale(0.98);
  300. }
  301. ​
  302. .loader-spin {
  303. width: 14px;
  304. height: 14px;
  305. border: 2px solid rgba(255, 255, 255, 0.2);
  306. border-top-color: white;
  307. border-radius: 50%;
  308. animation: spin 0.8s linear infinite;
  309. }
  310. ​
  311. @keyframes spin {
  312. to {
  313. transform: rotate(360deg);
  314. }
  315. }
  316. ​
  317. @media (max-width: 768px) {
  318. .tr-modal {
  319. max-width: 100%;
  320. border-radius: var(--radius) var(--radius) 0 0;
  321. padding: 20px;
  322. }
  323. ​
  324. .tr-title {
  325. font-size: 1.125rem;
  326. }
  327. ​
  328. .tr-api-option {
  329. font-size: 0.75rem;
  330. padding: 8px;
  331. }
  332. ​
  333. .tr-grid {
  334. grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
  335. }
  336. ​
  337. .tr-footer {
  338. flex-direction: column;
  339. gap: 10px;
  340. }
  341. ​
  342. .tr-btn-main {
  343. width: 100%;
  344. }
  345. }
  346. `;
  347. document.head.appendChild(style);
  348. ​
  349. async function loadAPIConfig() {
  350. try {
  351. const response = await fetch('../api/get-translate-config');
  352. return await response.json();
  353. } catch (e) {
  354. console.error('Failed to load API config:', e);
  355. return {
  356. 'mymemory': {
  357. name: 'MyMemory',
  358. free: true,
  359. rateLimit: '1000/day'
  360. }
  361. };
  362. }
  363. }
  364. ​
  365. function showModal(callback) {
  366. const overlay = document.createElement('div');
  367. overlay.className = 'tr-overlay';
  368. const apiButtons = apiConfig ? Object.entries(apiConfig).map(([id, config]) => `
  369. <div class="tr-api-option ${id === selectedAPI ? 'selected' : ''}" data-api="${id}">
  370. ${config.name}
  371. ${config.free ? '<span class="tr-api-badge free">FREE</span>' : '<span class="tr-api-badge">PRO</span>'}
  372. </div>
  373. `).join('') : '';
  374. ​
  375. overlay.innerHTML = `
  376. <div class="tr-modal">
  377. <div class="tr-title">
  378. <div class="tr-title-left">
  379. ${ICONS.globe} Smart Translator
  380. </div>
  381. </div>
  382. ​
  383. ${apiConfig ? `
  384. <div class="tr-label">Translation Provider</div>
  385. <div class="tr-api-selector">
  386. ${apiButtons}
  387. </div>
  388. ` : ''}
  389. <div class="tr-search-wrapper">
  390. <span class="tr-search-icon">${ICONS.search}</span>
  391. <input type="text" class="tr-search" placeholder="Search languages...">
  392. </div>
  393. <span class="tr-label">Source Language</span>
  394. <div class="tr-grid" id="src-grid"></div>
  395. ​
  396. <span class="tr-label">Target Language</span>
  397. <div class="tr-grid" id="trg-grid"></div>
  398. ​
  399. <div class="tr-footer">
  400. <button class="tr-btn-main tr-btn-cancel">Close</button>
  401. <button class="tr-btn-main tr-btn-confirm">Translate Content</button>
  402. </div>
  403. </div>`;
  404. document.body.appendChild(overlay);
  405. ​
  406. let sSrc = lastSourceLang;
  407. let sTrg = lastTargetLang;
  408. let sAPI = selectedAPI;
  409. ​
  410. overlay.querySelectorAll('.tr-api-option').forEach(opt => {
  411. opt.onclick = () => {
  412. sAPI = opt.dataset.api;
  413. overlay.querySelectorAll('.tr-api-option').forEach(o => o.classList.remove('selected'));
  414. opt.classList.add('selected');
  415. };
  416. });
  417. ​
  418. const render = (filter = '') => {
  419. const srcG = overlay.querySelector('#src-grid');
  420. const trgG = overlay.querySelector('#trg-grid');
  421. srcG.innerHTML = ''; trgG.innerHTML = '';
  422. ​
  423. Object.entries(LANGUAGES).forEach(([code, name]) => {
  424. if (!name.toLowerCase().includes(filter.toLowerCase())) return;
  425. ​
  426. [ {g: srcG, cur: sSrc, set: (c)=>sSrc=c}, {g: trgG, cur: sTrg, set: (c)=>sTrg=c} ].forEach(config => {
  427. const el = document.createElement('div');
  428. el.className = `tr-opt ${code === config.cur ? 'selected' : ''}`;
  429. el.textContent = name;
  430. el.onclick = () => { config.set(code); render(filter); };
  431. config.g.appendChild(el);
  432. });
  433. });
  434. };
  435. ​
  436. overlay.querySelector('.tr-search').oninput = (e) => render(e.target.value);
  437. overlay.querySelector('.tr-btn-cancel').onclick = () => overlay.remove();
  438. overlay.querySelector('.tr-btn-confirm').onclick = () => {
  439. lastSourceLang = sSrc; lastTargetLang = sTrg; selectedAPI = sAPI;
  440. localStorage.setItem('translator_source', sSrc);
  441. localStorage.setItem('translator_target', sTrg);
  442. localStorage.setItem('translator_api', sAPI);
  443. overlay.remove();
  444. callback(sSrc, sTrg, sAPI);
  445. };
  446. ​
  447. render();
  448. }
  449. ​
  450. async function translateText(text, src, trg, api) {
  451. const response = await fetch('../api/translate', {
  452. method: 'POST',
  453. headers: {
  454. 'Content-Type': 'application/json'
  455. },
  456. body: JSON.stringify({
  457. text: text,
  458. source: src,
  459. target: trg,
  460. provider: api
  461. })
  462. });
  463. ​
  464. if (!response.ok) {
  465. const error = await response.json();
  466. console.error('❌ Backend error:', error);
  467. throw new Error(error.error || 'Translation failed');
  468. }
  469. ​
  470. const data = await response.json();
  471. return data.translatedText;
  472. }
  473. ​
  474. async function handleTranslate(field, btn) {
  475. const val = field.value.trim();
  476. if (!val) {
  477. alert('Please enter text to translate');
  478. return;
  479. }
  480. ​
  481. showModal(async (src, trg, api) => {
  482. const original = btn.innerHTML;
  483. btn.innerHTML = `<div class="loader-spin"></div>`;
  484. btn.classList.add('loading');
  485. btn.disabled = true;
  486. ​
  487. try {
  488. const translated = await translateText(val, src, trg, api);
  489. if (translated) {
  490. field.value = translated;
  491. field.dispatchEvent(new Event('input', { bubbles: true }));
  492. } else {
  493. alert('Translation failed or limit exceeded');
  494. }
  495. } catch (e) {
  496. console.error('❌ Translation error:', e);
  497. alert('Connection failed: ' + e.message);
  498. } finally {
  499. btn.innerHTML = original;
  500. btn.classList.remove('loading');
  501. btn.disabled = false;
  502. }
  503. });
  504. }
  505. ​
  506. const FIELD_SELECTOR = 'textarea, input[type="text"]';
  507. const BLOCK_SELECTOR = '.blocks-container .block-content';
  508. const GAP = 6;
  509. const EDGE = 8;
  510. ​
  511. let activeField = null;
  512. let floatBtn = null;
  513. let busy = false;
  514. ​
  515. const PAGE_TEXT_FIELDS = /^(page_title|page_description|product_title|product_description)$/;
  516. const NON_TEXT_KEY = /(url|link|href|src|image|icon|poster|slug|color|class|embed)$/i;
  517. const ID_KEY = /(^|_)id$|[a-z]Id$/;
  518. const URL_LIKE = /^\s*(https?:\/\/|\/|#|mailto:|tel:|www\.)/i;
  519. ​
  520. function fieldKey(el) {
  521. const m = (el.getAttribute('oninput') || '').match(/'([A-Za-z0-9_]+)'\s*,\s*this\.value/);
  522. return m ? m[1] : '';
  523. }
  524. ​
  525. function isTranslatable(el) {
  526. if (!(el instanceof HTMLElement) ||
  527. !el.matches(FIELD_SELECTOR) ||
  528. el.readOnly ||
  529. el.disabled ||
  530. el.closest('.tr-modal') ||
  531. el.dataset.noTranslate !== undefined) {
  532. return false;
  533. }
  534. ​
  535. if (PAGE_TEXT_FIELDS.test(el.name || '')) {
  536. return true;
  537. }
  538. ​
  539. if (el.closest(BLOCK_SELECTOR) === null) {
  540. return false;
  541. }
  542. ​
  543. if (el.closest('.link-input-wrapper') || el.closest('.image-input-group')) {
  544. return false;
  545. }
  546. const key = fieldKey(el);
  547. if (NON_TEXT_KEY.test(key) || ID_KEY.test(key)) {
  548. return false;
  549. }
  550. if (el.type === 'url' || URL_LIKE.test(el.value) || /^\s*https?:\/\//i.test(el.placeholder || '')) {
  551. return false;
  552. }
  553. return true;
  554. }
  555. ​
  556. function ensureButton() {
  557. if (floatBtn) return floatBtn;
  558. floatBtn = document.createElement('button');
  559. floatBtn.type = 'button';
  560. floatBtn.className = 'translate-btn';
  561. floatBtn.innerHTML = `${ICONS.translate} Translate`;
  562. floatBtn.addEventListener('mousedown', (e) => e.preventDefault());
  563. floatBtn.addEventListener('click', (e) => {
  564. e.preventDefault();
  565. e.stopPropagation();
  566. if (activeField && !busy) {
  567. const field = activeField;
  568. busy = true;
  569. handleTranslate(field, floatBtn).finally(() => {
  570. busy = false;
  571. position();
  572. });
  573. }
  574. });
  575. document.body.appendChild(floatBtn);
  576. return floatBtn;
  577. }
  578. ​
  579. function hide() {
  580. if (floatBtn) floatBtn.classList.remove('visible');
  581. }
  582. ​
  583. function position() {
  584. if (!activeField || !document.contains(activeField)) {
  585. activeField = null;
  586. hide();
  587. return;
  588. }
  589. ​
  590. const btn = ensureButton();
  591. const rect = activeField.getBoundingClientRect();
  592. const vw = window.innerWidth;
  593. const vh = window.innerHeight;
  594. ​
  595. if (rect.bottom < 0 || rect.top > vh || rect.width === 0) {
  596. hide();
  597. return;
  598. }
  599. ​
  600. btn.classList.add('visible');
  601. const w = btn.offsetWidth;
  602. const h = btn.offsetHeight;
  603. ​
  604. let left = rect.right - w;
  605. left = Math.max(EDGE, Math.min(left, vw - w - EDGE));
  606. ​
  607. let top = rect.top - h - GAP;
  608. const header = document.querySelector('.main-header');
  609. const minTop = header ? header.getBoundingClientRect().bottom + 2 : EDGE;
  610. if (top < minTop) {
  611. top = Math.min(minTop, vh - h - EDGE);
  612. }
  613. ​
  614. btn.style.left = left + 'px';
  615. btn.style.top = top + 'px';
  616. }
  617. ​
  618. document.addEventListener('focusin', (e) => {
  619. if (floatBtn && (e.target === floatBtn || floatBtn.contains(e.target))) return;
  620. if (isTranslatable(e.target)) {
  621. activeField = e.target;
  622. position();
  623. } else if (!(e.target.closest && e.target.closest('.tr-modal'))) {
  624. activeField = null;
  625. hide();
  626. }
  627. });
  628. ​
  629. document.addEventListener('focusout', (e) => {
  630. if (e.target !== activeField || busy) return;
  631. setTimeout(() => {
  632. if (busy) return;
  633. const now = document.activeElement;
  634. if (now !== activeField && !(floatBtn && floatBtn.contains(now))) {
  635. activeField = null;
  636. hide();
  637. }
  638. }, 150);
  639. });
  640. ​
  641. let positionFrame = 0;
  642. const schedulePosition = () => {
  643. if (!activeField || positionFrame) return;
  644. positionFrame = requestAnimationFrame(() => {
  645. positionFrame = 0;
  646. position();
  647. });
  648. };
  649. ['scroll', 'resize'].forEach((evt) => window.addEventListener(evt, schedulePosition, { capture: true, passive: true }));
  650. document.addEventListener('input', (e) => {
  651. if (e.target === activeField) position();
  652. });
  653. ​
  654. (async function init() {
  655. apiConfig = await loadAPIConfig();
  656. })();
  657. })();
  658. ​