| @@ -1,113 +1,709 @@ | ||
| 1 | 1 | /* eslint-disable no-undef */ |
| 2 | 2 | /** |
| 3 | - * GutSlider Admin JavaScript | |
| 3 | + * GutSlider admin dashboard. | |
| 4 | + * | |
| 5 | + * Staged-change editing: toggles update the UI immediately, a save bar | |
| 6 | + * collects the diff, and one request persists everything. | |
| 4 | 7 | */ |
| 8 | +(function () { | |
| 9 | + 'use strict'; | |
| 5 | 10 | |
| 6 | -(function ($) { | |
| 7 | - 'use strict'; | |
| 11 | + var settings = window.gutslider || {}; | |
| 12 | + var i18n = settings.i18n || {}; | |
| 13 | + var app = document.querySelector('.gs-app'); | |
| 8 | 14 | |
| 9 | - $(document).ready(function () { | |
| 10 | - // Handle block toggle | |
| 11 | - $('.block-toggle').on('change', function () { | |
| 12 | - const $toggle = $(this); | |
| 13 | - const blockKey = $toggle.data('key'); | |
| 14 | - // const blockName = $toggle.data('block'); | |
| 15 | - const isChecked = $toggle.is(':checked'); | |
| 15 | + if (!app) { | |
| 16 | + return; | |
| 17 | + } | |
| 16 | 18 | |
| 17 | - // Disable toggle during request | |
| 18 | - $toggle.prop('disabled', true); | |
| 19 | + /* ---------------------------------------------------------------- | |
| 20 | + * Utilities | |
| 21 | + * -------------------------------------------------------------- */ | |
| 19 | 22 | |
| 20 | - // Show loading indicator | |
| 21 | - const $card = $toggle.closest('.gutslider-block-card'); | |
| 22 | - $card.css('opacity', '0.6'); | |
| 23 | + function $(selector, scope) { | |
| 24 | + return (scope || document).querySelector(selector); | |
| 25 | + } | |
| 23 | 26 | |
| 24 | - // Send AJAX request | |
| 25 | - $.ajax({ | |
| 26 | - url: gutslider.ajaxUrl, | |
| 27 | - type: 'POST', | |
| 28 | - data: { | |
| 29 | - action: 'gutslider_toggle_block', | |
| 30 | - nonce: gutslider.nonce, | |
| 31 | - block_key: blockKey, | |
| 32 | - status: isChecked | |
| 27 | + function $$(selector, scope) { | |
| 28 | + return Array.prototype.slice.call((scope || document).querySelectorAll(selector)); | |
| 29 | + } | |
| 30 | + | |
| 31 | + function sprintf(template, values) { | |
| 32 | + var i = 0; | |
| 33 | + return String(template) | |
| 34 | + .replace(/%(\d)\$d/g, function (match, index) { | |
| 35 | + return values[parseInt(index, 10) - 1]; | |
| 36 | + }) | |
| 37 | + .replace(/%d|%s/g, function () { | |
| 38 | + return values[i++]; | |
| 39 | + }); | |
| 40 | + } | |
| 41 | + | |
| 42 | + function post(endpoint, body) { | |
| 43 | + return window | |
| 44 | + .fetch(settings.restUrl + endpoint, { | |
| 45 | + method: 'POST', | |
| 46 | + credentials: 'same-origin', | |
| 47 | + headers: { | |
| 48 | + 'Content-Type': 'application/json', | |
| 49 | + 'X-WP-Nonce': settings.nonce | |
| 33 | 50 | }, |
| 34 | - success(response) { | |
| 35 | - if (response.success) { | |
| 36 | - // Show success notification | |
| 37 | - showNotification('success', response.data.message); | |
| 38 | - } else { | |
| 39 | - // Revert toggle on error | |
| 40 | - $toggle.prop('checked', !isChecked); | |
| 41 | - showNotification('error', response.data.message); | |
| 42 | - } | |
| 43 | - }, | |
| 44 | - error() { | |
| 45 | - // Revert toggle on error | |
| 46 | - $toggle.prop('checked', !isChecked); | |
| 47 | - showNotification('error', 'An error occurred. Please try again.'); | |
| 48 | - }, | |
| 49 | - complete() { | |
| 50 | - // Re-enable toggle and remove loading state | |
| 51 | - $toggle.prop('disabled', false); | |
| 52 | - $card.css('opacity', '1'); | |
| 51 | + body: JSON.stringify(body || {}) | |
| 52 | + }) | |
| 53 | + .then(function (response) { | |
| 54 | + if (!response.ok) { | |
| 55 | + throw new Error('HTTP ' + response.status); | |
| 53 | 56 | } |
| 57 | + return response.json(); | |
| 54 | 58 | }); |
| 59 | + } | |
| 60 | + | |
| 61 | + /* ---------------------------------------------------------------- | |
| 62 | + * Toasts | |
| 63 | + * -------------------------------------------------------------- */ | |
| 64 | + | |
| 65 | + var ICON_OK = '<path d="m5 13 4 4L19 7"/>'; | |
| 66 | + var ICON_ERR = '<path d="M18 6 6 18M6 6l12 12"/>'; | |
| 67 | + | |
| 68 | + function toast(message, type) { | |
| 69 | + var host = $('[data-gs-toasts]'); | |
| 70 | + | |
| 71 | + if (!host) { | |
| 72 | + return; | |
| 73 | + } | |
| 74 | + | |
| 75 | + var el = document.createElement('div'); | |
| 76 | + el.className = 'gs-toast' + (type === 'error' ? ' gs-toast--error' : ''); | |
| 77 | + el.setAttribute('role', 'status'); | |
| 78 | + el.innerHTML = | |
| 79 | + '<span class="gs-toast__icon"><svg class="gs-icon" viewBox="0 0 24 24" fill="none" ' + | |
| 80 | + 'stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">' + | |
| 81 | + (type === 'error' ? ICON_ERR : ICON_OK) + | |
| 82 | + '</svg></span><span></span>'; | |
| 83 | + el.lastChild.textContent = message; | |
| 84 | + | |
| 85 | + host.appendChild(el); | |
| 86 | + | |
| 87 | + window.setTimeout(function () { | |
| 88 | + el.classList.add('is-out'); | |
| 89 | + window.setTimeout(function () { | |
| 90 | + if (el.parentNode) { | |
| 91 | + el.parentNode.removeChild(el); | |
| 92 | + } | |
| 93 | + }, 240); | |
| 94 | + }, 2600); | |
| 95 | + } | |
| 96 | + | |
| 97 | + /* ---------------------------------------------------------------- | |
| 98 | + * Colour scheme | |
| 99 | + * -------------------------------------------------------------- */ | |
| 100 | + | |
| 101 | + (function theme() { | |
| 102 | + var KEY = 'gutslider-theme'; | |
| 103 | + var stored = null; | |
| 104 | + | |
| 105 | + try { | |
| 106 | + stored = window.localStorage.getItem(KEY); | |
| 107 | + } catch (e) { | |
| 108 | + stored = null; | |
| 109 | + } | |
| 110 | + | |
| 111 | + var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| 112 | + var mode = stored || (prefersDark ? 'dark' : 'light'); | |
| 113 | + | |
| 114 | + app.setAttribute('data-gs-theme', mode); | |
| 115 | + | |
| 116 | + var button = $('[data-gs-theme-toggle]'); | |
| 117 | + | |
| 118 | + if (!button) { | |
| 119 | + return; | |
| 120 | + } | |
| 121 | + | |
| 122 | + button.addEventListener('click', function () { | |
| 123 | + mode = app.getAttribute('data-gs-theme') === 'dark' ? 'light' : 'dark'; | |
| 124 | + app.setAttribute('data-gs-theme', mode); | |
| 125 | + | |
| 126 | + try { | |
| 127 | + window.localStorage.setItem(KEY, mode); | |
| 128 | + } catch (e) { | |
| 129 | + /* Storage unavailable — the choice just will not persist. */ | |
| 130 | + } | |
| 55 | 131 | }); |
| 132 | + })(); | |
| 56 | 133 | |
| 57 | - // Show notification function | |
| 58 | - function showNotification(type, message) { | |
| 59 | - const notificationClass = type === 'success' ? 'notice-success' : 'notice-error'; | |
| 60 | - const $notification = $('<div>', { | |
| 61 | - class: `notice ${notificationClass} is-dismissible gutslider-notification`, | |
| 62 | - html: `<p>${message}</p>` | |
| 134 | + /* ---------------------------------------------------------------- | |
| 135 | + * Walkthrough — a facade, so YouTube only loads if it is asked for | |
| 136 | + * -------------------------------------------------------------- */ | |
| 137 | + | |
| 138 | + (function walkthrough() { | |
| 139 | + var trigger = $('[data-gs-video]'); | |
| 140 | + | |
| 141 | + if (!trigger) { | |
| 142 | + return; | |
| 143 | + } | |
| 144 | + | |
| 145 | + trigger.addEventListener('click', function () { | |
| 146 | + var frame = document.createElement('iframe'); | |
| 147 | + | |
| 148 | + frame.src = trigger.getAttribute('data-gs-video'); | |
| 149 | + frame.title = trigger.getAttribute('data-gs-video-title') || ''; | |
| 150 | + frame.allow = | |
| 151 | + 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'; | |
| 152 | + frame.referrerPolicy = 'strict-origin-when-cross-origin'; | |
| 153 | + frame.allowFullscreen = true; | |
| 154 | + | |
| 155 | + trigger.innerHTML = ''; | |
| 156 | + trigger.appendChild(frame); | |
| 157 | + trigger.disabled = true; | |
| 158 | + trigger.style.cursor = 'default'; | |
| 159 | + }); | |
| 160 | + })(); | |
| 161 | + | |
| 162 | + /* ---------------------------------------------------------------- | |
| 163 | + * Save bar — shared by the blocks and settings screens | |
| 164 | + * -------------------------------------------------------------- */ | |
| 165 | + | |
| 166 | + function createSaveBar(handler) { | |
| 167 | + var bar = $('[data-gs-savebar]'); | |
| 168 | + var text = $('[data-gs-savebar-text]'); | |
| 169 | + var saveBtn = $('[data-gs-save]'); | |
| 170 | + var discardBtn = $('[data-gs-discard]'); | |
| 171 | + var kbd = $('[data-gs-save-kbd]'); | |
| 172 | + var count = 0; | |
| 173 | + var onDiscard = function () {}; | |
| 174 | + | |
| 175 | + if (!bar) { | |
| 176 | + return null; | |
| 177 | + } | |
| 178 | + | |
| 179 | + if (kbd && navigator.platform.indexOf('Mac') === -1) { | |
| 180 | + kbd.textContent = 'Ctrl+S'; | |
| 181 | + } | |
| 182 | + | |
| 183 | + function render(next) { | |
| 184 | + count = next; | |
| 185 | + | |
| 186 | + if (count > 0) { | |
| 187 | + text.textContent = sprintf(count === 1 ? i18n.changeSingle : i18n.changePlural, [count]); | |
| 188 | + bar.classList.add('is-visible'); | |
| 189 | + bar.setAttribute('aria-hidden', 'false'); | |
| 190 | + } else { | |
| 191 | + bar.classList.remove('is-visible'); | |
| 192 | + bar.setAttribute('aria-hidden', 'true'); | |
| 193 | + } | |
| 194 | + } | |
| 195 | + | |
| 196 | + function save() { | |
| 197 | + if (count === 0 || saveBtn.classList.contains('is-busy')) { | |
| 198 | + return; | |
| 199 | + } | |
| 200 | + | |
| 201 | + saveBtn.classList.add('is-busy'); | |
| 202 | + | |
| 203 | + handler() | |
| 204 | + .then(function () { | |
| 205 | + render(0); | |
| 206 | + toast(i18n.saved); | |
| 207 | + }) | |
| 208 | + .catch(function () { | |
| 209 | + toast(i18n.saveFailed, 'error'); | |
| 210 | + }) | |
| 211 | + .then(function () { | |
| 212 | + saveBtn.classList.remove('is-busy'); | |
| 213 | + }); | |
| 214 | + } | |
| 215 | + | |
| 216 | + saveBtn.addEventListener('click', save); | |
| 217 | + | |
| 218 | + discardBtn.addEventListener('click', function () { | |
| 219 | + onDiscard(); | |
| 220 | + render(0); | |
| 221 | + toast(i18n.discarded); | |
| 222 | + }); | |
| 223 | + | |
| 224 | + document.addEventListener('keydown', function (event) { | |
| 225 | + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { | |
| 226 | + if (count > 0) { | |
| 227 | + event.preventDefault(); | |
| 228 | + save(); | |
| 229 | + } | |
| 230 | + } | |
| 231 | + }); | |
| 232 | + | |
| 233 | + window.addEventListener('beforeunload', function (event) { | |
| 234 | + if (count > 0) { | |
| 235 | + event.preventDefault(); | |
| 236 | + event.returnValue = i18n.leaveWarning; | |
| 237 | + return i18n.leaveWarning; | |
| 238 | + } | |
| 239 | + | |
| 240 | + return undefined; | |
| 241 | + }); | |
| 242 | + | |
| 243 | + return { | |
| 244 | + update: render, | |
| 245 | + onDiscard: function (fn) { | |
| 246 | + onDiscard = fn; | |
| 247 | + } | |
| 248 | + }; | |
| 249 | + } | |
| 250 | + | |
| 251 | + /* ---------------------------------------------------------------- | |
| 252 | + * Blocks screen | |
| 253 | + * -------------------------------------------------------------- */ | |
| 254 | + | |
| 255 | + (function blocksScreen() { | |
| 256 | + var screen = $('[data-gs-blocks]'); | |
| 257 | + | |
| 258 | + if (!screen) { | |
| 259 | + return; | |
| 260 | + } | |
| 261 | + | |
| 262 | + var cards = $$('[data-gs-block]', screen); | |
| 263 | + var searchInput = $('[data-gs-search]', screen); | |
| 264 | + var counter = $('[data-gs-counter]', screen); | |
| 265 | + var summary = $('[data-gs-summary]', screen); | |
| 266 | + var metricValue = $('[data-gs-metric-enabled]', screen); | |
| 267 | + var metricNote = $('[data-gs-metric-note]', screen); | |
| 268 | + var empty = $('[data-gs-empty]', screen); | |
| 269 | + var grid = $('[data-gs-grid]', screen); | |
| 270 | + var filter = 'all'; | |
| 271 | + var query = ''; | |
| 272 | + | |
| 273 | + var freeTotal = cards.filter(function (card) { | |
| 274 | + return card.getAttribute('data-gs-tier') === 'free'; | |
| 275 | + }).length; | |
| 276 | + var proTotal = cards.length - freeTotal; | |
| 277 | + | |
| 278 | + var state = {}; | |
| 279 | + var initial = {}; | |
| 280 | + | |
| 281 | + cards.forEach(function (card) { | |
| 282 | + var name = card.getAttribute('data-gs-block'); | |
| 283 | + var on = card.getAttribute('data-gs-initial') === '1'; | |
| 284 | + initial[name] = on; | |
| 285 | + state[name] = on; | |
| 286 | + }); | |
| 287 | + | |
| 288 | + var bar = createSaveBar(function () { | |
| 289 | + var changed = diff(); | |
| 290 | + | |
| 291 | + return post('dashboard/blocks', { blocks: changed }).then(function () { | |
| 292 | + Object.keys(changed).forEach(function (name) { | |
| 293 | + initial[name] = changed[name]; | |
| 294 | + }); | |
| 295 | + markDirty(); | |
| 63 | 296 | }); |
| 297 | + }); | |
| 64 | 298 | |
| 65 | - // Add close button functionality | |
| 66 | - $notification.append( | |
| 67 | - '<button type="button" class="notice-dismiss"><span class="screen-reader-text">Dismiss this notice.</span></button>' | |
| 68 | - ); | |
| 299 | + function diff() { | |
| 300 | + var changed = {}; | |
| 69 | 301 | |
| 70 | - // Insert notification | |
| 71 | - if ($('.gutslider-admin-wrap').length) { | |
| 72 | - $('.gutslider-admin-wrap').prepend($notification); | |
| 73 | - } else { | |
| 74 | - $('.wrap').prepend($notification); | |
| 302 | + Object.keys(state).forEach(function (name) { | |
| 303 | + if (state[name] !== initial[name]) { | |
| 304 | + changed[name] = state[name]; | |
| 305 | + } | |
| 306 | + }); | |
| 307 | + | |
| 308 | + return changed; | |
| 309 | + } | |
| 310 | + | |
| 311 | + function markDirty() { | |
| 312 | + var changed = diff(); | |
| 313 | + | |
| 314 | + cards.forEach(function (card) { | |
| 315 | + var name = card.getAttribute('data-gs-block'); | |
| 316 | + card.classList.toggle('is-dirty', Object.prototype.hasOwnProperty.call(changed, name)); | |
| 317 | + }); | |
| 318 | + | |
| 319 | + if (bar) { | |
| 320 | + bar.update(Object.keys(changed).length); | |
| 75 | 321 | } |
| 322 | + } | |
| 76 | 323 | |
| 77 | - // Handle dismiss button | |
| 78 | - $notification.find('.notice-dismiss').on('click', function () { | |
| 79 | - $notification.fadeOut(300, function () { | |
| 80 | - $(this).remove(); | |
| 324 | + function updateCount() { | |
| 325 | + var enabled = 0; | |
| 326 | + | |
| 327 | + cards.forEach(function (card) { | |
| 328 | + if (state[card.getAttribute('data-gs-block')]) { | |
| 329 | + enabled += 1; | |
| 330 | + } | |
| 331 | + }); | |
| 332 | + | |
| 333 | + if (counter) { | |
| 334 | + counter.textContent = sprintf(i18n.enabledCount, [enabled, cards.length]); | |
| 335 | + } | |
| 336 | + | |
| 337 | + if (summary) { | |
| 338 | + summary.textContent = sprintf(i18n.blockSummary, [ | |
| 339 | + freeTotal, | |
| 340 | + proTotal, | |
| 341 | + enabled, | |
| 342 | + cards.length | |
| 343 | + ]); | |
| 344 | + } | |
| 345 | + | |
| 346 | + if (metricValue) { | |
| 347 | + metricValue.textContent = String(enabled); | |
| 348 | + } | |
| 349 | + | |
| 350 | + if (metricNote) { | |
| 351 | + var off = cards.length - enabled; | |
| 352 | + metricNote.textContent = off > 0 ? sprintf(i18n.someDisabled, [off]) : i18n.allEnabled; | |
| 353 | + } | |
| 354 | + } | |
| 355 | + | |
| 356 | + function applyFilters() { | |
| 357 | + var visible = 0; | |
| 358 | + | |
| 359 | + cards.forEach(function (card) { | |
| 360 | + var name = card.getAttribute('data-gs-block'); | |
| 361 | + var tier = card.getAttribute('data-gs-tier'); | |
| 362 | + var haystack = card.getAttribute('data-gs-name') || ''; | |
| 363 | + var on = !!state[name]; | |
| 364 | + | |
| 365 | + var matchesQuery = query === '' || haystack.indexOf(query) !== -1; | |
| 366 | + var matchesFilter = | |
| 367 | + filter === 'all' || | |
| 368 | + (filter === 'free' && tier === 'free') || | |
| 369 | + (filter === 'pro' && tier === 'pro') || | |
| 370 | + (filter === 'enabled' && on) || | |
| 371 | + (filter === 'disabled' && !on); | |
| 372 | + | |
| 373 | + var show = matchesQuery && matchesFilter; | |
| 374 | + card.classList.toggle('is-hidden', !show); | |
| 375 | + | |
| 376 | + if (show) { | |
| 377 | + visible += 1; | |
| 378 | + } | |
| 379 | + }); | |
| 380 | + | |
| 381 | + if (empty) { | |
| 382 | + empty.hidden = visible !== 0; | |
| 383 | + } | |
| 384 | + | |
| 385 | + if (grid) { | |
| 386 | + grid.hidden = visible === 0; | |
| 387 | + } | |
| 388 | + } | |
| 389 | + | |
| 390 | + function setToggle(card, on) { | |
| 391 | + var name = card.getAttribute('data-gs-block'); | |
| 392 | + var input = $('[data-gs-toggle]', card); | |
| 393 | + | |
| 394 | + state[name] = on; | |
| 395 | + | |
| 396 | + if (input) { | |
| 397 | + input.checked = on; | |
| 398 | + } | |
| 399 | + | |
| 400 | + card.classList.toggle('is-on', on); | |
| 401 | + card.classList.toggle('is-off', !on); | |
| 402 | + } | |
| 403 | + | |
| 404 | + // Toggles. | |
| 405 | + $$('[data-gs-toggle]', screen).forEach(function (input) { | |
| 406 | + input.addEventListener('change', function () { | |
| 407 | + var card = input.closest('[data-gs-block]'); | |
| 408 | + setToggle(card, input.checked); | |
| 409 | + markDirty(); | |
| 410 | + updateCount(); | |
| 411 | + applyFilters(); | |
| 412 | + }); | |
| 413 | + }); | |
| 414 | + | |
| 415 | + // Search. | |
| 416 | + if (searchInput) { | |
| 417 | + searchInput.addEventListener('input', function () { | |
| 418 | + query = searchInput.value.trim().toLowerCase(); | |
| 419 | + applyFilters(); | |
| 420 | + }); | |
| 421 | + | |
| 422 | + document.addEventListener('keydown', function (event) { | |
| 423 | + var tag = document.activeElement ? document.activeElement.tagName : ''; | |
| 424 | + | |
| 425 | + if (event.key === '/' && tag !== 'INPUT' && tag !== 'TEXTAREA') { | |
| 426 | + event.preventDefault(); | |
| 427 | + searchInput.focus(); | |
| 428 | + } | |
| 429 | + | |
| 430 | + if (event.key === 'Escape' && document.activeElement === searchInput) { | |
| 431 | + searchInput.value = ''; | |
| 432 | + query = ''; | |
| 433 | + applyFilters(); | |
| 434 | + } | |
| 435 | + }); | |
| 436 | + } | |
| 437 | + | |
| 438 | + // Filter segments. | |
| 439 | + $$('[data-gs-filter]', screen).forEach(function (button) { | |
| 440 | + button.addEventListener('click', function () { | |
| 441 | + filter = button.getAttribute('data-gs-filter'); | |
| 442 | + | |
| 443 | + $$('[data-gs-filter]', screen).forEach(function (other) { | |
| 444 | + var active = other === button; | |
| 445 | + other.classList.toggle('is-active', active); | |
| 446 | + other.setAttribute('aria-selected', active ? 'true' : 'false'); | |
| 81 | 447 | }); |
| 448 | + | |
| 449 | + applyFilters(); | |
| 82 | 450 | }); |
| 451 | + }); | |
| 83 | 452 | |
| 84 | - // Auto remove after 3 seconds | |
| 85 | - setTimeout(function () { | |
| 86 | - $notification.fadeOut(300, function () { | |
| 87 | - $(this).remove(); | |
| 453 | + // Clear filters from the empty state. | |
| 454 | + var clearBtn = $('[data-gs-clear-filters]', screen); | |
| 455 | + | |
| 456 | + if (clearBtn) { | |
| 457 | + clearBtn.addEventListener('click', function () { | |
| 458 | + query = ''; | |
| 459 | + filter = 'all'; | |
| 460 | + | |
| 461 | + if (searchInput) { | |
| 462 | + searchInput.value = ''; | |
| 463 | + } | |
| 464 | + | |
| 465 | + $$('[data-gs-filter]', screen).forEach(function (other) { | |
| 466 | + var active = other.getAttribute('data-gs-filter') === 'all'; | |
| 467 | + other.classList.toggle('is-active', active); | |
| 468 | + other.setAttribute('aria-selected', active ? 'true' : 'false'); | |
| 88 | 469 | }); |
| 89 | - }, 1000); | |
| 470 | + | |
| 471 | + applyFilters(); | |
| 472 | + }); | |
| 90 | 473 | } |
| 91 | 474 | |
| 92 | - // Handle pro block clicks | |
| 93 | - $('.pro-block .toggle-switch input:disabled').on('click', function (e) { | |
| 94 | - e.preventDefault(); | |
| 95 | - showProUpgradeNotice(); | |
| 475 | + // Bulk actions apply to whatever is currently visible. | |
| 476 | + $$('[data-gs-bulk]', screen).forEach(function (button) { | |
| 477 | + button.addEventListener('click', function () { | |
| 478 | + var on = button.getAttribute('data-gs-bulk') === 'on'; | |
| 479 | + | |
| 480 | + cards.forEach(function (card) { | |
| 481 | + if (card.classList.contains('is-hidden') || card.getAttribute('data-gs-locked') === '1') { | |
| 482 | + return; | |
| 483 | + } | |
| 484 | + | |
| 485 | + setToggle(card, on); | |
| 486 | + }); | |
| 487 | + | |
| 488 | + markDirty(); | |
| 489 | + updateCount(); | |
| 490 | + applyFilters(); | |
| 491 | + }); | |
| 96 | 492 | }); |
| 97 | 493 | |
| 98 | - $('.pro-block').on('click', function (e) { | |
| 99 | - if ($(e.target).closest('.toggle-switch, .demo-link').length === 0) { | |
| 100 | - showProUpgradeNotice(); | |
| 494 | + if (bar) { | |
| 495 | + bar.onDiscard(function () { | |
| 496 | + cards.forEach(function (card) { | |
| 497 | + setToggle(card, initial[card.getAttribute('data-gs-block')]); | |
| 498 | + card.classList.remove('is-dirty'); | |
| 499 | + }); | |
| 500 | + | |
| 501 | + updateCount(); | |
| 502 | + applyFilters(); | |
| 503 | + }); | |
| 504 | + } | |
| 505 | + | |
| 506 | + updateCount(); | |
| 507 | + applyFilters(); | |
| 508 | + })(); | |
| 509 | + | |
| 510 | + /* ---------------------------------------------------------------- | |
| 511 | + * Settings screen | |
| 512 | + * -------------------------------------------------------------- */ | |
| 513 | + | |
| 514 | + (function settingsScreen() { | |
| 515 | + var screen = $('[data-gs-settings]'); | |
| 516 | + | |
| 517 | + if (!screen) { | |
| 518 | + return; | |
| 519 | + } | |
| 520 | + | |
| 521 | + var inputs = $$('[data-gs-setting]', screen); | |
| 522 | + var initial = {}; | |
| 523 | + var state = {}; | |
| 524 | + | |
| 525 | + inputs.forEach(function (input) { | |
| 526 | + var key = input.getAttribute('data-gs-setting'); | |
| 527 | + | |
| 528 | + if (input.type === 'radio') { | |
| 529 | + initial[key] = input.getAttribute('data-gs-initial'); | |
| 530 | + | |
| 531 | + if (input.checked) { | |
| 532 | + state[key] = input.value; | |
| 533 | + } | |
| 534 | + } else { | |
| 535 | + initial[key] = input.getAttribute('data-gs-initial') === '1'; | |
| 536 | + state[key] = input.checked; | |
| 101 | 537 | } |
| 102 | 538 | }); |
| 103 | 539 | |
| 104 | - // Show pro upgrade notice | |
| 105 | - function showProUpgradeNotice() { | |
| 106 | - if (!gutslider.isPro) { | |
| 107 | - const upgradeUrl = 'https://gutslider.com/pricing/'; | |
| 108 | - const message = `This is a Pro feature. <a href="${upgradeUrl}" target="_blank" rel="noopener noreferrer">Upgrade to Pro</a> to unlock this block.`; | |
| 109 | - showNotification('error', message); | |
| 540 | + var bar = createSaveBar(function () { | |
| 541 | + var changed = diff(); | |
| 542 | + | |
| 543 | + return post('dashboard/settings', { settings: changed }).then(function () { | |
| 544 | + Object.keys(changed).forEach(function (key) { | |
| 545 | + initial[key] = changed[key]; | |
| 546 | + }); | |
| 547 | + }); | |
| 548 | + }); | |
| 549 | + | |
| 550 | + function diff() { | |
| 551 | + var changed = {}; | |
| 552 | + | |
| 553 | + Object.keys(state).forEach(function (key) { | |
| 554 | + if (state[key] !== initial[key]) { | |
| 555 | + changed[key] = state[key]; | |
| 556 | + } | |
| 557 | + }); | |
| 558 | + | |
| 559 | + return changed; | |
| 560 | + } | |
| 561 | + | |
| 562 | + function markDirty() { | |
| 563 | + if (bar) { | |
| 564 | + bar.update(Object.keys(diff()).length); | |
| 110 | 565 | } |
| 111 | 566 | } |
| 112 | - }); | |
| 113 | -})(jQuery); | |
| 567 | + | |
| 568 | + function syncRadioCards() { | |
| 569 | + $$('.gs-radio-card', screen).forEach(function (card) { | |
| 570 | + var input = $('input', card); | |
| 571 | + card.classList.toggle('is-active', !!input && input.checked); | |
| 572 | + }); | |
| 573 | + } | |
| 574 | + | |
| 575 | + inputs.forEach(function (input) { | |
| 576 | + input.addEventListener('change', function () { | |
| 577 | + var key = input.getAttribute('data-gs-setting'); | |
| 578 | + | |
| 579 | + if (input.type === 'radio') { | |
| 580 | + if (!input.checked) { | |
| 581 | + return; | |
| 582 | + } | |
| 583 | + state[key] = input.value; | |
| 584 | + syncRadioCards(); | |
| 585 | + } else { | |
| 586 | + state[key] = input.checked; | |
| 587 | + } | |
| 588 | + | |
| 589 | + markDirty(); | |
| 590 | + }); | |
| 591 | + }); | |
| 592 | + | |
| 593 | + if (bar) { | |
| 594 | + bar.onDiscard(function () { | |
| 595 | + inputs.forEach(function (input) { | |
| 596 | + var key = input.getAttribute('data-gs-setting'); | |
| 597 | + | |
| 598 | + if (input.type === 'radio') { | |
| 599 | + input.checked = input.value === initial[key]; | |
| 600 | + state[key] = initial[key]; | |
| 601 | + } else { | |
| 602 | + input.checked = initial[key]; | |
| 603 | + state[key] = initial[key]; | |
| 604 | + } | |
| 605 | + }); | |
| 606 | + | |
| 607 | + syncRadioCards(); | |
| 608 | + }); | |
| 609 | + } | |
| 610 | + | |
| 611 | + // Clear generated stylesheets. | |
| 612 | + var clearCache = $('[data-gs-clear-cache]', screen); | |
| 613 | + | |
| 614 | + if (clearCache) { | |
| 615 | + clearCache.addEventListener('click', function () { | |
| 616 | + if (clearCache.classList.contains('is-busy')) { | |
| 617 | + return; | |
| 618 | + } | |
| 619 | + | |
| 620 | + clearCache.classList.add('is-busy'); | |
| 621 | + | |
| 622 | + post('dashboard/clear-cache') | |
| 623 | + .then(function () { | |
| 624 | + toast(i18n.cacheCleared); | |
| 625 | + }) | |
| 626 | + .catch(function () { | |
| 627 | + toast(i18n.saveFailed, 'error'); | |
| 628 | + }) | |
| 629 | + .then(function () { | |
| 630 | + clearCache.classList.remove('is-busy'); | |
| 631 | + }); | |
| 632 | + }); | |
| 633 | + } | |
| 634 | + | |
| 635 | + /* | |
| 636 | + * Section navigation. | |
| 637 | + * | |
| 638 | + * Deliberately not an IntersectionObserver: the trailing sections are | |
| 639 | + * often too short to ever cross a fixed detection band, so they could | |
| 640 | + * never become active. Instead, the current section is the last one | |
| 641 | + * whose top has passed the offset line, and the bottom of the page | |
| 642 | + * always owns the final section. | |
| 643 | + */ | |
| 644 | + var sections = $$('[data-gs-sidenav]', screen) | |
| 645 | + .map(function (link) { | |
| 646 | + var panel = $(link.getAttribute('href'), screen); | |
| 647 | + return panel ? { link: link, panel: panel } : null; | |
| 648 | + }) | |
| 649 | + .filter(Boolean); | |
| 650 | + | |
| 651 | + if (sections.length) { | |
| 652 | + // Distance from the viewport top, clearing the sticky bar. | |
| 653 | + var SPY_OFFSET = 140; | |
| 654 | + var spyFrame = null; | |
| 655 | + var spyLockedUntil = 0; | |
| 656 | + | |
| 657 | + var setActiveSection = function (link) { | |
| 658 | + sections.forEach(function (section) { | |
| 659 | + section.link.classList.toggle('is-active', section.link === link); | |
| 660 | + }); | |
| 661 | + }; | |
| 662 | + | |
| 663 | + var syncSpy = function () { | |
| 664 | + spyFrame = null; | |
| 665 | + | |
| 666 | + // A click owns the highlight until its smooth scroll settles. | |
| 667 | + if (Date.now() < spyLockedUntil) { | |
| 668 | + return; | |
| 669 | + } | |
| 670 | + | |
| 671 | + var scrollable = document.documentElement.scrollHeight - window.innerHeight; | |
| 672 | + var current = sections[0]; | |
| 673 | + | |
| 674 | + if (scrollable > 4 && window.pageYOffset >= scrollable - 2) { | |
| 675 | + current = sections[sections.length - 1]; | |
| 676 | + } else { | |
| 677 | + sections.forEach(function (section) { | |
| 678 | + if (section.panel.getBoundingClientRect().top <= SPY_OFFSET) { | |
| 679 | + current = section; | |
| 680 | + } | |
| 681 | + }); | |
| 682 | + } | |
| 683 | + | |
| 684 | + setActiveSection(current.link); | |
| 685 | + }; | |
| 686 | + | |
| 687 | + var queueSpy = function () { | |
| 688 | + if (spyFrame === null) { | |
| 689 | + spyFrame = window.requestAnimationFrame(syncSpy); | |
| 690 | + } | |
| 691 | + }; | |
| 692 | + | |
| 693 | + sections.forEach(function (section) { | |
| 694 | + section.link.addEventListener('click', function (event) { | |
| 695 | + event.preventDefault(); | |
| 696 | + setActiveSection(section.link); | |
| 697 | + spyLockedUntil = Date.now() + 700; | |
| 698 | + section.panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| 699 | + }); | |
| 700 | + }); | |
| 701 | + | |
| 702 | + window.addEventListener('scroll', queueSpy, { passive: true }); | |
| 703 | + window.addEventListener('resize', queueSpy); | |
| 704 | + syncSpy(); | |
| 705 | + } | |
| 706 | + | |
| 707 | + syncRadioCards(); | |
| 708 | + })(); | |
| 709 | +})(); | |