v3.0.0.2
FlatlyPage
- (function () {
- 'use strict';
-
- const cfg = window.FLATLY_AI;
- const isSettings = !!cfg && cfg.mode === 'settings';
- if (!cfg || (!isSettings && (typeof blocks === 'undefined' || !document.getElementById('blocksContainer')))) return;
-
- 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>';
- 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>';
- 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>';
- 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>';
-
- const lang = cfg.language || null;
- const QUICK_ACTIONS = [
- { label: 'Write', fill: 'Write content about: ' },
- { label: 'Improve', prompt: 'Improve the writing (clarity, flow and tone). Keep the meaning and the language.' },
- { label: 'Fix spelling', prompt: 'Fix spelling mistakes only. Do not change anything else.' },
- { label: 'Check words', prompt: 'Check the word choice: replace weak, repetitive or unsuitable words with better ones. Keep the meaning.' },
- { label: 'Check grammar', prompt: 'Fix grammar and punctuation errors only.' },
- { label: 'Fix formatting', prompt: 'Fix formatting: consistent capitalization, punctuation, spacing and title style. Do not change the meaning.' },
- lang
- ? { 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.') }
- : { label: 'Translate', fill: 'Translate to: ' },
- ];
-
- const undoStack = [];
- const history = [];
- let busy = false;
- let scopeBlock = null;
-
- function el(tag, className, text) {
- const node = document.createElement(tag);
- if (className) node.className = className;
- if (text !== undefined) node.textContent = text;
- return node;
- }
-
- function inputByName(names) {
- return document.querySelector(names.map(n => 'input[name="' + n + '"]').join(','));
- }
-
- const titleInput = () => inputByName(['page_title', 'product_title']);
- const descriptionInput = () => inputByName(['page_description', 'product_description']);
-
- function snapshot() {
- return {
- blocks: JSON.parse(JSON.stringify(blocks)),
- title: titleInput() ? titleInput().value : null,
- description: descriptionInput() ? descriptionInput().value : null,
- };
- }
-
- function setInput(input, value) {
- if (!input || typeof value !== 'string') return;
- input.value = value;
- input.dispatchEvent(new Event('input', { bubbles: true }));
- input.dispatchEvent(new Event('change', { bubbles: true }));
- }
-
- function refresh() {
- renderBlocks();
- schedulePreviewUpdate();
- refreshScope();
- }
-
- function mergeInto(target, patch) {
- Object.keys(patch).forEach(key => {
- const isItem = item => item && typeof item === 'object' && (!Array.isArray(item) || item.length === 0);
- const value = Array.isArray(patch[key]) && patch[key].some(item => isItem(item) && !Array.isArray(item))
- ? patch[key].map(item => (Array.isArray(item) && item.length === 0 ? {} : item))
- : patch[key];
- const isObjectList = Array.isArray(value) && value.length > 0 && value.every(isItem);
- if (isObjectList) {
- const current = Array.isArray(target[key]) ? target[key] : [];
- value.forEach((item, index) => {
- if (index < current.length && current[index] && typeof current[index] === 'object') {
- mergeInto(current[index], item);
- } else {
- current.push(item);
- }
- });
- target[key] = current;
- } else {
- target[key] = value;
- }
- });
- }
-
- function clampIndex(position, max) {
- if (!Number.isInteger(position) || position < 0) return max;
- return Math.min(position, max);
- }
-
- const root = el('div', 'ai-root');
- const fab = el('button', 'ai-fab');
- fab.type = 'button';
- fab.innerHTML = ICON_SPARK;
- fab.appendChild(el('span', null, 'AI'));
-
- const panel = el('div', 'ai-panel');
- panel.hidden = true;
- panel.setAttribute('role', 'dialog');
- panel.setAttribute('aria-label', 'AI helper');
-
- const header = el('div', 'ai-header');
- const headerText = el('div', 'ai-header-text');
- headerText.appendChild(el('div', 'ai-title', 'AI helper'));
- headerText.appendChild(el('div', 'ai-subtitle', cfg.provider + ' · ' + cfg.model + (lang ? ' · ' + lang.name + ' (' + lang.code + ')' : '')));
- const undoButton = el('button', 'ai-btn ai-undo');
- undoButton.type = 'button';
- undoButton.innerHTML = ICON_UNDO;
- undoButton.appendChild(el('span', null, 'Undo'));
- undoButton.disabled = true;
- const closeButton = el('button', 'ai-icon-btn');
- closeButton.type = 'button';
- closeButton.setAttribute('aria-label', 'Close');
- closeButton.innerHTML = ICON_CLOSE;
- header.append(headerText, undoButton, closeButton);
-
- const messages = el('div', 'ai-messages');
- messages.appendChild(el('div', 'ai-note', 'Your page text is sent to ' + cfg.provider + '. The AI only suggests changes, nothing happens until you click Apply, and you can undo it.'));
-
- const chips = el('div', 'ai-chips');
- const scopeRow = el('div', 'ai-scope');
- scopeRow.appendChild(el('label', null, isSettings ? 'Editing' : 'Scope'));
- const scopeSelect = el('select', 'ai-select');
- const scopeTab = el('div', 'ai-scope-tab');
- scopeRow.appendChild(isSettings ? scopeTab : scopeSelect);
-
- const composer = el('div', 'ai-composer');
- const textarea = el('textarea', 'ai-input');
- textarea.rows = 2;
- textarea.maxLength = 4000;
- textarea.placeholder = 'Ask the AI…';
- const sendButton = el('button', 'ai-send');
- sendButton.type = 'button';
- sendButton.setAttribute('aria-label', 'Send');
- sendButton.innerHTML = ICON_SEND;
- composer.append(textarea, sendButton);
-
- panel.append(header, messages, chips, scopeRow, composer);
- root.append(fab, panel);
- document.body.appendChild(root);
-
- const SETTINGS_TABS = ['general', 'navigation', 'footer'];
- const TAB_LABELS = { general: 'General', navigation: 'Navigation', footer: 'Footer' };
-
- function activeTab() {
- const tab = document.querySelector('.settings-tab.active');
- return tab ? tab.dataset.tab : '';
- }
-
- function fieldValue(name) {
- const field = document.querySelector('[name="' + name + '"]');
- return field ? field.value : '';
- }
-
- const labelsOf = items => (Array.isArray(items) ? items : []).map(item => ({ label: (item && item.label) || '' }));
-
- function readSettings(tab) {
- if (tab === 'general') {
- return { site_name: fieldValue('site_name'), site_description: fieldValue('site_description'), logo_text: fieldValue('logo_text') };
- }
- if (tab === 'navigation') {
- return { links: labelsOf(navLinks), buttons: labelsOf(navButtons) };
- }
- updateFooterData();
- return {
- brand_description: footerData.brand_description || '',
- copyright: footerData.copyright || '',
- columns: (footerData.columns || []).map(col => ({ title: (col && col.title) || '', links: labelsOf(col && col.links) })),
- bottom_links: labelsOf(footerData.bottom_links),
- };
- }
-
- function settingsSnapshot() {
- updateFooterData();
- return {
- fields: { site_name: fieldValue('site_name'), site_description: fieldValue('site_description'), logo_text: fieldValue('logo_text') },
- navLinks: JSON.parse(JSON.stringify(navLinks)),
- navButtons: JSON.parse(JSON.stringify(navButtons)),
- footer: JSON.parse(JSON.stringify(footerData)),
- };
- }
-
- function renderSettingsLists() {
- renderNavLinks();
- renderNavButtons();
- renderFooterColumns();
- renderBottomLinks();
- }
-
- function restoreSettings(snap) {
- Object.keys(snap.fields).forEach(name => setInput(document.querySelector('[name="' + name + '"]'), snap.fields[name]));
- navLinks.splice(0, navLinks.length, ...snap.navLinks);
- navButtons.splice(0, navButtons.length, ...snap.navButtons);
- Object.keys(footerData).forEach(key => delete footerData[key]);
- Object.assign(footerData, snap.footer);
- setInput(document.getElementById('footerBrandDesc'), snap.footer.brand_description || '');
- setInput(document.getElementById('footerCopyright'), snap.footer.copyright || '');
- renderSettingsLists();
- }
-
- function mergeList(list, items, defaults) {
- items.forEach((item, index) => {
- if (index < list.length && list[index] && typeof list[index] === 'object') {
- Object.assign(list[index], item);
- } else {
- list.push(Object.assign({}, defaults, item));
- }
- });
- }
-
- function patchSettings(tab, data) {
- if (tab === 'general') {
- ['site_name', 'site_description', 'logo_text'].forEach(name => {
- if (typeof data[name] === 'string') setInput(document.querySelector('[name="' + name + '"]'), data[name]);
- });
- } else if (tab === 'navigation') {
- if (Array.isArray(data.links)) mergeList(navLinks, data.links, { url: '#' });
- if (Array.isArray(data.buttons)) mergeList(navButtons, data.buttons, { url: '#', style: 'ghost' });
- renderNavLinks();
- renderNavButtons();
- } else if (tab === 'footer') {
- if (typeof data.brand_description === 'string') setInput(document.getElementById('footerBrandDesc'), data.brand_description);
- if (typeof data.copyright === 'string') setInput(document.getElementById('footerCopyright'), data.copyright);
- updateFooterData();
- if (Array.isArray(data.columns)) {
- data.columns.forEach((col, index) => {
- if (index >= footerData.columns.length) footerData.columns.push({ title: '', links: [] });
- const current = footerData.columns[index];
- if (typeof col.title === 'string') current.title = col.title;
- if (Array.isArray(col.links)) {
- if (!Array.isArray(current.links)) current.links = [];
- mergeList(current.links, col.links, { url: '#' });
- }
- });
- }
- if (Array.isArray(data.bottom_links)) mergeList(footerData.bottom_links, data.bottom_links, { url: '#' });
- updateFooterData();
- renderFooterColumns();
- renderBottomLinks();
- }
- }
-
- function refreshScope() {
- if (isSettings) {
- const tab = activeTab();
- scopeTab.textContent = SETTINGS_TABS.includes(tab) ? TAB_LABELS[tab] + ' tab only' : "Doesn't work on this tab";
- return;
- }
- const selectedIndex = scopeBlock ? blocks.indexOf(scopeBlock) : -1;
- scopeSelect.innerHTML = '';
- const whole = el('option', null, 'Whole page');
- whole.value = 'all';
- scopeSelect.appendChild(whole);
- blocks.forEach((block, index) => {
- const option = el('option', null, (index + 1) + '. ' + block.type + ': ' + String(getBlockTitle(block) || '').slice(0, 40));
- option.value = String(index);
- scopeSelect.appendChild(option);
- });
- scopeSelect.value = selectedIndex >= 0 ? String(selectedIndex) : 'all';
- if (selectedIndex < 0) scopeBlock = null;
- }
-
- function scrollDown() {
- messages.scrollTop = messages.scrollHeight;
- }
-
- function addMessage(role, text) {
- const bubble = el('div', 'ai-msg ai-msg-' + role, text);
- messages.appendChild(bubble);
- scrollDown();
- return bubble;
- }
-
- function describeOp(op, proposal) {
- if (op.summary) return op.summary;
- if (op.op === 'update_settings') return 'Edit the ' + (TAB_LABELS[proposal.tab] || 'settings') + ' tab';
- const index = op.ref ? Number(op.ref.slice(1)) - 1 : -1;
- const block = proposal.sent[index];
- const name = block ? block.type : 'block';
- switch (op.op) {
- case 'update_block': return 'Edit ' + name + ' #' + (index + 1);
- case 'add_block': return 'Add a ' + op.block_type + ' block';
- case 'move_block': return 'Move ' + name + ' #' + (index + 1);
- default: return 'Edit the page title or description';
- }
- }
-
- function applySettings(proposal) {
- const snap = settingsSnapshot();
- let done = 0;
- proposal.operations.forEach(op => {
- try {
- patchSettings(proposal.tab, op.data);
- done++;
- } catch (error) {
- renderSettingsLists();
- }
- });
- if (done === 0) return { done, skipped: proposal.operations.length };
- undoStack.push(snap);
- undoButton.disabled = false;
- return { done, skipped: proposal.operations.length - done };
- }
-
- function applyProposal(proposal) {
- if (isSettings) return applySettings(proposal);
- const snap = snapshot();
- let done = 0;
- let skipped = 0;
-
- proposal.operations.forEach(op => {
- try {
- if (op.op === 'update_block' || op.op === 'move_block') {
- const target = proposal.sent[Number(op.ref.slice(1)) - 1];
- if (!target || !blocks.includes(target)) { skipped++; return; }
- if (op.op === 'update_block') {
- if (!target.data || typeof target.data !== 'object' || Array.isArray(target.data)) target.data = {};
- mergeInto(target.data, op.data);
- } else {
- blocks.splice(blocks.indexOf(target), 1);
- blocks.splice(clampIndex(op.position, blocks.length), 0, target);
- }
- done++;
- } else if (op.op === 'add_block') {
- const block = { id: getDefaultBlockId(op.block_type), type: op.block_type, data: op.data };
- blocks.splice(clampIndex(op.position, blocks.length), 0, block);
- done++;
- } else if (op.op === 'update_page') {
- if (typeof op.data.title === 'string') setInput(titleInput(), op.data.title);
- if (typeof op.data.description === 'string') setInput(descriptionInput(), op.data.description);
- done++;
- }
- } catch (error) {
- skipped++;
- }
- });
-
- if (done === 0) return { done, skipped };
- undoStack.push(snap);
- undoButton.disabled = false;
- refresh();
- return { done, skipped };
- }
-
- function undo() {
- const snap = undoStack.pop();
- if (!snap) return;
- if (isSettings) {
- restoreSettings(snap);
- } else {
- blocks.splice(0, blocks.length, ...snap.blocks);
- setInput(titleInput(), snap.title);
- setInput(descriptionInput(), snap.description);
- refresh();
- }
- undoButton.disabled = undoStack.length === 0;
- addMessage('note', 'Undid the last change.');
- }
-
- function renderProposal(proposal) {
- const card = el('div', 'ai-proposal');
- const list = el('ul', 'ai-ops');
- proposal.operations.forEach(op => list.appendChild(el('li', null, describeOp(op, proposal))));
- card.appendChild(list);
-
- (proposal.warnings || []).forEach(warning => card.appendChild(el('div', 'ai-warning', warning)));
-
- const actions = el('div', 'ai-actions');
- const applyButton = el('button', 'ai-btn ai-btn-primary', 'Apply');
- applyButton.type = 'button';
- const dismissButton = el('button', 'ai-btn', 'Dismiss');
- dismissButton.type = 'button';
- actions.append(applyButton, dismissButton);
- card.appendChild(actions);
-
- applyButton.addEventListener('click', () => {
- const result = applyProposal(proposal);
- actions.remove();
- card.appendChild(el('div', result.done ? 'ai-applied' : 'ai-warning',
- result.done
- ? 'Applied' + (result.skipped ? ', but ' + result.skipped + ' skipped because the page changed in the meantime.' : '.')
- : 'Nothing was applied, the page changed after this suggestion was made.'));
- history.push({ role: 'assistant', text: 'Applied: ' + proposal.operations.map(op => describeOp(op, proposal)).join('; ') });
- });
- dismissButton.addEventListener('click', () => {
- actions.remove();
- card.appendChild(el('div', 'ai-muted', 'Dismissed.'));
- });
-
- messages.appendChild(card);
- scrollDown();
- }
-
- async function send(prompt) {
- prompt = prompt.trim();
- if (!prompt || busy) return;
-
- refreshScope();
- const languagePayload = lang ? { code: lang.code, name: lang.name, main_code: lang.main_code, main_name: lang.main_name } : null;
- let payload;
- let context;
- if (isSettings) {
- const tab = activeTab();
- if (!SETTINGS_TABS.includes(tab)) {
- addMessage('note', 'The AI helper works on the General, Navigation and Footer tabs. Open one of them first.');
- return;
- }
- payload = {
- action: 'chat', mode: 'settings', tab: tab, prompt: prompt,
- settings: readSettings(tab),
- language: languagePayload,
- source: lang && cfg.sourceSettings ? cfg.sourceSettings[tab] || null : null,
- history: history.slice(-6),
- };
- context = { tab: tab };
- } else {
- const scopeValue = scopeSelect.value;
- const sent = blocks.slice();
- payload = {
- action: 'chat',
- prompt: prompt,
- scope: scopeValue === 'all' ? 'all' : 'b' + (Number(scopeValue) + 1),
- page: {
- title: titleInput() ? titleInput().value : '',
- description: descriptionInput() ? descriptionInput().value : '',
- },
- blocks: sent.map(block => ({ id: block.id || '', type: block.type, data: block.type === 'html' ? {} : block.data })),
- language: languagePayload,
- source: lang ? cfg.source : null,
- history: history.slice(-6),
- };
- context = { sent: sent };
- }
-
- busy = true;
- sendButton.disabled = true;
- addMessage('user', prompt);
- const pending = addMessage('assistant', 'Thinking…');
- pending.classList.add('ai-pending');
- textarea.value = '';
-
- try {
- const response = await fetch(flatlyUrl(cfg.endpoint), {
- method: 'POST',
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': cfg.csrf },
- body: JSON.stringify(payload),
- });
-
- let data = null;
- try { data = await response.json(); } catch (error) { data = null; }
-
- pending.classList.remove('ai-pending');
- if (!data || !data.ok) {
- pending.classList.add('ai-msg-error');
- pending.textContent = (data && data.error) || 'Something went wrong (error ' + response.status + ').';
- return;
- }
-
- history.push({ role: 'user', text: prompt });
- pending.textContent = data.message || (data.operations.length ? "Here's what I suggest." : 'No changes needed.');
- history.push({ role: 'assistant', text: pending.textContent });
- if (data.operations.length) {
- renderProposal(Object.assign({ operations: data.operations, warnings: data.warnings }, context));
- } else if (data.warnings && data.warnings.length) {
- data.warnings.forEach(warning => messages.appendChild(el('div', 'ai-warning', warning)));
- }
- } catch (error) {
- pending.classList.remove('ai-pending');
- pending.classList.add('ai-msg-error');
- pending.textContent = "Couldn't reach the server.";
- } finally {
- busy = false;
- sendButton.disabled = false;
- scrollDown();
- }
- }
-
- QUICK_ACTIONS.forEach(action => {
- const chip = el('button', 'ai-chip', action.label);
- chip.type = 'button';
- chip.addEventListener('click', () => {
- if (action.prompt) {
- send(action.prompt);
- } else {
- textarea.value = action.fill;
- textarea.focus();
- textarea.setSelectionRange(textarea.value.length, textarea.value.length);
- }
- });
- chips.appendChild(chip);
- });
-
- function open() {
- refreshScope();
- panel.hidden = false;
- fab.classList.add('ai-fab-hidden');
- textarea.focus();
- }
-
- function close() {
- panel.hidden = true;
- fab.classList.remove('ai-fab-hidden');
- fab.focus();
- }
-
- fab.addEventListener('click', open);
- closeButton.addEventListener('click', close);
- undoButton.addEventListener('click', undo);
- document.querySelectorAll('.settings-tab').forEach(tab => tab.addEventListener('click', () => setTimeout(refreshScope, 0)));
- scopeSelect.addEventListener('change', () => {
- scopeBlock = scopeSelect.value === 'all' ? null : blocks[Number(scopeSelect.value)] || null;
- });
- sendButton.addEventListener('click', () => send(textarea.value));
- textarea.addEventListener('keydown', event => {
- if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault();
- send(textarea.value);
- }
- });
- panel.addEventListener('keydown', event => {
- if (event.key === 'Escape') close();
- });
- })();
-