PageSourceSearch

https://spacewaves2.net/template/default/js/spacewaves_favorites.js

js spacewaves2.net collected 2026-09-26 03:39:18 UTC 27,410 bytes, 783 lines download raw bytes

1(function () {
2  'use strict';
3
4  var STORAGE_KEY = 'spacewaves_favorites';
5  var HOME_SLUG = 'spacewaves-home';
6  var HOME_THUMB = 'https://cdn.spacewaves2.net/images/spacewaves.webp';
7  var menu = document.getElementById('game-favorites-menu');
8  var trigger = document.getElementById('game-favorites-trigger');
9  var dropdown = document.getElementById('game-favorites-dropdown');
10  var badge = document.getElementById('game-favorites-badge');
11  var closeTimer = null;
12  var toastTimer = null;
13  var toastEl = null;
14  var reorderBusy = false;
15  var hoverCloseTimer = null;
16
17  function read() {
18    try {
19      var raw = localStorage.getItem(STORAGE_KEY);
20      if (!raw) return [];
21      var data = JSON.parse(raw);
22      return Array.isArray(data) ? data : [];
23    } catch (e) {
24      return [];
25    }
26  }
27
28  function write(list) {
29    try {
30      localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
31    } catch (e) { /* ignore */ }
32    syncTriggerBadge();
33    document.dispatchEvent(new CustomEvent('spacewaves-favorites-change'));
34  }
35
36  function normalizeItem(item) {
37    if (!item || !item.slug) return null;
38    return {
39      slug: String(item.slug),
40      name: String(item.name || item.slug),
41      url: String(item.url || '/'),
42      image: item.image ? String(item.image) : '',
43      addedAt: item.addedAt || Date.now()
44    };
45  }
46
47  function isFavorite(slug) {
48    if (!slug) return false;
49    return read().some(function (g) { return g.slug === slug; });
50  }
51
52  function toggle(item) {
53    var norm = normalizeItem(item);
54    if (!norm) return false;
55    var list = read();
56    var idx = list.findIndex(function (g) { return g.slug === norm.slug; });
57    if (idx >= 0) {
58      list.splice(idx, 1);
59      write(list);
60      showToast(menu && menu.getAttribute('data-removed-label'));
61      return false;
62    }
63    list.push(norm);
64    write(list);
65    showToast(menu && menu.getAttribute('data-added-label'));
66    return true;
67  }
68
69  function remove(slug, silent) {
70    if (!slug) return;
71    write(read().filter(function (g) { return g.slug !== slug; }));
72    if (!silent) {
73      showToast(menu && menu.getAttribute('data-removed-label'));
74    }
75  }
76
77  function move(slug, delta) {
78    var list = read();
79    var idx = list.findIndex(function (g) { return g.slug === slug; });
80    if (idx < 0) return false;
81    var next = idx + delta;
82    if (next < 0 || next >= list.length) return false;
83    var tmp = list[idx];
84    list[idx] = list[next];
85    list[next] = tmp;
86    write(list);
87    return true;
88  }
89
90  function syncMoveButtons(listWrap) {
91    if (!listWrap) return;
92    var rows = listWrap.querySelectorAll('.game-favorites__item');
93    rows.forEach(function (row, index) {
94      var upBtn = row.querySelector('.fav-list__act--up');
95      if (upBtn) upBtn.disabled = index === 0;
96    });
97  }
98
99  /** Swap two rows with FLIP so the panel does not look like a full refresh. */
100  function animateReorder(slug, delta) {
101    if (reorderBusy || !dropdown || delta !== -1) {
102      if (move(slug, delta) && isDropdownOpen()) renderDropdown({ skipEnter: true });
103      return;
104    }
105    var listWrap = dropdown.querySelector('.game-favorites__list');
106    if (!listWrap) {
107      if (move(slug, delta)) renderDropdown({ skipEnter: true });
108      return;
109    }
110    listWrap.classList.remove('is-entering');
111
112    var rows = Array.prototype.slice.call(listWrap.children);
113    var idx = -1;
114    for (var i = 0; i < rows.length; i++) {
115      if (rows[i].getAttribute('data-slug') === slug) {
116        idx = i;
117        break;
118      }
119    }
120    if (idx <= 0) return;
121
122    var moving = rows[idx];
123    var above = rows[idx - 1];
124    var firstMoving = moving.getBoundingClientRect();
125    var firstAbove = above.getBoundingClientRect();
126
127    if (!move(slug, delta)) return;
128
129    listWrap.insertBefore(moving, above);
130    syncMoveButtons(listWrap);
131
132    var lastMoving = moving.getBoundingClientRect();
133    var lastAbove = above.getBoundingClientRect();
134    var dyMoving = firstMoving.top - lastMoving.top;
135    var dyAbove = firstAbove.top - lastAbove.top;
136
137    if (Math.abs(dyMoving) < 1 && Math.abs(dyAbove) < 1) return;
138
139    reorderBusy = true;
140    listWrap.classList.add('game-favorites__list--reordering');
141    moving.classList.add('is-swapping');
142    above.classList.add('is-swapping');
143
144    moving.style.transition = 'none';
145    above.style.transition = 'none';
146    moving.style.transform = 'translate3d(0,' + dyMoving + 'px,0)';
147    above.style.transform = 'translate3d(0,' + dyAbove + 'px,0)';
148
149    requestAnimationFrame(function () {
150      requestAnimationFrame(function () {
151        var ease = 'transform .28s cubic-bezier(.22,.7,.25,1)';
152        moving.style.transition = ease;
153        above.style.transition = ease;
154        moving.style.transform = 'translate3d(0,0,0)';
155        above.style.transform = 'translate3d(0,0,0)';
156
157        var done = false;
158        function finish() {
159          if (done) return;
160          done = true;
161          moving.style.transition = 'none';
162          above.style.transition = 'none';
163          moving.style.transform = '';
164          above.style.transform = '';
165          moving.classList.remove('is-swapping');
166          above.classList.remove('is-swapping');
167          listWrap.classList.remove('game-favorites__list--reordering');
168          /* Force reflow once with overflow still stable before unlocking. */
169          void listWrap.offsetHeight;
170          reorderBusy = false;
171        }
172        moving.addEventListener('transitionend', function onEnd(e) {
173          if (e.propertyName && e.propertyName !== 'transform') return;
174          moving.removeEventListener('transitionend', onEnd);
175          finish();
176        });
177        setTimeout(finish, 360);
178      });
179    });
180  }
181
182  function thumbFor(item) {
183    if (!item) return '';
184    if (item.slug === HOME_SLUG) {
185      var homeThumb = (menu && menu.getAttribute('data-home-thumb')) || item.image || HOME_THUMB;
186      if (!homeThumb || /\/images\/ico\.webp(?:\?|$)/.test(homeThumb)) {
187        return HOME_THUMB;
188      }
189      return homeThumb;
190    }
191    if (item.image) return item.image;
192    if (item.url && /\/geometry-games\//.test(item.url)) {
193      return '/geometry-games/' + encodeURIComponent(item.slug) + '/200x200.webp';
194    }
195    if (item.slug) return '/wave-games/' + encodeURIComponent(item.slug) + '/200x200.webp';
196    return '';
197  }
198
199  function syncTriggerBadge() {
200    if (!badge) return;
201    var n = read().length;
202    if (n > 0) {
203      badge.textContent = n > 99 ? '99+' : String(n);
204      badge.hidden = false;
205      badge.setAttribute('aria-hidden', 'false');
206      if (trigger) trigger.classList.add('game-favorites__trigger--has-items');
207    } else {
208      badge.hidden = true;
209      badge.setAttribute('aria-hidden', 'true');
210      if (trigger) trigger.classList.remove('game-favorites__trigger--has-items');
211    }
212  }
213
214  function isDropdownOpen() {
215    return !!(trigger && trigger.classList.contains('game-favorites__trigger--open'));
216  }
217
218  function positionDropdownMobile() {
219    if (!dropdown) return;
220    if (window.innerWidth > 900) {
221      dropdown.style.top = '';
222      dropdown.style.left = '';
223      dropdown.style.right = '';
224      dropdown.style.width = '';
225      return;
226    }
227    var header = document.querySelector('.site-header, header');
228    var top = 64;
229    if (header) {
230      top = Math.round(header.getBoundingClientRect().bottom + 8);
231    }
232    dropdown.style.top = top + 'px';
233    dropdown.style.left = '12px';
234    dropdown.style.right = '12px';
235    dropdown.style.width = 'auto';
236  }
237
238  function isHoverDevice() {
239    try {
240      return window.matchMedia('(hover: hover) and (pointer: fine)').matches;
241    } catch (e) {
242      return true;
243    }
244  }
245
246  function clearHoverClose() {
247    if (hoverCloseTimer) {
248      clearTimeout(hoverCloseTimer);
249      hoverCloseTimer = null;
250    }
251  }
252
253  function scheduleHoverClose() {
254    clearHoverClose();
255    hoverCloseTimer = setTimeout(function () {
256      hoverCloseTimer = null;
257      if (isHoverDevice()) closeDropdown();
258    }, 160);
259  }
260
261  function setDropdownOpen(open) {
262    if (!dropdown || !trigger) return;
263    if (closeTimer) {
264      clearTimeout(closeTimer);
265      closeTimer = null;
266    }
267    if (open) {
268      clearHoverClose();
269      if (window.SpaceWavesSearchClose) window.SpaceWavesSearchClose();
270      dropdown.removeAttribute('hidden');
271      trigger.classList.add('game-favorites__trigger--open');
272      trigger.setAttribute('aria-expanded', 'true');
273      renderDropdown();
274      positionDropdownMobile();
275      requestAnimationFrame(function () {
276        requestAnimationFrame(function () {
277          dropdown.classList.add('game-favorites__dropdown--open');
278        });
279      });
280    } else {
281      dropdown.classList.remove('game-favorites__dropdown--open');
282      trigger.classList.remove('game-favorites__trigger--open');
283      trigger.setAttribute('aria-expanded', 'false');
284      closeTimer = setTimeout(function () {
285        if (!isDropdownOpen()) {
286          dropdown.setAttribute('hidden', '');
287        }
288        closeTimer = null;
289      }, 240);
290    }
291  }
292
293  function closeDropdown() {
294    setDropdownOpen(false);
295  }
296
297  function svgHeartOutline() {
298    var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
299    svg.setAttribute('viewBox', '0 0 24 24');
300    svg.setAttribute('aria-hidden', 'true');
301    var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
302    path.setAttribute('fill', 'none');
303    path.setAttribute('stroke', 'currentColor');
304    path.setAttribute('stroke-width', '2');
305    path.setAttribute('d', 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z');
306    svg.appendChild(path);
307    return svg;
308  }
309
310  function svgIconUp() {
311    var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
312    svg.setAttribute('viewBox', '0 0 24 24');
313    svg.setAttribute('width', '16');
314    svg.setAttribute('height', '16');
315    svg.setAttribute('aria-hidden', 'true');
316    var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
317    path.setAttribute('fill', 'currentColor');
318    path.setAttribute('d', 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z');
319    svg.appendChild(path);
320    return svg;
321  }
322
323  function svgIconTrash() {
324    var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
325    svg.setAttribute('viewBox', '0 0 24 24');
326    svg.setAttribute('width', '15');
327    svg.setAttribute('height', '15');
328    svg.setAttribute('fill', 'none');
329    svg.setAttribute('stroke', 'currentColor');
330    svg.setAttribute('stroke-width', '2');
331    svg.setAttribute('stroke-linecap', 'round');
332    svg.setAttribute('stroke-linejoin', 'round');
333    svg.setAttribute('aria-hidden', 'true');
334    function line(x1, y1, x2, y2) {
335      var el = document.createElementNS('http://www.w3.org/2000/svg', 'line');
336      el.setAttribute('x1', x1);
337      el.setAttribute('y1', y1);
338      el.setAttribute('x2', x2);
339      el.setAttribute('y2', y2);
340      return el;
341    }
342    var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
343    path.setAttribute('d', 'M3 6h18');
344    var path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
345    path2.setAttribute('d', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2');
346    var path3 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
347    path3.setAttribute('d', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6');
348    svg.appendChild(path);
349    svg.appendChild(path2);
350    svg.appendChild(path3);
351    svg.appendChild(line(10, 11, 10, 17));
352    svg.appendChild(line(14, 11, 14, 17));
353    return svg;
354  }
355
356  function labelAttr(name, fallback) {
357    var value = menu && menu.getAttribute(name);
358    return (value && value.trim()) || fallback;
359  }
360
361  function siteHost() {
362    return (location.hostname || '').replace(/^www\./i, '') || 'spacewaves2.net';
363  }
364
365  /* Inline SVG on purpose: no icon font or icon-only stylesheet on this site. */
366  function installDesktopSvg() {
367    return (
368      '<svg class="fav-menu-actions__icon fav-menu-actions__icon--install" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">' +
369      '<path fill="currentColor" d="M20 17H4V5h8V3H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h4v2h8v-2h4a2 2 0 0 0 2-2v-5h-2v5zM17 14l5-5-1.41-1.41L18 10.17V3h-2v7.17l-2.59-2.58L12 9l5 5z"/>' +
370      '</svg>'
371    );
372  }
373
374  function googleGSvg() {
375    return (
376      '<svg class="fav-menu-actions__icon fav-menu-actions__icon--google" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">' +
377      '<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>' +
378      '<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>' +
379      '<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>' +
380      '<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>' +
381      '</svg>'
382    );
383  }
384
385  /* Only the installable check — never the banner's 30-day dismiss key. */
386  function canShowInstallAction() {
387    if (typeof window.SpaceWavesPwaCanInstall === 'function') return window.SpaceWavesPwaCanInstall();
388    /* Before the idle PWA script loads: honor the early-captured BIP from the bootstrap */
389    try {
390      if (window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true) {
391        return false;
392      }
393    } catch (e) {}
394    return !!window.__SPACEWAVES_PWA_DEFERRED;
395  }
396
397  function syncInstallActionVisibility() {
398    var installBtn = document.getElementById('fav-action-install');
399    if (installBtn) installBtn.hidden = !canShowInstallAction();
400  }
401
402  var installActionBound = false;
403  function bindInstallActionEvents() {
404    if (installActionBound) return;
405    installActionBound = true;
406    document.addEventListener('spacewaves-pwa-installable', syncInstallActionVisibility);
407    document.addEventListener('spacewaves-pwa-installed', syncInstallActionVisibility);
408    syncInstallActionVisibility();
409  }
410
411  /* The install script itself loads at idle, so listen after it has had a chance
412     to run instead of hooking beforeinstallprompt here. */
413  function scheduleInstallActionBind() {
414    if ('requestIdleCallback' in window) {
415      requestIdleCallback(bindInstallActionEvents, { timeout: 4000 });
416    } else {
417      setTimeout(bindInstallActionEvents, 2000);
418    }
419  }
420
421  function triggerPwaInstall() {
422    if (!canShowInstallAction()) return;
423    if (typeof window.SpaceWavesPwaInstall === 'function') {
424      window.SpaceWavesPwaInstall();
425      closeDropdown();
426      return;
427    }
428    /* PWA script not ready yet — wait briefly then retry */
429    var tries = 0;
430    var timer = setInterval(function () {
431      tries += 1;
432      if (typeof window.SpaceWavesPwaInstall === 'function') {
433        clearInterval(timer);
434        window.SpaceWavesPwaInstall();
435        closeDropdown();
436      } else if (tries >= 20) {
437        clearInterval(timer);
438      }
439    }, 100);
440  }
441
442  /* A temporary anchor is the only reliable way to keep rel on a new tab. */
443  function openGooglePreferred() {
444    var a = document.createElement('a');
445    a.href = 'https://www.google.com/preferences/source?q=' + encodeURIComponent(siteHost());
446    a.target = '_blank';
447    a.rel = 'nofollow noreferrer noopener';
448    a.style.display = 'none';
449    document.body.appendChild(a);
450    a.click();
451    a.remove();
452    closeDropdown();
453  }
454
455  function buildActionsFooter() {
456    var footer = document.createElement('div');
457    footer.className = 'fav-menu-actions';
458    footer.setAttribute('role', 'group');
459    footer.setAttribute('aria-label', labelAttr('data-quick-actions-label', 'Quick actions'));
460
461    var installBtn = document.createElement('button');
462    installBtn.type = 'button';
463    installBtn.id = 'fav-action-install';
464    installBtn.className = 'fav-menu-actions__btn';
465    installBtn.innerHTML = installDesktopSvg() + '<span class="fav-menu-actions__label"></span>';
466    installBtn.querySelector('.fav-menu-actions__label').textContent = labelAttr('data-install-app-label', 'Install to Desktop');
467    installBtn.hidden = true;
468    installBtn.addEventListener('click', function (e) {
469      e.preventDefault();
470      e.stopPropagation();
471      triggerPwaInstall();
472    });
473
474    var googleBtn = document.createElement('button');
475    googleBtn.type = 'button';
476    googleBtn.id = 'fav-action-google';
477    googleBtn.className = 'fav-menu-actions__btn';
478    googleBtn.innerHTML = googleGSvg() + '<span class="fav-menu-actions__label"></span>';
479    googleBtn.querySelector('.fav-menu-actions__label').textContent = labelAttr('data-add-preferred-google-label', 'Add preferred on Google');
480    googleBtn.addEventListener('click', function (e) {
481      e.preventDefault();
482      e.stopPropagation();
483      openGooglePreferred();
484    });
485
486    footer.appendChild(installBtn);
487    footer.appendChild(googleBtn);
488    return footer;
489  }
490
491  /* The footer must survive every re-render, so drop only the rendered rows
492     instead of wiping the whole dropdown. */
493  function clearRenderedList() {
494    if (!dropdown) return;
495    var stale = dropdown.querySelectorAll('.game-favorites__panel-head,.game-favorites__list,.game-favorites__empty-state');
496    Array.prototype.forEach.call(stale, function (node) { node.remove(); });
497  }
498
499  function ensureActionsFooter() {
500    if (!dropdown) return null;
501    var footer = dropdown.querySelector('.fav-menu-actions');
502    if (!footer) {
503      footer = buildActionsFooter();
504      dropdown.appendChild(footer);
505      syncInstallActionVisibility();
506    }
507    return footer;
508  }
509
510  function buildPanelHead(count) {
511    var head = document.createElement('div');
512    head.className = 'game-favorites__panel-head';
513    var title = document.createElement('span');
514    title.className = 'game-favorites__panel-title';
515    title.textContent = (menu && menu.getAttribute('data-panel-label')) || 'My favorites';
516    var countEl = document.createElement('span');
517    countEl.className = 'game-favorites__panel-count';
518    countEl.textContent = String(count);
519    head.appendChild(title);
520    head.appendChild(countEl);
521    return head;
522  }
523
524  function buildEmptyState() {
525    var wrap = document.createElement('div');
526    wrap.className = 'game-favorites__empty-state';
527    wrap.setAttribute('role', 'status');
528    var iconWrap = document.createElement('div');
529    iconWrap.className = 'game-favorites__empty-icon';
530    iconWrap.appendChild(svgHeartOutline());
531    var title = document.createElement('p');
532    title.className = 'game-favorites__empty-title';
533    title.textContent = (menu && menu.getAttribute('data-empty-label')) || 'No favorites yet';
534    var hint = document.createElement('p');
535    hint.className = 'game-favorites__empty-hint';
536    hint.textContent = (menu && menu.getAttribute('data-empty-hint')) || 'Tap the heart under any game player to save it here.';
537    wrap.appendChild(iconWrap);
538    wrap.appendChild(title);
539    wrap.appendChild(hint);
540    return wrap;
541  }
542
543  function renderDropdown(opts) {
544    if (!dropdown || !menu) return;
545    opts = opts || {};
546    var list = read();
547    var removeLabel = menu.getAttribute('data-remove-label') || 'Remove';
548    var moveUpLabel = menu.getAttribute('data-move-up-label') || 'Move up';
549    var withEnter = !opts.skipEnter;
550    var footer = ensureActionsFooter();
551    clearRenderedList();
552
553    if (!list.length) {
554      dropdown.insertBefore(buildEmptyState(), footer);
555      return;
556    }
557
558    dropdown.insertBefore(buildPanelHead(list.length), footer);
559    var listWrap = document.createElement('div');
560    listWrap.className = 'game-favorites__list' + (withEnter ? ' is-entering' : '');
561    var frag = document.createDocumentFragment();
562
563    list.forEach(function (item, index) {
564      var row = document.createElement('div');
565      row.className = 'game-favorites__item fav-list__item';
566      row.setAttribute('data-slug', item.slug);
567      row.setAttribute('role', 'option');
568      if (withEnter) {
569        row.style.animationDelay = (index * 0.04) + 's';
570      }
571
572      var link = document.createElement('a');
573      link.className = 'game-favorites__item-link nav-search__opt';
574      link.href = item.url;
575      link.title = item.name;
576
577      var img = document.createElement('img');
578      img.src = thumbFor(item);
579      img.alt = '';
580      img.width = 28;
581      img.height = 28;
582      img.decoding = 'async';
583      img.onerror = function () {
584        if (item.slug === HOME_SLUG && img.getAttribute('src') !== HOME_THUMB) {
585          img.src = HOME_THUMB;
586          return;
587        }
588        img.removeAttribute('src');
589        img.style.display = 'none';
590      };
591
592      var title = document.createElement('span');
593      title.textContent = item.name;
594      link.appendChild(img);
595      link.appendChild(title);
596      link.addEventListener('click', function () { closeDropdown(); });
597
598      var actions = document.createElement('div');
599      actions.className = 'fav-list__actions';
600
601      var upBtn = document.createElement('button');
602      upBtn.type = 'button';
603      upBtn.className = 'fav-list__act fav-list__act--up';
604      upBtn.setAttribute('aria-label', moveUpLabel);
605      upBtn.disabled = index === 0;
606      upBtn.appendChild(svgIconUp());
607      upBtn.addEventListener('click', function (e) {
608        e.preventDefault();
609        e.stopPropagation();
610        upBtn.blur();
611        animateReorder(item.slug, -1);
612      });
613
614      var rmBtn = document.createElement('button');
615      rmBtn.type = 'button';
616      rmBtn.className = 'fav-list__act fav-list__act--remove';
617      rmBtn.setAttribute('aria-label', removeLabel);
618      rmBtn.appendChild(svgIconTrash());
619      rmBtn.addEventListener('click', function (e) {
620        e.preventDefault();
621        e.stopPropagation();
622        if (reorderBusy) return;
623        row.classList.add('is-removing');
624        setTimeout(function () {
625          remove(item.slug, false);
626          renderDropdown({ skipEnter: true });
627          syncFavoriteButtons();
628        }, 200);
629      });
630
631      actions.appendChild(upBtn);
632      actions.appendChild(rmBtn);
633      row.appendChild(link);
634      row.appendChild(actions);
635      frag.appendChild(row);
636    });
637
638    listWrap.appendChild(frag);
639    dropdown.insertBefore(listWrap, footer);
640
641    if (withEnter) {
642      var enterMs = 280 + Math.max(0, list.length - 1) * 40 + 40;
643      setTimeout(function () {
644        if (listWrap.parentNode) listWrap.classList.remove('is-entering');
645      }, enterMs);
646    }
647  }
648
649  function showToast(message) {
650    if (!message) return;
651    if (!toastEl) {
652      toastEl = document.createElement('div');
653      toastEl.className = 'game-favorites-toast';
654      toastEl.setAttribute('role', 'status');
655      toastEl.setAttribute('aria-live', 'polite');
656      var icon = document.createElement('span');
657      icon.className = 'game-favorites-toast__icon';
658      icon.appendChild(svgHeartOutline());
659      var text = document.createElement('span');
660      text.className = 'game-favorites-toast__text';
661      toastEl.appendChild(icon);
662      toastEl.appendChild(text);
663      document.body.appendChild(toastEl);
664    }
665    var textEl = toastEl.querySelector('.game-favorites-toast__text');
666    if (textEl) textEl.textContent = message;
667    toastEl.classList.add('game-favorites-toast--show');
668    if (toastTimer) clearTimeout(toastTimer);
669    toastTimer = setTimeout(function () {
670      if (toastEl) toastEl.classList.remove('game-favorites-toast--show');
671      toastTimer = null;
672    }, 2200);
673  }
674
675  function syncFavoriteButtons() {
676    document.querySelectorAll('.game-favorite-btn[data-favorite-slug]').forEach(function (btn) {
677      var slug = btn.getAttribute('data-favorite-slug');
678      var on = isFavorite(slug);
679      btn.classList.toggle('is-favorited', on);
680      btn.setAttribute('aria-pressed', on ? 'true' : 'false');
681      var label = btn.querySelector('.game-favorite-btn__label');
682      if (label) {
683        label.textContent = on
684          ? (btn.getAttribute('data-label-remove') || 'favorited')
685          : (btn.getAttribute('data-label-add') || 'favorite');
686      }
687      var hint = on
688        ? (btn.getAttribute('data-label-remove-hint') || btn.getAttribute('data-label-remove') || '')
689        : (btn.getAttribute('data-label-add-hint') || btn.getAttribute('data-label-add') || '');
690      if (hint) {
691        btn.setAttribute('title', hint);
692        btn.setAttribute('aria-label', hint);
693      }
694    });
695  }
696
697  function bindFavoriteButtons() {
698    document.querySelectorAll('.game-favorite-btn[data-favorite-slug]').forEach(function (btn) {
699      if (btn._swFavBound) return;
700      btn._swFavBound = true;
701      btn.addEventListener('click', function () {
702        var item = {
703          slug: btn.getAttribute('data-favorite-slug'),
704          name: btn.getAttribute('data-favorite-name'),
705          url: btn.getAttribute('data-favorite-url'),
706          image: btn.getAttribute('data-favorite-image') || ''
707        };
708        toggle(item);
709        syncFavoriteButtons();
710        if (isDropdownOpen()) renderDropdown();
711      });
712    });
713    syncFavoriteButtons();
714  }
715
716  if (trigger && dropdown && menu) {
717    /* Desktop: open on hover. Mobile: open on tap. */
718    menu.addEventListener('mouseenter', function () {
719      if (!isHoverDevice()) return;
720      clearHoverClose();
721      setDropdownOpen(true);
722    });
723    menu.addEventListener('mouseleave', function () {
724      if (!isHoverDevice()) return;
725      scheduleHoverClose();
726    });
727
728    trigger.addEventListener('click', function (e) {
729      e.stopPropagation();
730      if (isHoverDevice()) {
731        /* Already opened by hover; keep open, don't toggle shut. */
732        clearHoverClose();
733        if (!isDropdownOpen()) setDropdownOpen(true);
734        return;
735      }
736      setDropdownOpen(!isDropdownOpen());
737    });
738
739    document.addEventListener('mousedown', function (e) {
740      if (!menu || menu.contains(e.target)) return;
741      closeDropdown();
742    });
743    document.addEventListener('touchstart', function (e) {
744      if (!menu || menu.contains(e.target)) return;
745      closeDropdown();
746    }, { passive: true });
747    var menuToggle = document.getElementById('nav-toggle');
748    if (menuToggle) {
749      menuToggle.addEventListener('click', function () {
750        closeDropdown();
751      });
752    }
753    document.addEventListener('keydown', function (e) {
754      if (e.key === 'Escape') closeDropdown();
755    });
756    window.addEventListener('resize', function () {
757      if (isDropdownOpen()) positionDropdownMobile();
758    });
759  }
760
761  document.addEventListener('spacewaves-favorites-change', syncFavoriteButtons);
762
763  function init() {
764    syncTriggerBadge();
765    bindFavoriteButtons();
766    scheduleInstallActionBind();
767  }
768
769  if (document.readyState === 'loading') {
770    document.addEventListener('DOMContentLoaded', init);
771  } else {
772    init();
773  }
774
775  window.SpaceWavesFavorites = {
776    read: read,
777    toggle: toggle,
778    remove: remove,
779    move: move,
780    isFavorite: isFavorite,
781    closeDropdown: closeDropdown
782  };
783})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.