PluginProbe
GutSlider – All in One Slider and Carousel Blocks for Gutenberg / 3.2.0
GutSlider – All in One Slider and Carousel Blocks for Gutenberg v3.2.0
3.2.0 3.1.0 3.0.0 2.13.2 2.13.1 2.13.0 trunk 1.0.0 2.1.0 2.10.0 2.10.1 2.11.0 2.11.1 2.11.2 2.11.3 2.11.4 2.12.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.1 2.5.3 2.5.4 2.5.5 All 57 releases
← All changes | admin/js/admin.js +678 -82 2.11.3 → 3.2.0 View file →
@@ -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 +})();