WebOrbiton
v3.0.0.0

FlatlyPage

528 lines · 22.9 KB
  1. (function () {
  2. 'use strict';
  3. ​
  4. const cfg = window.FLATLY_AI;
  5. const isSettings = !!cfg && cfg.mode === 'settings';
  6. if (!cfg || (!isSettings && (typeof blocks === 'undefined' || !document.getElementById('blocksContainer')))) return;
  7. ​
  8. const ICON_SPARK = '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="16" height="16"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z"/><path d="M19 15l.7 1.8L21.5 17.5l-1.8.7L19 20l-.7-1.8-1.8-.7 1.8-.7L19 15z"/></svg>';
  9. const ICON_UNDO = '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="16" height="16"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><polyline points="21 3 21 8 16 8"/></svg>';
  10. const ICON_CLOSE = '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" viewBox="0 0 24 24" width="18" height="18"><line x1="6" y1="6" x2="18" y2="18"/><line x1="6" y1="18" x2="18" y2="6"/></svg>';
  11. const ICON_SEND = '<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" width="18" height="18"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>';
  12. ​
  13. const lang = cfg.language || null;
  14. const QUICK_ACTIONS = [
  15. { label: 'Write', fill: 'Write content about: ' },
  16. { label: 'Improve', prompt: 'Improve the writing (clarity, flow and tone). Keep the meaning and the language.' },
  17. { label: 'Fix spelling', prompt: 'Fix spelling mistakes only. Do not change anything else.' },
  18. { label: 'Check words', prompt: 'Check the word choice: replace weak, repetitive or unsuitable words with better ones. Keep the meaning.' },
  19. { label: 'Check grammar', prompt: 'Fix grammar and punctuation errors only.' },
  20. { label: 'Fix formatting', prompt: 'Fix formatting: consistent capitalization, punctuation, spacing and title style. Do not change the meaning.' },
  21. lang
  22. ? { label: 'Translate', prompt: 'Translate the content ' + (isSettings ? 'of this settings tab' : 'in the scope') + ' from the main-language original (' + lang.main_name + ') into ' + lang.name + '. Keep the meaning, tone and names.' + (isSettings ? '' : ' Translate the page title and description too when the scope is the whole page.') }
  23. : { label: 'Translate', fill: 'Translate to: ' },
  24. ];
  25. ​
  26. const undoStack = [];
  27. const history = [];
  28. let busy = false;
  29. let scopeBlock = null;
  30. ​
  31. function el(tag, className, text) {
  32. const node = document.createElement(tag);
  33. if (className) node.className = className;
  34. if (text !== undefined) node.textContent = text;
  35. return node;
  36. }
  37. ​
  38. function inputByName(names) {
  39. return document.querySelector(names.map(n => 'input[name="' + n + '"]').join(','));
  40. }
  41. ​
  42. const titleInput = () => inputByName(['page_title', 'product_title']);
  43. const descriptionInput = () => inputByName(['page_description', 'product_description']);
  44. ​
  45. function snapshot() {
  46. return {
  47. blocks: JSON.parse(JSON.stringify(blocks)),
  48. title: titleInput() ? titleInput().value : null,
  49. description: descriptionInput() ? descriptionInput().value : null,
  50. };
  51. }
  52. ​
  53. function setInput(input, value) {
  54. if (!input || typeof value !== 'string') return;
  55. input.value = value;
  56. input.dispatchEvent(new Event('input', { bubbles: true }));
  57. input.dispatchEvent(new Event('change', { bubbles: true }));
  58. }
  59. ​
  60. function refresh() {
  61. renderBlocks();
  62. schedulePreviewUpdate();
  63. refreshScope();
  64. }
  65. ​
  66. function mergeInto(target, patch) {
  67. Object.keys(patch).forEach(key => {
  68. const isItem = item => item && typeof item === 'object' && (!Array.isArray(item) || item.length === 0);
  69. const value = Array.isArray(patch[key]) && patch[key].some(item => isItem(item) && !Array.isArray(item))
  70. ? patch[key].map(item => (Array.isArray(item) && item.length === 0 ? {} : item))
  71. : patch[key];
  72. const isObjectList = Array.isArray(value) && value.length > 0 && value.every(isItem);
  73. if (isObjectList) {
  74. const current = Array.isArray(target[key]) ? target[key] : [];
  75. value.forEach((item, index) => {
  76. if (index < current.length && current[index] && typeof current[index] === 'object') {
  77. mergeInto(current[index], item);
  78. } else {
  79. current.push(item);
  80. }
  81. });
  82. target[key] = current;
  83. } else {
  84. target[key] = value;
  85. }
  86. });
  87. }
  88. ​
  89. function clampIndex(position, max) {
  90. if (!Number.isInteger(position) || position < 0) return max;
  91. return Math.min(position, max);
  92. }
  93. ​
  94. const root = el('div', 'ai-root');
  95. const fab = el('button', 'ai-fab');
  96. fab.type = 'button';
  97. fab.innerHTML = ICON_SPARK;
  98. fab.appendChild(el('span', null, 'AI'));
  99. ​
  100. const panel = el('div', 'ai-panel');
  101. panel.hidden = true;
  102. panel.setAttribute('role', 'dialog');
  103. panel.setAttribute('aria-label', 'AI helper');
  104. ​
  105. const header = el('div', 'ai-header');
  106. const headerText = el('div', 'ai-header-text');
  107. headerText.appendChild(el('div', 'ai-title', 'AI helper'));
  108. headerText.appendChild(el('div', 'ai-subtitle', cfg.provider + ' · ' + cfg.model + (lang ? ' · ' + lang.name + ' (' + lang.code + ')' : '')));
  109. const undoButton = el('button', 'ai-btn ai-undo');
  110. undoButton.type = 'button';
  111. undoButton.innerHTML = ICON_UNDO;
  112. undoButton.appendChild(el('span', null, 'Undo'));
  113. undoButton.disabled = true;
  114. const closeButton = el('button', 'ai-icon-btn');
  115. closeButton.type = 'button';
  116. closeButton.setAttribute('aria-label', 'Close');
  117. closeButton.innerHTML = ICON_CLOSE;
  118. header.append(headerText, undoButton, closeButton);
  119. ​
  120. const messages = el('div', 'ai-messages');
  121. messages.appendChild(el('div', 'ai-note', 'Your page text is sent to ' + cfg.provider + '. Changes are only proposals: nothing is applied until you press Apply, and you can undo it afterwards.'));
  122. ​
  123. const chips = el('div', 'ai-chips');
  124. const scopeRow = el('div', 'ai-scope');
  125. scopeRow.appendChild(el('label', null, isSettings ? 'Editing' : 'Scope'));
  126. const scopeSelect = el('select', 'ai-select');
  127. const scopeTab = el('div', 'ai-scope-tab');
  128. scopeRow.appendChild(isSettings ? scopeTab : scopeSelect);
  129. ​
  130. const composer = el('div', 'ai-composer');
  131. const textarea = el('textarea', 'ai-input');
  132. textarea.rows = 2;
  133. textarea.maxLength = 4000;
  134. textarea.placeholder = 'Ask the AI…';
  135. const sendButton = el('button', 'ai-send');
  136. sendButton.type = 'button';
  137. sendButton.setAttribute('aria-label', 'Send');
  138. sendButton.innerHTML = ICON_SEND;
  139. composer.append(textarea, sendButton);
  140. ​
  141. panel.append(header, messages, chips, scopeRow, composer);
  142. root.append(fab, panel);
  143. document.body.appendChild(root);
  144. ​
  145. const SETTINGS_TABS = ['general', 'navigation', 'footer'];
  146. const TAB_LABELS = { general: 'General', navigation: 'Navigation', footer: 'Footer' };
  147. ​
  148. function activeTab() {
  149. const tab = document.querySelector('.settings-tab.active');
  150. return tab ? tab.dataset.tab : '';
  151. }
  152. ​
  153. function fieldValue(name) {
  154. const field = document.querySelector('[name="' + name + '"]');
  155. return field ? field.value : '';
  156. }
  157. ​
  158. const labelsOf = items => (Array.isArray(items) ? items : []).map(item => ({ label: (item && item.label) || '' }));
  159. ​
  160. function readSettings(tab) {
  161. if (tab === 'general') {
  162. return { site_name: fieldValue('site_name'), site_description: fieldValue('site_description'), logo_text: fieldValue('logo_text') };
  163. }
  164. if (tab === 'navigation') {
  165. return { links: labelsOf(navLinks), buttons: labelsOf(navButtons) };
  166. }
  167. updateFooterData();
  168. return {
  169. brand_description: footerData.brand_description || '',
  170. copyright: footerData.copyright || '',
  171. columns: (footerData.columns || []).map(col => ({ title: (col && col.title) || '', links: labelsOf(col && col.links) })),
  172. bottom_links: labelsOf(footerData.bottom_links),
  173. };
  174. }
  175. ​
  176. function settingsSnapshot() {
  177. updateFooterData();
  178. return {
  179. fields: { site_name: fieldValue('site_name'), site_description: fieldValue('site_description'), logo_text: fieldValue('logo_text') },
  180. navLinks: JSON.parse(JSON.stringify(navLinks)),
  181. navButtons: JSON.parse(JSON.stringify(navButtons)),
  182. footer: JSON.parse(JSON.stringify(footerData)),
  183. };
  184. }
  185. ​
  186. function renderSettingsLists() {
  187. renderNavLinks();
  188. renderNavButtons();
  189. renderFooterColumns();
  190. renderBottomLinks();
  191. }
  192. ​
  193. function restoreSettings(snap) {
  194. Object.keys(snap.fields).forEach(name => setInput(document.querySelector('[name="' + name + '"]'), snap.fields[name]));
  195. navLinks.splice(0, navLinks.length, ...snap.navLinks);
  196. navButtons.splice(0, navButtons.length, ...snap.navButtons);
  197. Object.keys(footerData).forEach(key => delete footerData[key]);
  198. Object.assign(footerData, snap.footer);
  199. setInput(document.getElementById('footerBrandDesc'), snap.footer.brand_description || '');
  200. setInput(document.getElementById('footerCopyright'), snap.footer.copyright || '');
  201. renderSettingsLists();
  202. }
  203. ​
  204. function mergeList(list, items, defaults) {
  205. items.forEach((item, index) => {
  206. if (index < list.length && list[index] && typeof list[index] === 'object') {
  207. Object.assign(list[index], item);
  208. } else {
  209. list.push(Object.assign({}, defaults, item));
  210. }
  211. });
  212. }
  213. ​
  214. function patchSettings(tab, data) {
  215. if (tab === 'general') {
  216. ['site_name', 'site_description', 'logo_text'].forEach(name => {
  217. if (typeof data[name] === 'string') setInput(document.querySelector('[name="' + name + '"]'), data[name]);
  218. });
  219. } else if (tab === 'navigation') {
  220. if (Array.isArray(data.links)) mergeList(navLinks, data.links, { url: '#' });
  221. if (Array.isArray(data.buttons)) mergeList(navButtons, data.buttons, { url: '#', style: 'ghost' });
  222. renderNavLinks();
  223. renderNavButtons();
  224. } else if (tab === 'footer') {
  225. if (typeof data.brand_description === 'string') setInput(document.getElementById('footerBrandDesc'), data.brand_description);
  226. if (typeof data.copyright === 'string') setInput(document.getElementById('footerCopyright'), data.copyright);
  227. updateFooterData();
  228. if (Array.isArray(data.columns)) {
  229. data.columns.forEach((col, index) => {
  230. if (index >= footerData.columns.length) footerData.columns.push({ title: '', links: [] });
  231. const current = footerData.columns[index];
  232. if (typeof col.title === 'string') current.title = col.title;
  233. if (Array.isArray(col.links)) {
  234. if (!Array.isArray(current.links)) current.links = [];
  235. mergeList(current.links, col.links, { url: '#' });
  236. }
  237. });
  238. }
  239. if (Array.isArray(data.bottom_links)) mergeList(footerData.bottom_links, data.bottom_links, { url: '#' });
  240. updateFooterData();
  241. renderFooterColumns();
  242. renderBottomLinks();
  243. }
  244. }
  245. ​
  246. function refreshScope() {
  247. if (isSettings) {
  248. const tab = activeTab();
  249. scopeTab.textContent = SETTINGS_TABS.includes(tab) ? TAB_LABELS[tab] + ' tab only' : 'Not available on this tab';
  250. return;
  251. }
  252. const selectedIndex = scopeBlock ? blocks.indexOf(scopeBlock) : -1;
  253. scopeSelect.innerHTML = '';
  254. const whole = el('option', null, 'Whole page');
  255. whole.value = 'all';
  256. scopeSelect.appendChild(whole);
  257. blocks.forEach((block, index) => {
  258. const option = el('option', null, (index + 1) + '. ' + block.type + ' – ' + String(getBlockTitle(block) || '').slice(0, 40));
  259. option.value = String(index);
  260. scopeSelect.appendChild(option);
  261. });
  262. scopeSelect.value = selectedIndex >= 0 ? String(selectedIndex) : 'all';
  263. if (selectedIndex < 0) scopeBlock = null;
  264. }
  265. ​
  266. function scrollDown() {
  267. messages.scrollTop = messages.scrollHeight;
  268. }
  269. ​
  270. function addMessage(role, text) {
  271. const bubble = el('div', 'ai-msg ai-msg-' + role, text);
  272. messages.appendChild(bubble);
  273. scrollDown();
  274. return bubble;
  275. }
  276. ​
  277. function describeOp(op, proposal) {
  278. if (op.summary) return op.summary;
  279. if (op.op === 'update_settings') return 'Edit the ' + (TAB_LABELS[proposal.tab] || 'settings') + ' tab';
  280. const index = op.ref ? Number(op.ref.slice(1)) - 1 : -1;
  281. const block = proposal.sent[index];
  282. const name = block ? block.type : 'block';
  283. switch (op.op) {
  284. case 'update_block': return 'Edit ' + name + ' #' + (index + 1);
  285. case 'add_block': return 'Add a ' + op.block_type + ' block';
  286. case 'move_block': return 'Move ' + name + ' #' + (index + 1);
  287. default: return 'Edit the page title or description';
  288. }
  289. }
  290. ​
  291. function applySettings(proposal) {
  292. const snap = settingsSnapshot();
  293. let done = 0;
  294. proposal.operations.forEach(op => {
  295. try {
  296. patchSettings(proposal.tab, op.data);
  297. done++;
  298. } catch (error) {
  299. renderSettingsLists();
  300. }
  301. });
  302. if (done === 0) return { done, skipped: proposal.operations.length };
  303. undoStack.push(snap);
  304. undoButton.disabled = false;
  305. return { done, skipped: proposal.operations.length - done };
  306. }
  307. ​
  308. function applyProposal(proposal) {
  309. if (isSettings) return applySettings(proposal);
  310. const snap = snapshot();
  311. let done = 0;
  312. let skipped = 0;
  313. ​
  314. proposal.operations.forEach(op => {
  315. try {
  316. if (op.op === 'update_block' || op.op === 'move_block') {
  317. const target = proposal.sent[Number(op.ref.slice(1)) - 1];
  318. if (!target || !blocks.includes(target)) { skipped++; return; }
  319. if (op.op === 'update_block') {
  320. if (!target.data || typeof target.data !== 'object' || Array.isArray(target.data)) target.data = {};
  321. mergeInto(target.data, op.data);
  322. } else {
  323. blocks.splice(blocks.indexOf(target), 1);
  324. blocks.splice(clampIndex(op.position, blocks.length), 0, target);
  325. }
  326. done++;
  327. } else if (op.op === 'add_block') {
  328. const block = { id: getDefaultBlockId(op.block_type), type: op.block_type, data: op.data };
  329. blocks.splice(clampIndex(op.position, blocks.length), 0, block);
  330. done++;
  331. } else if (op.op === 'update_page') {
  332. if (typeof op.data.title === 'string') setInput(titleInput(), op.data.title);
  333. if (typeof op.data.description === 'string') setInput(descriptionInput(), op.data.description);
  334. done++;
  335. }
  336. } catch (error) {
  337. skipped++;
  338. }
  339. });
  340. ​
  341. if (done === 0) return { done, skipped };
  342. undoStack.push(snap);
  343. undoButton.disabled = false;
  344. refresh();
  345. return { done, skipped };
  346. }
  347. ​
  348. function undo() {
  349. const snap = undoStack.pop();
  350. if (!snap) return;
  351. if (isSettings) {
  352. restoreSettings(snap);
  353. } else {
  354. blocks.splice(0, blocks.length, ...snap.blocks);
  355. setInput(titleInput(), snap.title);
  356. setInput(descriptionInput(), snap.description);
  357. refresh();
  358. }
  359. undoButton.disabled = undoStack.length === 0;
  360. addMessage('note', 'Last applied change was undone.');
  361. }
  362. ​
  363. function renderProposal(proposal) {
  364. const card = el('div', 'ai-proposal');
  365. const list = el('ul', 'ai-ops');
  366. proposal.operations.forEach(op => list.appendChild(el('li', null, describeOp(op, proposal))));
  367. card.appendChild(list);
  368. ​
  369. (proposal.warnings || []).forEach(warning => card.appendChild(el('div', 'ai-warning', warning)));
  370. ​
  371. const actions = el('div', 'ai-actions');
  372. const applyButton = el('button', 'ai-btn ai-btn-primary', 'Apply');
  373. applyButton.type = 'button';
  374. const dismissButton = el('button', 'ai-btn', 'Dismiss');
  375. dismissButton.type = 'button';
  376. actions.append(applyButton, dismissButton);
  377. card.appendChild(actions);
  378. ​
  379. applyButton.addEventListener('click', () => {
  380. const result = applyProposal(proposal);
  381. actions.remove();
  382. card.appendChild(el('div', result.done ? 'ai-applied' : 'ai-warning',
  383. result.done
  384. ? 'Applied' + (result.skipped ? ' (' + result.skipped + ' skipped because the page changed).' : '.')
  385. : 'Nothing was applied because the page has changed since the proposal.'));
  386. history.push({ role: 'assistant', text: 'Applied: ' + proposal.operations.map(op => describeOp(op, proposal)).join('; ') });
  387. });
  388. dismissButton.addEventListener('click', () => {
  389. actions.remove();
  390. card.appendChild(el('div', 'ai-muted', 'Dismissed.'));
  391. });
  392. ​
  393. messages.appendChild(card);
  394. scrollDown();
  395. }
  396. ​
  397. async function send(prompt) {
  398. prompt = prompt.trim();
  399. if (!prompt || busy) return;
  400. ​
  401. refreshScope();
  402. const languagePayload = lang ? { code: lang.code, name: lang.name, main_code: lang.main_code, main_name: lang.main_name } : null;
  403. let payload;
  404. let context;
  405. if (isSettings) {
  406. const tab = activeTab();
  407. if (!SETTINGS_TABS.includes(tab)) {
  408. addMessage('note', 'The AI helper works on the General, Navigation and Footer tabs. Open one of them first.');
  409. return;
  410. }
  411. payload = {
  412. action: 'chat', mode: 'settings', tab: tab, prompt: prompt,
  413. settings: readSettings(tab),
  414. language: languagePayload,
  415. source: lang && cfg.sourceSettings ? cfg.sourceSettings[tab] || null : null,
  416. history: history.slice(-6),
  417. };
  418. context = { tab: tab };
  419. } else {
  420. const scopeValue = scopeSelect.value;
  421. const sent = blocks.slice();
  422. payload = {
  423. action: 'chat',
  424. prompt: prompt,
  425. scope: scopeValue === 'all' ? 'all' : 'b' + (Number(scopeValue) + 1),
  426. page: {
  427. title: titleInput() ? titleInput().value : '',
  428. description: descriptionInput() ? descriptionInput().value : '',
  429. },
  430. blocks: sent.map(block => ({ id: block.id || '', type: block.type, data: block.type === 'html' ? {} : block.data })),
  431. language: languagePayload,
  432. source: lang ? cfg.source : null,
  433. history: history.slice(-6),
  434. };
  435. context = { sent: sent };
  436. }
  437. ​
  438. busy = true;
  439. sendButton.disabled = true;
  440. addMessage('user', prompt);
  441. const pending = addMessage('assistant', 'Thinking…');
  442. pending.classList.add('ai-pending');
  443. textarea.value = '';
  444. ​
  445. try {
  446. const response = await fetch(flatlyUrl(cfg.endpoint), {
  447. method: 'POST',
  448. credentials: 'same-origin',
  449. headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': cfg.csrf },
  450. body: JSON.stringify(payload),
  451. });
  452. ​
  453. let data = null;
  454. try { data = await response.json(); } catch (error) { data = null; }
  455. ​
  456. pending.classList.remove('ai-pending');
  457. if (!data || !data.ok) {
  458. pending.classList.add('ai-msg-error');
  459. pending.textContent = (data && data.error) || 'The request failed (HTTP ' + response.status + ').';
  460. return;
  461. }
  462. ​
  463. history.push({ role: 'user', text: prompt });
  464. pending.textContent = data.message || (data.operations.length ? 'Here is what I propose.' : 'No changes needed.');
  465. history.push({ role: 'assistant', text: pending.textContent });
  466. if (data.operations.length) {
  467. renderProposal(Object.assign({ operations: data.operations, warnings: data.warnings }, context));
  468. } else if (data.warnings && data.warnings.length) {
  469. data.warnings.forEach(warning => messages.appendChild(el('div', 'ai-warning', warning)));
  470. }
  471. } catch (error) {
  472. pending.classList.remove('ai-pending');
  473. pending.classList.add('ai-msg-error');
  474. pending.textContent = 'Could not reach the server.';
  475. } finally {
  476. busy = false;
  477. sendButton.disabled = false;
  478. scrollDown();
  479. }
  480. }
  481. ​
  482. QUICK_ACTIONS.forEach(action => {
  483. const chip = el('button', 'ai-chip', action.label);
  484. chip.type = 'button';
  485. chip.addEventListener('click', () => {
  486. if (action.prompt) {
  487. send(action.prompt);
  488. } else {
  489. textarea.value = action.fill;
  490. textarea.focus();
  491. textarea.setSelectionRange(textarea.value.length, textarea.value.length);
  492. }
  493. });
  494. chips.appendChild(chip);
  495. });
  496. ​
  497. function open() {
  498. refreshScope();
  499. panel.hidden = false;
  500. fab.classList.add('ai-fab-hidden');
  501. textarea.focus();
  502. }
  503. ​
  504. function close() {
  505. panel.hidden = true;
  506. fab.classList.remove('ai-fab-hidden');
  507. fab.focus();
  508. }
  509. ​
  510. fab.addEventListener('click', open);
  511. closeButton.addEventListener('click', close);
  512. undoButton.addEventListener('click', undo);
  513. document.querySelectorAll('.settings-tab').forEach(tab => tab.addEventListener('click', () => setTimeout(refreshScope, 0)));
  514. scopeSelect.addEventListener('change', () => {
  515. scopeBlock = scopeSelect.value === 'all' ? null : blocks[Number(scopeSelect.value)] || null;
  516. });
  517. sendButton.addEventListener('click', () => send(textarea.value));
  518. textarea.addEventListener('keydown', event => {
  519. if (event.key === 'Enter' && !event.shiftKey) {
  520. event.preventDefault();
  521. send(textarea.value);
  522. }
  523. });
  524. panel.addEventListener('keydown', event => {
  525. if (event.key === 'Escape') close();
  526. });
  527. })();
  528. ​