/* eslint-disable no-undef */ /** * GutSlider admin dashboard. * * Staged-change editing: toggles update the UI immediately, a save bar * collects the diff, and one request persists everything. */ (function () { 'use strict'; var settings = window.gutslider || {}; var i18n = settings.i18n || {}; var app = document.querySelector('.gs-app'); if (!app) { return; } /* ---------------------------------------------------------------- * Utilities * -------------------------------------------------------------- */ function $(selector, scope) { return (scope || document).querySelector(selector); } function $$(selector, scope) { return Array.prototype.slice.call((scope || document).querySelectorAll(selector)); } function sprintf(template, values) { var i = 0; return String(template) .replace(/%(\d)\$d/g, function (match, index) { return values[parseInt(index, 10) - 1]; }) .replace(/%d|%s/g, function () { return values[i++]; }); } function post(endpoint, body) { return window .fetch(settings.restUrl + endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': settings.nonce }, body: JSON.stringify(body || {}) }) .then(function (response) { if (!response.ok) { throw new Error('HTTP ' + response.status); } return response.json(); }); } /* ---------------------------------------------------------------- * Toasts * -------------------------------------------------------------- */ var ICON_OK = ''; var ICON_ERR = ''; function toast(message, type) { var host = $('[data-gs-toasts]'); if (!host) { return; } var el = document.createElement('div'); el.className = 'gs-toast' + (type === 'error' ? ' gs-toast--error' : ''); el.setAttribute('role', 'status'); el.innerHTML = '' + (type === 'error' ? ICON_ERR : ICON_OK) + ''; el.lastChild.textContent = message; host.appendChild(el); window.setTimeout(function () { el.classList.add('is-out'); window.setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 240); }, 2600); } /* ---------------------------------------------------------------- * Colour scheme * -------------------------------------------------------------- */ (function theme() { var KEY = 'gutslider-theme'; var stored = null; try { stored = window.localStorage.getItem(KEY); } catch (e) { stored = null; } var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; var mode = stored || (prefersDark ? 'dark' : 'light'); app.setAttribute('data-gs-theme', mode); var button = $('[data-gs-theme-toggle]'); if (!button) { return; } button.addEventListener('click', function () { mode = app.getAttribute('data-gs-theme') === 'dark' ? 'light' : 'dark'; app.setAttribute('data-gs-theme', mode); try { window.localStorage.setItem(KEY, mode); } catch (e) { /* Storage unavailable — the choice just will not persist. */ } }); })(); /* ---------------------------------------------------------------- * Walkthrough — a facade, so YouTube only loads if it is asked for * -------------------------------------------------------------- */ (function walkthrough() { var trigger = $('[data-gs-video]'); if (!trigger) { return; } trigger.addEventListener('click', function () { var frame = document.createElement('iframe'); frame.src = trigger.getAttribute('data-gs-video'); frame.title = trigger.getAttribute('data-gs-video-title') || ''; frame.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'; frame.referrerPolicy = 'strict-origin-when-cross-origin'; frame.allowFullscreen = true; trigger.innerHTML = ''; trigger.appendChild(frame); trigger.disabled = true; trigger.style.cursor = 'default'; }); })(); /* ---------------------------------------------------------------- * Save bar — shared by the blocks and settings screens * -------------------------------------------------------------- */ function createSaveBar(handler) { var bar = $('[data-gs-savebar]'); var text = $('[data-gs-savebar-text]'); var saveBtn = $('[data-gs-save]'); var discardBtn = $('[data-gs-discard]'); var kbd = $('[data-gs-save-kbd]'); var count = 0; var onDiscard = function () {}; if (!bar) { return null; } if (kbd && navigator.platform.indexOf('Mac') === -1) { kbd.textContent = 'Ctrl+S'; } function render(next) { count = next; if (count > 0) { text.textContent = sprintf(count === 1 ? i18n.changeSingle : i18n.changePlural, [count]); bar.classList.add('is-visible'); bar.setAttribute('aria-hidden', 'false'); } else { bar.classList.remove('is-visible'); bar.setAttribute('aria-hidden', 'true'); } } function save() { if (count === 0 || saveBtn.classList.contains('is-busy')) { return; } saveBtn.classList.add('is-busy'); handler() .then(function () { render(0); toast(i18n.saved); }) .catch(function () { toast(i18n.saveFailed, 'error'); }) .then(function () { saveBtn.classList.remove('is-busy'); }); } saveBtn.addEventListener('click', save); discardBtn.addEventListener('click', function () { onDiscard(); render(0); toast(i18n.discarded); }); document.addEventListener('keydown', function (event) { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { if (count > 0) { event.preventDefault(); save(); } } }); window.addEventListener('beforeunload', function (event) { if (count > 0) { event.preventDefault(); event.returnValue = i18n.leaveWarning; return i18n.leaveWarning; } return undefined; }); return { update: render, onDiscard: function (fn) { onDiscard = fn; } }; } /* ---------------------------------------------------------------- * Blocks screen * -------------------------------------------------------------- */ (function blocksScreen() { var screen = $('[data-gs-blocks]'); if (!screen) { return; } var cards = $$('[data-gs-block]', screen); var searchInput = $('[data-gs-search]', screen); var counter = $('[data-gs-counter]', screen); var summary = $('[data-gs-summary]', screen); var metricValue = $('[data-gs-metric-enabled]', screen); var metricNote = $('[data-gs-metric-note]', screen); var empty = $('[data-gs-empty]', screen); var grid = $('[data-gs-grid]', screen); var filter = 'all'; var query = ''; var freeTotal = cards.filter(function (card) { return card.getAttribute('data-gs-tier') === 'free'; }).length; var proTotal = cards.length - freeTotal; var state = {}; var initial = {}; cards.forEach(function (card) { var name = card.getAttribute('data-gs-block'); var on = card.getAttribute('data-gs-initial') === '1'; initial[name] = on; state[name] = on; }); var bar = createSaveBar(function () { var changed = diff(); return post('dashboard/blocks', { blocks: changed }).then(function () { Object.keys(changed).forEach(function (name) { initial[name] = changed[name]; }); markDirty(); }); }); function diff() { var changed = {}; Object.keys(state).forEach(function (name) { if (state[name] !== initial[name]) { changed[name] = state[name]; } }); return changed; } function markDirty() { var changed = diff(); cards.forEach(function (card) { var name = card.getAttribute('data-gs-block'); card.classList.toggle('is-dirty', Object.prototype.hasOwnProperty.call(changed, name)); }); if (bar) { bar.update(Object.keys(changed).length); } } function updateCount() { var enabled = 0; cards.forEach(function (card) { if (state[card.getAttribute('data-gs-block')]) { enabled += 1; } }); if (counter) { counter.textContent = sprintf(i18n.enabledCount, [enabled, cards.length]); } if (summary) { summary.textContent = sprintf(i18n.blockSummary, [ freeTotal, proTotal, enabled, cards.length ]); } if (metricValue) { metricValue.textContent = String(enabled); } if (metricNote) { var off = cards.length - enabled; metricNote.textContent = off > 0 ? sprintf(i18n.someDisabled, [off]) : i18n.allEnabled; } } function applyFilters() { var visible = 0; cards.forEach(function (card) { var name = card.getAttribute('data-gs-block'); var tier = card.getAttribute('data-gs-tier'); var haystack = card.getAttribute('data-gs-name') || ''; var on = !!state[name]; var matchesQuery = query === '' || haystack.indexOf(query) !== -1; var matchesFilter = filter === 'all' || (filter === 'free' && tier === 'free') || (filter === 'pro' && tier === 'pro') || (filter === 'enabled' && on) || (filter === 'disabled' && !on); var show = matchesQuery && matchesFilter; card.classList.toggle('is-hidden', !show); if (show) { visible += 1; } }); if (empty) { empty.hidden = visible !== 0; } if (grid) { grid.hidden = visible === 0; } } function setToggle(card, on) { var name = card.getAttribute('data-gs-block'); var input = $('[data-gs-toggle]', card); state[name] = on; if (input) { input.checked = on; } card.classList.toggle('is-on', on); card.classList.toggle('is-off', !on); } // Toggles. $$('[data-gs-toggle]', screen).forEach(function (input) { input.addEventListener('change', function () { var card = input.closest('[data-gs-block]'); setToggle(card, input.checked); markDirty(); updateCount(); applyFilters(); }); }); // Search. if (searchInput) { searchInput.addEventListener('input', function () { query = searchInput.value.trim().toLowerCase(); applyFilters(); }); document.addEventListener('keydown', function (event) { var tag = document.activeElement ? document.activeElement.tagName : ''; if (event.key === '/' && tag !== 'INPUT' && tag !== 'TEXTAREA') { event.preventDefault(); searchInput.focus(); } if (event.key === 'Escape' && document.activeElement === searchInput) { searchInput.value = ''; query = ''; applyFilters(); } }); } // Filter segments. $$('[data-gs-filter]', screen).forEach(function (button) { button.addEventListener('click', function () { filter = button.getAttribute('data-gs-filter'); $$('[data-gs-filter]', screen).forEach(function (other) { var active = other === button; other.classList.toggle('is-active', active); other.setAttribute('aria-selected', active ? 'true' : 'false'); }); applyFilters(); }); }); // Clear filters from the empty state. var clearBtn = $('[data-gs-clear-filters]', screen); if (clearBtn) { clearBtn.addEventListener('click', function () { query = ''; filter = 'all'; if (searchInput) { searchInput.value = ''; } $$('[data-gs-filter]', screen).forEach(function (other) { var active = other.getAttribute('data-gs-filter') === 'all'; other.classList.toggle('is-active', active); other.setAttribute('aria-selected', active ? 'true' : 'false'); }); applyFilters(); }); } // Bulk actions apply to whatever is currently visible. $$('[data-gs-bulk]', screen).forEach(function (button) { button.addEventListener('click', function () { var on = button.getAttribute('data-gs-bulk') === 'on'; cards.forEach(function (card) { if (card.classList.contains('is-hidden') || card.getAttribute('data-gs-locked') === '1') { return; } setToggle(card, on); }); markDirty(); updateCount(); applyFilters(); }); }); if (bar) { bar.onDiscard(function () { cards.forEach(function (card) { setToggle(card, initial[card.getAttribute('data-gs-block')]); card.classList.remove('is-dirty'); }); updateCount(); applyFilters(); }); } updateCount(); applyFilters(); })(); /* ---------------------------------------------------------------- * Settings screen * -------------------------------------------------------------- */ (function settingsScreen() { var screen = $('[data-gs-settings]'); if (!screen) { return; } var inputs = $$('[data-gs-setting]', screen); var initial = {}; var state = {}; inputs.forEach(function (input) { var key = input.getAttribute('data-gs-setting'); if (input.type === 'radio') { initial[key] = input.getAttribute('data-gs-initial'); if (input.checked) { state[key] = input.value; } } else { initial[key] = input.getAttribute('data-gs-initial') === '1'; state[key] = input.checked; } }); var bar = createSaveBar(function () { var changed = diff(); return post('dashboard/settings', { settings: changed }).then(function () { Object.keys(changed).forEach(function (key) { initial[key] = changed[key]; }); }); }); function diff() { var changed = {}; Object.keys(state).forEach(function (key) { if (state[key] !== initial[key]) { changed[key] = state[key]; } }); return changed; } function markDirty() { if (bar) { bar.update(Object.keys(diff()).length); } } function syncRadioCards() { $$('.gs-radio-card', screen).forEach(function (card) { var input = $('input', card); card.classList.toggle('is-active', !!input && input.checked); }); } inputs.forEach(function (input) { input.addEventListener('change', function () { var key = input.getAttribute('data-gs-setting'); if (input.type === 'radio') { if (!input.checked) { return; } state[key] = input.value; syncRadioCards(); } else { state[key] = input.checked; } markDirty(); }); }); if (bar) { bar.onDiscard(function () { inputs.forEach(function (input) { var key = input.getAttribute('data-gs-setting'); if (input.type === 'radio') { input.checked = input.value === initial[key]; state[key] = initial[key]; } else { input.checked = initial[key]; state[key] = initial[key]; } }); syncRadioCards(); }); } // Clear generated stylesheets. var clearCache = $('[data-gs-clear-cache]', screen); if (clearCache) { clearCache.addEventListener('click', function () { if (clearCache.classList.contains('is-busy')) { return; } clearCache.classList.add('is-busy'); post('dashboard/clear-cache') .then(function () { toast(i18n.cacheCleared); }) .catch(function () { toast(i18n.saveFailed, 'error'); }) .then(function () { clearCache.classList.remove('is-busy'); }); }); } /* * Section navigation. * * Deliberately not an IntersectionObserver: the trailing sections are * often too short to ever cross a fixed detection band, so they could * never become active. Instead, the current section is the last one * whose top has passed the offset line, and the bottom of the page * always owns the final section. */ var sections = $$('[data-gs-sidenav]', screen) .map(function (link) { var panel = $(link.getAttribute('href'), screen); return panel ? { link: link, panel: panel } : null; }) .filter(Boolean); if (sections.length) { // Distance from the viewport top, clearing the sticky bar. var SPY_OFFSET = 140; var spyFrame = null; var spyLockedUntil = 0; var setActiveSection = function (link) { sections.forEach(function (section) { section.link.classList.toggle('is-active', section.link === link); }); }; var syncSpy = function () { spyFrame = null; // A click owns the highlight until its smooth scroll settles. if (Date.now() < spyLockedUntil) { return; } var scrollable = document.documentElement.scrollHeight - window.innerHeight; var current = sections[0]; if (scrollable > 4 && window.pageYOffset >= scrollable - 2) { current = sections[sections.length - 1]; } else { sections.forEach(function (section) { if (section.panel.getBoundingClientRect().top <= SPY_OFFSET) { current = section; } }); } setActiveSection(current.link); }; var queueSpy = function () { if (spyFrame === null) { spyFrame = window.requestAnimationFrame(syncSpy); } }; sections.forEach(function (section) { section.link.addEventListener('click', function (event) { event.preventDefault(); setActiveSection(section.link); spyLockedUntil = Date.now() + 700; section.panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); window.addEventListener('scroll', queueSpy, { passive: true }); window.addEventListener('resize', queueSpy); syncSpy(); } syncRadioCards(); })(); })();