1123 lines
39 KiB
JavaScript
1123 lines
39 KiB
JavaScript
// Nova toolbar interactions
|
|
// - dropdown menus via [data-dropdown]
|
|
// - mobile menu toggle via [data-mobile-toggle] + #mobileMenu
|
|
|
|
// Alpine.js — powers x-data/x-show/@click in Blade layouts (e.g. cookie banner, toasts).
|
|
// Guard: don't start a second instance if app.js already loaded Alpine on this page.
|
|
import Alpine from 'alpinejs';
|
|
import React from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
if (!window.Alpine) {
|
|
window.Alpine = Alpine;
|
|
Alpine.start();
|
|
}
|
|
|
|
// Gallery navigation context: stores artwork list for prev/next on artwork page
|
|
import './lib/nav-context.js';
|
|
import { sendTagInteractionEvent } from './lib/tagAnalytics';
|
|
|
|
function safeParseJson(value, fallback) {
|
|
try {
|
|
return JSON.parse(value || 'null') ?? fallback;
|
|
} catch (_error) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function mountStoryEditor() {
|
|
var storyEditorRoot = document.getElementById('story-editor-react-root');
|
|
if (!storyEditorRoot) return;
|
|
if (storyEditorRoot.dataset.reactMounted === 'true') return;
|
|
|
|
var mode = storyEditorRoot.getAttribute('data-mode') || 'create';
|
|
var storyRaw = storyEditorRoot.getAttribute('data-story') || '{}';
|
|
var storyTypesRaw = storyEditorRoot.getAttribute('data-story-types') || '[]';
|
|
var endpointsRaw = storyEditorRoot.getAttribute('data-endpoints') || '{}';
|
|
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
|
|
|
var initialStory = {};
|
|
var storyTypes = [];
|
|
var endpoints = {};
|
|
|
|
try {
|
|
initialStory = JSON.parse(storyRaw);
|
|
storyTypes = JSON.parse(storyTypesRaw);
|
|
endpoints = JSON.parse(endpointsRaw);
|
|
} catch (_error) {
|
|
// If parsing fails, the editor falls back to component defaults.
|
|
}
|
|
|
|
storyEditorRoot.dataset.reactMounted = 'true';
|
|
|
|
void import('./components/editor/StoryEditor')
|
|
.then(function (module) {
|
|
var StoryEditor = module.default;
|
|
createRoot(storyEditorRoot).render(
|
|
React.createElement(StoryEditor, {
|
|
mode: mode,
|
|
initialStory: initialStory,
|
|
storyTypes: storyTypes,
|
|
endpoints: endpoints,
|
|
csrfToken: csrfToken,
|
|
})
|
|
);
|
|
})
|
|
.catch(function () {
|
|
storyEditorRoot.dataset.reactMounted = 'false';
|
|
storyEditorRoot.innerHTML = '<div class="rounded-xl border border-rose-700 bg-rose-900/20 p-4 text-rose-200">Failed to load editor. Please refresh the page.</div>';
|
|
});
|
|
}
|
|
|
|
mountStoryEditor();
|
|
|
|
function mountToolbarNotifications() {
|
|
var rootEl = document.getElementById('toolbar-notification-root');
|
|
if (!rootEl || rootEl.dataset.reactMounted === 'true') return;
|
|
|
|
var props = safeParseJson(rootEl.getAttribute('data-props'), {});
|
|
rootEl.dataset.reactMounted = 'true';
|
|
|
|
void import('./components/social/NotificationDropdown.jsx')
|
|
.then(function (module) {
|
|
var Component = module.default;
|
|
createRoot(rootEl).render(React.createElement(Component, props));
|
|
})
|
|
.catch(function () {
|
|
rootEl.dataset.reactMounted = 'false';
|
|
});
|
|
}
|
|
|
|
function mountStorySocial() {
|
|
var socialRoot = document.getElementById('story-social-root');
|
|
if (socialRoot && socialRoot.dataset.reactMounted !== 'true') {
|
|
var props = safeParseJson(socialRoot.getAttribute('data-props'), {});
|
|
socialRoot.dataset.reactMounted = 'true';
|
|
|
|
void import('./components/social/StorySocialPanel.jsx')
|
|
.then(function (module) {
|
|
var Component = module.default;
|
|
createRoot(socialRoot).render(React.createElement(Component, {
|
|
story: props.story,
|
|
creator: props.creator,
|
|
initialState: props.state,
|
|
initialComments: props.comments,
|
|
isAuthenticated: Boolean(props.is_authenticated),
|
|
}));
|
|
})
|
|
.catch(function () {
|
|
socialRoot.dataset.reactMounted = 'false';
|
|
});
|
|
}
|
|
|
|
var followRoot = document.getElementById('story-creator-follow-root');
|
|
if (!followRoot || followRoot.dataset.reactMounted === 'true') return;
|
|
|
|
var followProps = safeParseJson(followRoot.getAttribute('data-props'), {});
|
|
followRoot.dataset.reactMounted = 'true';
|
|
|
|
void import('./components/social/FollowButton.jsx')
|
|
.then(function (module) {
|
|
var Component = module.default;
|
|
createRoot(followRoot).render(React.createElement(Component, {
|
|
username: followProps.username,
|
|
initialFollowing: Boolean(followProps.following),
|
|
initialCount: Number(followProps.followers_count || 0),
|
|
className: 'w-full justify-center',
|
|
}));
|
|
})
|
|
.catch(function () {
|
|
followRoot.dataset.reactMounted = 'false';
|
|
});
|
|
}
|
|
|
|
mountToolbarNotifications();
|
|
mountStorySocial();
|
|
|
|
function initStorySyntaxHighlighting() {
|
|
var codeBlocks = Array.prototype.slice.call(document.querySelectorAll('.story-prose pre code'));
|
|
if (!codeBlocks.length) return;
|
|
|
|
function fallbackCopyText(text) {
|
|
var textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.setAttribute('readonly', 'true');
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.top = '-1000px';
|
|
textarea.style.left = '-1000px';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
|
|
try {
|
|
return document.execCommand('copy');
|
|
} catch (_error) {
|
|
return false;
|
|
} finally {
|
|
document.body.removeChild(textarea);
|
|
}
|
|
}
|
|
|
|
function copyText(text) {
|
|
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
return navigator.clipboard.writeText(text);
|
|
}
|
|
|
|
return fallbackCopyText(text)
|
|
? Promise.resolve()
|
|
: Promise.reject(new Error('Clipboard unavailable'));
|
|
}
|
|
|
|
function attachCopyButton(block) {
|
|
var pre = block.parentElement;
|
|
if (!pre || pre.dataset.copyButtonMounted === 'true') return;
|
|
|
|
var button = document.createElement('button');
|
|
var icon = document.createElement('span');
|
|
var label = document.createElement('span');
|
|
|
|
button.type = 'button';
|
|
button.className = 'story-code-copy-button';
|
|
icon.className = 'story-code-copy-icon';
|
|
icon.setAttribute('aria-hidden', 'true');
|
|
icon.textContent = '⧉';
|
|
label.className = 'story-code-copy-label';
|
|
label.textContent = 'Copy';
|
|
button.appendChild(icon);
|
|
button.appendChild(label);
|
|
button.dataset.copied = 'idle';
|
|
button.setAttribute('aria-label', 'Copy code block');
|
|
|
|
var resetTimer = 0;
|
|
button.addEventListener('click', function () {
|
|
var source = block.innerText || block.textContent || '';
|
|
|
|
copyText(source)
|
|
.then(function () {
|
|
icon.textContent = '✓';
|
|
label.textContent = 'Copied';
|
|
button.dataset.copied = 'true';
|
|
})
|
|
.catch(function () {
|
|
icon.textContent = '!';
|
|
label.textContent = 'Failed';
|
|
button.dataset.copied = 'false';
|
|
})
|
|
.finally(function () {
|
|
window.clearTimeout(resetTimer);
|
|
resetTimer = window.setTimeout(function () {
|
|
icon.textContent = '⧉';
|
|
label.textContent = 'Copy';
|
|
button.dataset.copied = 'idle';
|
|
}, 1800);
|
|
});
|
|
});
|
|
|
|
pre.appendChild(button);
|
|
pre.dataset.copyButtonMounted = 'true';
|
|
}
|
|
|
|
void import('highlight.js/lib/common')
|
|
.then(function (module) {
|
|
var hljs = module.default;
|
|
|
|
codeBlocks.forEach(function (block) {
|
|
attachCopyButton(block);
|
|
|
|
if (block.dataset.syntaxHighlighted === 'true') return;
|
|
|
|
var language = (block.getAttribute('data-language') || '').trim();
|
|
if (language && !block.className.includes('language-')) {
|
|
block.classList.add('language-' + language);
|
|
}
|
|
|
|
hljs.highlightElement(block);
|
|
block.dataset.syntaxHighlighted = 'true';
|
|
});
|
|
})
|
|
.catch(function () {
|
|
// Leave code blocks readable even if highlighting fails to load.
|
|
});
|
|
}
|
|
|
|
initStorySyntaxHighlighting();
|
|
|
|
function initTagsSearchAssist() {
|
|
var roots = document.querySelectorAll('[data-tags-search-root]');
|
|
if (!roots.length) return;
|
|
|
|
var recentSearchesKey = 'skinbase.tags.recent-searches';
|
|
|
|
function findClosest(el, selector) {
|
|
while (el && el.nodeType === 1) {
|
|
if (el.matches(selector)) return el;
|
|
el = el.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
roots.forEach(function (root, rootIndex) {
|
|
var form = findClosest(root, '[data-tags-search-form]');
|
|
var input = root.querySelector('[data-tags-search-input]');
|
|
var panel = root.querySelector('[data-tags-search-panel]');
|
|
var title = root.querySelector('[data-tags-search-title]');
|
|
var results = root.querySelector('[data-tags-search-results]');
|
|
var endpoint = root.getAttribute('data-search-endpoint') || '/api/tags/search';
|
|
var popularEndpoint = root.getAttribute('data-popular-endpoint') || '/api/tags/popular';
|
|
var optionIdPrefix = 'tags-search-option-' + rootIndex + '-';
|
|
var debounceTimer = null;
|
|
var abortController = null;
|
|
var latestQuery = '';
|
|
var activeIndex = -1;
|
|
|
|
if (!input || !panel || !results) return;
|
|
|
|
function readRecentSearches() {
|
|
try {
|
|
var parsed = JSON.parse(window.localStorage.getItem(recentSearchesKey) || '[]');
|
|
return Array.isArray(parsed) ? parsed.filter(Boolean).slice(0, 5) : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function writeRecentSearch(query) {
|
|
var normalized = (query || '').trim();
|
|
if (!normalized) return;
|
|
|
|
var next = readRecentSearches().filter(function (item) {
|
|
return item.toLowerCase() !== normalized.toLowerCase();
|
|
});
|
|
|
|
next.unshift(normalized);
|
|
|
|
try {
|
|
window.localStorage.setItem(recentSearchesKey, JSON.stringify(next.slice(0, 5)));
|
|
} catch (_error) {
|
|
// Ignore storage failures silently.
|
|
}
|
|
}
|
|
|
|
function clearRecentSearches() {
|
|
try {
|
|
window.localStorage.removeItem(recentSearchesKey);
|
|
} catch (_error) {
|
|
// Ignore storage failures silently.
|
|
}
|
|
}
|
|
|
|
function removeRecentSearch(query) {
|
|
var normalized = (query || '').trim().toLowerCase();
|
|
if (!normalized) return;
|
|
|
|
var next = readRecentSearches().filter(function (item) {
|
|
return item.toLowerCase() !== normalized;
|
|
});
|
|
|
|
try {
|
|
window.localStorage.setItem(recentSearchesKey, JSON.stringify(next));
|
|
} catch (_error) {
|
|
// Ignore storage failures silently.
|
|
}
|
|
}
|
|
|
|
function getItems() {
|
|
return Array.prototype.slice.call(results.querySelectorAll('[data-tags-search-item]'));
|
|
}
|
|
|
|
function setActiveItem(nextIndex) {
|
|
var items = getItems();
|
|
activeIndex = nextIndex;
|
|
|
|
items.forEach(function (item, index) {
|
|
var active = index === nextIndex;
|
|
item.classList.toggle('bg-white/[0.06]', active);
|
|
item.classList.toggle('text-white', active);
|
|
item.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
if (!item.id) {
|
|
item.id = optionIdPrefix + index;
|
|
}
|
|
if (active) {
|
|
item.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
});
|
|
|
|
if (nextIndex >= 0 && items[nextIndex]) {
|
|
input.setAttribute('aria-activedescendant', items[nextIndex].id);
|
|
} else {
|
|
input.removeAttribute('aria-activedescendant');
|
|
}
|
|
}
|
|
|
|
function focusItem(nextIndex) {
|
|
var items = getItems();
|
|
if (!items.length) return;
|
|
|
|
var boundedIndex = Math.max(0, Math.min(nextIndex, items.length - 1));
|
|
setActiveItem(boundedIndex);
|
|
items[boundedIndex].focus();
|
|
}
|
|
|
|
function setExpanded(expanded) {
|
|
input.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
panel.classList.toggle('hidden', !expanded);
|
|
if (!expanded) {
|
|
activeIndex = -1;
|
|
setActiveItem(-1);
|
|
}
|
|
}
|
|
|
|
function clearResults() {
|
|
results.innerHTML = '';
|
|
activeIndex = -1;
|
|
}
|
|
|
|
function hidePanel() {
|
|
setExpanded(false);
|
|
}
|
|
|
|
function renderLoadingState(query) {
|
|
title.textContent = query ? 'Searching tags' : 'Loading suggestions';
|
|
results.setAttribute('aria-busy', 'true');
|
|
results.innerHTML = '<div class="space-y-2 px-2 py-2">'
|
|
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
|
|
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-24 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-16 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
|
|
+ '<span class="h-3 w-10 animate-pulse rounded bg-white/[0.06]"></span>'
|
|
+ '</div>'
|
|
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
|
|
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-28 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-20 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
|
|
+ '<span class="h-3 w-12 animate-pulse rounded bg-white/[0.06]"></span>'
|
|
+ '</div>'
|
|
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
|
|
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-20 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-14 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
|
|
+ '<span class="h-3 w-8 animate-pulse rounded bg-white/[0.06]"></span>'
|
|
+ '</div>'
|
|
+ '</div>';
|
|
setExpanded(true);
|
|
setActiveItem(-1);
|
|
}
|
|
|
|
function fetchJson(url, signal) {
|
|
return fetch(url, {
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
|
signal: signal,
|
|
}).then(function (response) {
|
|
if (!response.ok) throw new Error('Failed to load tag suggestions');
|
|
return response.json();
|
|
});
|
|
}
|
|
|
|
function renderRecentSearches(items) {
|
|
if (!items.length) return '';
|
|
|
|
return '<div class="px-2 pb-2">'
|
|
+ '<div class="flex items-center justify-between gap-3 px-2 pb-2 pt-1">'
|
|
+ '<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/30">Recent searches</div>'
|
|
+ '<button type="button" data-tags-search-clear-recent class="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/48 transition hover:bg-white/[0.08] hover:text-white">Clear</button>'
|
|
+ '</div>'
|
|
+ '<div class="flex flex-wrap gap-2 px-2 pb-2">'
|
|
+ items.map(function (item) {
|
|
var encoded = encodeURIComponent(item);
|
|
var escaped = escapeHtml(item);
|
|
return '<span class="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] pr-1 text-xs font-medium text-white/68 transition hover:bg-white/[0.08] hover:text-white">'
|
|
+ '<a href="/tags?q=' + encoded + '" data-tags-search-item data-tags-search-surface="recent_search" data-tags-search-query="' + escaped + '" role="option" aria-selected="false" class="rounded-full px-3 py-1.5">' + escaped + '</a>'
|
|
+ '<button type="button" data-tags-search-remove-recent data-tags-search-query="' + escaped + '" class="inline-flex h-6 w-6 items-center justify-center rounded-full text-white/38 transition hover:bg-black/20 hover:text-white" aria-label="Remove recent search ' + escaped + '">'
|
|
+ '×'
|
|
+ '</button>'
|
|
+ '</span>';
|
|
}).join('')
|
|
+ '</div>'
|
|
+ '</div>';
|
|
}
|
|
|
|
function renderRescueSuggestions(items, query) {
|
|
if (!items.length) return '';
|
|
|
|
return '<div class="border-t border-white/8 px-2 pt-3">'
|
|
+ '<div class="px-2 pb-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/30">Try these instead</div>'
|
|
+ '<div class="grid gap-1.5 px-1 pb-2">'
|
|
+ items.map(function (item, index) {
|
|
var name = escapeHtml(item.name || '');
|
|
var slug = escapeHtml(item.slug || '');
|
|
return '<a href="/tag/' + slug + '" data-tags-search-item data-tags-search-surface="rescue_suggestion" data-tags-search-tag="' + slug + '" data-tags-search-query="' + escapeHtml(query || '') + '" data-tags-search-position="' + (index + 1) + '" role="option" aria-selected="false" class="flex items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-sm text-white/72 transition hover:bg-white/[0.06] hover:text-white">'
|
|
+ '<span class="min-w-0"><span class="block truncate font-medium text-white">' + name + '</span><span class="block truncate text-xs text-white/34">#' + slug + '</span></span>'
|
|
+ '<span class="shrink-0 text-xs text-white/30">Popular</span>'
|
|
+ '</a>';
|
|
}).join('')
|
|
+ '</div>'
|
|
+ '<div class="px-2 pb-2"><a href="/tags" class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-sky-200 transition hover:text-white">Browse full tags index</a></div>'
|
|
+ '</div>';
|
|
}
|
|
|
|
function renderNoMatchState(query, rescueItems) {
|
|
title.textContent = 'No matching tags';
|
|
results.setAttribute('aria-busy', 'false');
|
|
results.innerHTML = '<div class="rounded-xl px-3 py-4 text-sm text-white/42">No direct matches for <span class="text-white">' + escapeHtml(query) + '</span>. Try a broader keyword or jump into one of these popular tags.</div>'
|
|
+ renderRescueSuggestions(rescueItems || [], query);
|
|
setExpanded(true);
|
|
setActiveItem(-1);
|
|
}
|
|
|
|
function renderItems(items, query) {
|
|
clearResults();
|
|
results.setAttribute('aria-busy', 'false');
|
|
|
|
var recentSearches = !query ? readRecentSearches() : [];
|
|
|
|
if (!items.length && recentSearches.length) {
|
|
title.textContent = 'Recent searches';
|
|
results.innerHTML = renderRecentSearches(recentSearches);
|
|
setExpanded(true);
|
|
setActiveItem(-1);
|
|
return;
|
|
}
|
|
|
|
if (!items.length) {
|
|
return;
|
|
}
|
|
|
|
title.textContent = query ? 'Matching tags' : (recentSearches.length ? 'Recent and popular' : 'Popular tags');
|
|
|
|
if (recentSearches.length) {
|
|
results.insertAdjacentHTML('beforeend', renderRecentSearches(recentSearches));
|
|
}
|
|
|
|
items.forEach(function (item, index) {
|
|
var link = document.createElement('a');
|
|
var itemName = escapeHtml(item.name || '');
|
|
var itemSlug = escapeHtml(item.slug || '');
|
|
var recentClicks = Number(item.recent_clicks || 0);
|
|
var metricLabel = recentClicks > 0
|
|
? recentClicks.toLocaleString() + ' recent'
|
|
: Number(item.usage_count || 0).toLocaleString() + ' uses';
|
|
link.href = '/tag/' + item.slug;
|
|
link.className = 'flex items-center justify-between gap-3 rounded-xl px-3 py-3 text-sm text-white/72 transition hover:bg-white/[0.06] hover:text-white';
|
|
link.setAttribute('data-tags-search-item', '');
|
|
link.setAttribute('data-tags-search-surface', 'search_suggestion');
|
|
link.setAttribute('data-tags-search-tag', item.slug || '');
|
|
link.setAttribute('data-tags-search-query', item.name || item.slug || '');
|
|
link.setAttribute('data-tags-search-input', query || '');
|
|
link.setAttribute('data-tags-search-position', String(index + 1));
|
|
link.setAttribute('aria-selected', 'false');
|
|
link.setAttribute('role', 'option');
|
|
link.tabIndex = -1;
|
|
link.innerHTML =
|
|
'<span class="flex min-w-0 items-center gap-3">'
|
|
+ '<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-white/[0.06] text-xs text-sky-200">#</span>'
|
|
+ '<span class="min-w-0"><span class="block truncate font-medium text-white">' + itemName + '</span><span class="block truncate text-xs text-white/36">#' + itemSlug + '</span></span>'
|
|
+ '</span>'
|
|
+ '<span class="shrink-0 text-xs text-white/34">' + metricLabel + '</span>';
|
|
results.appendChild(link);
|
|
});
|
|
|
|
setExpanded(true);
|
|
setActiveItem(-1);
|
|
}
|
|
|
|
function fetchSuggestions(query) {
|
|
if (abortController) abortController.abort();
|
|
abortController = new AbortController();
|
|
latestQuery = query;
|
|
renderLoadingState(query);
|
|
|
|
var params = new URLSearchParams();
|
|
if (query) params.set('q', query);
|
|
|
|
fetchJson(endpoint + (params.toString() ? ('?' + params.toString()) : ''), abortController.signal)
|
|
.then(function (payload) {
|
|
if (input.value.trim() !== latestQuery) return;
|
|
var items = Array.isArray(payload.data) ? payload.data.slice(0, 8) : [];
|
|
if (items.length || query === '') {
|
|
renderItems(items, query);
|
|
return;
|
|
}
|
|
|
|
var popularParams = new URLSearchParams();
|
|
popularParams.set('limit', '4');
|
|
|
|
return fetchJson(popularEndpoint + '?' + popularParams.toString(), abortController.signal)
|
|
.then(function (popularPayload) {
|
|
if (input.value.trim() !== latestQuery) return;
|
|
renderNoMatchState(query, Array.isArray(popularPayload.data) ? popularPayload.data : []);
|
|
});
|
|
})
|
|
.catch(function (error) {
|
|
if (error && error.name === 'AbortError') return;
|
|
hidePanel();
|
|
});
|
|
}
|
|
|
|
input.addEventListener('focus', function () {
|
|
fetchSuggestions(input.value.trim());
|
|
});
|
|
|
|
input.addEventListener('input', function () {
|
|
var query = input.value.trim();
|
|
window.clearTimeout(debounceTimer);
|
|
debounceTimer = window.setTimeout(function () {
|
|
fetchSuggestions(query);
|
|
}, 180);
|
|
});
|
|
|
|
input.addEventListener('keydown', function (event) {
|
|
var items = getItems();
|
|
|
|
if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && items.length) {
|
|
event.preventDefault();
|
|
if (event.key === 'ArrowDown') {
|
|
setActiveItem(activeIndex < 0 ? 0 : Math.min(activeIndex + 1, items.length - 1));
|
|
} else {
|
|
setActiveItem(activeIndex < 0 ? items.length - 1 : Math.max(activeIndex - 1, 0));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Enter' && activeIndex >= 0 && items[activeIndex]) {
|
|
event.preventDefault();
|
|
writeRecentSearch(items[activeIndex].getAttribute('data-tags-search-query') || input.value);
|
|
window.location.href = items[activeIndex].href;
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
hidePanel();
|
|
}
|
|
});
|
|
|
|
results.addEventListener('keydown', function (event) {
|
|
var items = getItems();
|
|
var currentIndex = items.indexOf(document.activeElement);
|
|
|
|
if (event.key === 'ArrowDown' && items.length) {
|
|
event.preventDefault();
|
|
focusItem(currentIndex + 1);
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'ArrowUp' && items.length) {
|
|
event.preventDefault();
|
|
if (currentIndex <= 0) {
|
|
setActiveItem(-1);
|
|
input.focus();
|
|
} else {
|
|
focusItem(currentIndex - 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
hidePanel();
|
|
input.focus();
|
|
}
|
|
});
|
|
|
|
results.addEventListener('mouseover', function (event) {
|
|
var item = findClosest(event.target, '[data-tags-search-item]');
|
|
if (!item) return;
|
|
var items = getItems();
|
|
setActiveItem(items.indexOf(item));
|
|
});
|
|
|
|
results.addEventListener('click', function (event) {
|
|
var clearButton = findClosest(event.target, '[data-tags-search-clear-recent]');
|
|
if (clearButton) {
|
|
event.preventDefault();
|
|
clearRecentSearches();
|
|
fetchSuggestions(input.value.trim());
|
|
return;
|
|
}
|
|
|
|
var removeButton = findClosest(event.target, '[data-tags-search-remove-recent]');
|
|
if (removeButton) {
|
|
event.preventDefault();
|
|
removeRecentSearch(removeButton.getAttribute('data-tags-search-query') || '');
|
|
fetchSuggestions(input.value.trim());
|
|
return;
|
|
}
|
|
|
|
var item = findClosest(event.target, '[data-tags-search-item]');
|
|
if (!item) return;
|
|
var query = item.getAttribute('data-tags-search-query') || item.textContent || '';
|
|
writeRecentSearch(query);
|
|
|
|
var surface = item.getAttribute('data-tags-search-surface') || '';
|
|
var tagSlug = item.getAttribute('data-tags-search-tag') || '';
|
|
|
|
if (surface && (tagSlug || surface === 'recent_search')) {
|
|
sendTagInteractionEvent({
|
|
event_type: 'click',
|
|
surface: surface,
|
|
tag_slug: tagSlug || null,
|
|
query: item.getAttribute('data-tags-search-input') || item.getAttribute('data-tags-search-query') || input.value.trim() || null,
|
|
position: Number(item.getAttribute('data-tags-search-position') || 0) || null,
|
|
occurred_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
});
|
|
|
|
if (form) {
|
|
form.addEventListener('submit', function () {
|
|
writeRecentSearch(input.value);
|
|
});
|
|
}
|
|
|
|
document.addEventListener('click', function (event) {
|
|
if (!root.contains(event.target)) {
|
|
hidePanel();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
initTagsSearchAssist();
|
|
|
|
function initTagAnalyticsLinks() {
|
|
document.addEventListener('click', function (event) {
|
|
var el = event.target;
|
|
|
|
while (el && el.nodeType === 1) {
|
|
if (el.matches('[data-tag-analytics-link]')) {
|
|
var tagSlug = el.getAttribute('data-tag-analytics-tag') || '';
|
|
var surface = el.getAttribute('data-tag-analytics-surface') || '';
|
|
|
|
if (tagSlug && surface) {
|
|
sendTagInteractionEvent({
|
|
event_type: 'click',
|
|
surface: surface,
|
|
tag_slug: tagSlug,
|
|
source_tag_slug: el.getAttribute('data-tag-analytics-source-tag') || null,
|
|
position: Number(el.getAttribute('data-tag-analytics-position') || 0) || null,
|
|
occurred_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
el = el.parentElement;
|
|
}
|
|
});
|
|
}
|
|
|
|
initTagAnalyticsLinks();
|
|
|
|
(function () {
|
|
function initBlurPreviewImages() {
|
|
var selector = 'img[data-blur-preview]';
|
|
|
|
function markLoaded(img) {
|
|
if (!img) return;
|
|
img.classList.remove('blur-sm', 'scale-[1.02]');
|
|
img.classList.add('is-loaded');
|
|
}
|
|
|
|
document.querySelectorAll(selector).forEach(function (img) {
|
|
if (img.complete && img.naturalWidth > 0) {
|
|
markLoaded(img);
|
|
return;
|
|
}
|
|
img.addEventListener('load', function () { markLoaded(img); }, { once: true });
|
|
img.addEventListener('error', function () { markLoaded(img); }, { once: true });
|
|
});
|
|
|
|
document.addEventListener('load', function (event) {
|
|
var target = event.target;
|
|
if (target && target.matches && target.matches(selector)) {
|
|
markLoaded(target);
|
|
}
|
|
}, true);
|
|
}
|
|
|
|
initBlurPreviewImages();
|
|
|
|
function closest(el, selector) {
|
|
while (el && el.nodeType === 1) {
|
|
if (el.matches(selector)) return el;
|
|
el = el.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function canHover() {
|
|
return window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
|
}
|
|
|
|
function setExpanded(toggleEl, expanded) {
|
|
if (!toggleEl) return;
|
|
toggleEl.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
}
|
|
|
|
function closeAllDropdowns(except) {
|
|
var dropdowns = document.querySelectorAll('[data-dropdown]');
|
|
dropdowns.forEach(function (dropdown) {
|
|
if (except && dropdown === except) return;
|
|
var menu = dropdown.querySelector('[data-dropdown-menu]');
|
|
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
|
|
if (menu) menu.classList.remove('is-open');
|
|
setExpanded(toggle, false);
|
|
|
|
// Close any submenus
|
|
dropdown.querySelectorAll('[data-submenu-menu]').forEach(function (sm) {
|
|
sm.classList.add('hidden');
|
|
});
|
|
dropdown.querySelectorAll('[data-submenu-toggle]').forEach(function (st) {
|
|
setExpanded(st, false);
|
|
});
|
|
});
|
|
}
|
|
|
|
function openDropdown(dropdown) {
|
|
var menu = dropdown.querySelector('[data-dropdown-menu]');
|
|
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
|
|
if (!menu || !toggle) return;
|
|
closeAllDropdowns(dropdown);
|
|
menu.classList.add('is-open');
|
|
setExpanded(toggle, true);
|
|
}
|
|
|
|
function closeDropdown(dropdown) {
|
|
var menu = dropdown.querySelector('[data-dropdown-menu]');
|
|
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
|
|
if (menu) menu.classList.remove('is-open');
|
|
setExpanded(toggle, false);
|
|
}
|
|
|
|
function toggleDropdown(dropdown) {
|
|
var menu = dropdown.querySelector('[data-dropdown-menu]');
|
|
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
|
|
if (!menu || !toggle) return;
|
|
|
|
var isOpen = menu.classList.contains('is-open');
|
|
closeAllDropdowns(isOpen ? null : dropdown);
|
|
|
|
if (isOpen) {
|
|
menu.classList.remove('is-open');
|
|
setExpanded(toggle, false);
|
|
} else {
|
|
menu.classList.add('is-open');
|
|
setExpanded(toggle, true);
|
|
}
|
|
}
|
|
|
|
function getMobileMenu() {
|
|
return document.getElementById('mobileMenu');
|
|
}
|
|
|
|
function setMobileToggleVisual(isOpen) {
|
|
var toggle = document.querySelector('[data-mobile-toggle]') || document.getElementById('btnSidebar');
|
|
if (!toggle) return;
|
|
|
|
setExpanded(toggle, !!isOpen);
|
|
|
|
var hamburgerIcon = toggle.querySelector('[data-mobile-icon-hamburger]');
|
|
var closeIcon = toggle.querySelector('[data-mobile-icon-close]');
|
|
if (hamburgerIcon) hamburgerIcon.classList.toggle('hidden', !!isOpen);
|
|
if (closeIcon) closeIcon.classList.toggle('hidden', !isOpen);
|
|
}
|
|
|
|
function closeMobileMenu() {
|
|
var menu = getMobileMenu();
|
|
if (!menu) return;
|
|
menu.classList.add('hidden');
|
|
setMobileToggleVisual(false);
|
|
}
|
|
|
|
function toggleMobileMenu() {
|
|
var menu = getMobileMenu();
|
|
if (!menu) return;
|
|
|
|
var isOpen = !menu.classList.contains('hidden');
|
|
if (isOpen) {
|
|
closeMobileMenu();
|
|
} else {
|
|
menu.classList.remove('hidden');
|
|
setMobileToggleVisual(true);
|
|
closeAllDropdowns();
|
|
}
|
|
}
|
|
|
|
document.addEventListener('click', function (e) {
|
|
var dropdownToggle = closest(e.target, '[data-dropdown-toggle]');
|
|
// legacy shorthand toggles: data-dd="name" -> menu id = dd-name
|
|
var legacyToggle = closest(e.target, '[data-dd]');
|
|
if (dropdownToggle) {
|
|
// On pointer/hover-capable devices prefer hover; ignore mouse clicks
|
|
if (canHover() && e.detail > 0) {
|
|
// allow keyboard activation (e.detail === 0) to fall through
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
var dropdown = closest(dropdownToggle, '[data-dropdown]');
|
|
if (dropdown) toggleDropdown(dropdown);
|
|
return;
|
|
}
|
|
|
|
if (legacyToggle) {
|
|
// On pointer/hover-capable devices prefer hover; ignore mouse clicks
|
|
if (canHover() && e.detail > 0) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
var ddName = legacyToggle.getAttribute('data-dd');
|
|
if (!ddName) return;
|
|
var menu = document.getElementById('dd-' + ddName);
|
|
if (!menu) return;
|
|
|
|
// treat this pair (toggle + menu) similarly to our dropdown API
|
|
var isOpen = menu.classList.contains('is-open');
|
|
// close other dropdowns
|
|
closeAllDropdowns();
|
|
// also close other legacy (data-dd) menus
|
|
document.querySelectorAll('[data-dd]').forEach(function (other) {
|
|
if (other === legacyToggle) return;
|
|
var otherId = other.getAttribute('data-dd');
|
|
var otherMenu = otherId ? document.getElementById('dd-' + otherId) : null;
|
|
if (otherMenu) otherMenu.classList.remove('is-open');
|
|
setExpanded(other, false);
|
|
});
|
|
|
|
if (isOpen) {
|
|
menu.classList.remove('is-open');
|
|
setExpanded(legacyToggle, false);
|
|
} else {
|
|
menu.classList.add('is-open');
|
|
setExpanded(legacyToggle, true);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var mobileToggle = closest(e.target, '[data-mobile-toggle]');
|
|
if (mobileToggle) {
|
|
e.preventDefault();
|
|
toggleMobileMenu();
|
|
return;
|
|
}
|
|
|
|
var mobileSectionToggle = closest(e.target, '[data-mobile-section-toggle]');
|
|
if (mobileSectionToggle) {
|
|
e.preventDefault();
|
|
|
|
var panelId = mobileSectionToggle.getAttribute('aria-controls');
|
|
var panel = panelId ? document.getElementById(panelId) : null;
|
|
if (!panel) return;
|
|
|
|
var wasOpen = !panel.classList.contains('hidden');
|
|
var menuRoot = getMobileMenu();
|
|
|
|
// Keep mobile navigation tidy: close all sections first.
|
|
if (menuRoot) {
|
|
menuRoot.querySelectorAll('[data-mobile-section-panel]').forEach(function (el) {
|
|
el.classList.add('hidden');
|
|
});
|
|
menuRoot.querySelectorAll('[data-mobile-section-toggle]').forEach(function (btn) {
|
|
setExpanded(btn, false);
|
|
var icon = btn.querySelector('[data-mobile-section-icon]');
|
|
if (icon) icon.classList.remove('rotate-180');
|
|
});
|
|
}
|
|
|
|
// If it was closed, open it. If it was open, it stays closed (toggle behavior).
|
|
if (!wasOpen) {
|
|
panel.classList.remove('hidden');
|
|
setExpanded(mobileSectionToggle, true);
|
|
var currentIcon = mobileSectionToggle.querySelector('[data-mobile-section-icon]');
|
|
if (currentIcon) currentIcon.classList.add('rotate-180');
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Submenu toggle (touch/click fallback)
|
|
var submenuToggle = closest(e.target, '[data-submenu-toggle]');
|
|
if (submenuToggle) {
|
|
if (canHover()) {
|
|
// On desktop, submenu opens on hover via CSS.
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
var submenu = closest(submenuToggle, '[data-submenu]');
|
|
if (!submenu) return;
|
|
var menu = submenu.querySelector('[data-submenu-menu]');
|
|
if (!menu) return;
|
|
|
|
// Close other submenus within the same dropdown
|
|
var dropdown = closest(submenuToggle, '[data-dropdown]');
|
|
if (dropdown) {
|
|
dropdown.querySelectorAll('[data-submenu-menu]').forEach(function (sm) {
|
|
if (sm !== menu) sm.classList.add('hidden');
|
|
});
|
|
dropdown.querySelectorAll('[data-submenu-toggle]').forEach(function (st) {
|
|
if (st !== submenuToggle) setExpanded(st, false);
|
|
});
|
|
}
|
|
|
|
var isOpen = !menu.classList.contains('hidden');
|
|
if (isOpen) {
|
|
menu.classList.add('hidden');
|
|
setExpanded(submenuToggle, false);
|
|
} else {
|
|
menu.classList.remove('hidden');
|
|
setExpanded(submenuToggle, true);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (!closest(e.target, '[data-dropdown]')) {
|
|
closeAllDropdowns();
|
|
}
|
|
|
|
// Close mobile menu when tapping outside of it and outside the hamburger toggle.
|
|
var mobileMenu = getMobileMenu();
|
|
var mobileToggle = closest(e.target, '[data-mobile-toggle]') || closest(e.target, '#btnSidebar');
|
|
if (mobileMenu && !mobileMenu.classList.contains('hidden') && !mobileToggle && !closest(e.target, '#mobileMenu')) {
|
|
closeMobileMenu();
|
|
}
|
|
});
|
|
|
|
// Hover-to-open for desktop pointers
|
|
var hoverCloseTimers = new WeakMap();
|
|
function clearHoverTimer(dropdown) {
|
|
var t = hoverCloseTimers.get(dropdown);
|
|
if (t) window.clearTimeout(t);
|
|
hoverCloseTimers.delete(dropdown);
|
|
}
|
|
|
|
function scheduleClose(dropdown) {
|
|
clearHoverTimer(dropdown);
|
|
hoverCloseTimers.set(
|
|
dropdown,
|
|
window.setTimeout(function () {
|
|
closeMenuElement(dropdown);
|
|
}, 140)
|
|
);
|
|
}
|
|
|
|
// Close a menu element or its parent dropdown wrapper.
|
|
function closeMenuElement(el) {
|
|
if (!el) return;
|
|
|
|
// If this is a dropdown wrapper, find the menu inside
|
|
if (el.hasAttribute && el.hasAttribute('data-dropdown')) {
|
|
var menu = el.querySelector('[data-dropdown-menu]');
|
|
var toggle = el.querySelector('[data-dropdown-toggle]');
|
|
if (menu) menu.classList.remove('is-open');
|
|
setExpanded(toggle, false);
|
|
// also close submenus inside
|
|
el.querySelectorAll('[data-submenu-menu]').forEach(function (sm) {
|
|
sm.classList.add('hidden');
|
|
});
|
|
el.querySelectorAll('[data-submenu-toggle]').forEach(function (st) {
|
|
setExpanded(st, false);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// If it's a menu element (e.g., legacy id=dd-name) hide it and try to find its toggle
|
|
var menuEl = el;
|
|
if (!menuEl.id && el.getAttribute && el.getAttribute('data-dropdown-menu')) {
|
|
// explicit menu element
|
|
}
|
|
|
|
// hide the element if possible
|
|
try { menuEl.classList.remove('is-open'); } catch (e) {}
|
|
|
|
// Try to map back to a toggle: id like dd-name -> data-dd="name"
|
|
if (menuEl.id && menuEl.id.indexOf('dd-') === 0) {
|
|
var name = menuEl.id.slice(3);
|
|
var toggle = document.querySelector('[data-dd="' + name + '"]');
|
|
if (toggle) setExpanded(toggle, false);
|
|
} else {
|
|
// fallback: if menu is inside a [data-dropdown], handled above; nothing more to do
|
|
}
|
|
}
|
|
|
|
function bindHoverHandlers() {
|
|
if (!canHover()) return;
|
|
document.querySelectorAll('[data-dropdown]').forEach(function (dropdown) {
|
|
dropdown.addEventListener('mouseenter', function () {
|
|
clearHoverTimer(dropdown);
|
|
openDropdown(dropdown);
|
|
});
|
|
dropdown.addEventListener('mouseleave', function () {
|
|
scheduleClose(dropdown);
|
|
});
|
|
});
|
|
|
|
// legacy hover binding for shorthand toggles (data-dd)
|
|
document.querySelectorAll('[data-dd]').forEach(function (el) {
|
|
var ddName = el.getAttribute('data-dd');
|
|
if (!ddName) return;
|
|
var menu = document.getElementById('dd-' + ddName);
|
|
if (!menu) return;
|
|
|
|
// when pointer enters either toggle or menu, open
|
|
function enter() {
|
|
clearHoverTimer(menu);
|
|
// Instantly close any other open legacy dropdown to prevent overlap
|
|
document.querySelectorAll('[data-dd]').forEach(function (other) {
|
|
if (other === el) return;
|
|
var otherId = other.getAttribute('data-dd');
|
|
var otherMenu = otherId ? document.getElementById('dd-' + otherId) : null;
|
|
if (otherMenu) otherMenu.classList.remove('is-open');
|
|
setExpanded(other, false);
|
|
});
|
|
menu.classList.add('is-open');
|
|
setExpanded(el, true);
|
|
}
|
|
|
|
function leave() {
|
|
scheduleClose(menu);
|
|
}
|
|
|
|
el.addEventListener('mouseenter', enter);
|
|
el.addEventListener('mouseleave', leave);
|
|
menu.addEventListener('mouseenter', enter);
|
|
menu.addEventListener('mouseleave', leave);
|
|
});
|
|
}
|
|
|
|
bindHoverHandlers();
|
|
|
|
// Submenu hover handlers: ensure flyouts open on pointer devices
|
|
if (canHover()) {
|
|
document.querySelectorAll('[data-submenu]').forEach(function (group) {
|
|
var toggle = group.querySelector('[data-submenu-toggle]');
|
|
var menu = group.querySelector('[data-submenu-menu]');
|
|
if (!menu) return;
|
|
|
|
group.addEventListener('mouseenter', function () {
|
|
menu.classList.remove('hidden');
|
|
if (toggle) setExpanded(toggle, true);
|
|
});
|
|
group.addEventListener('mouseleave', function () {
|
|
menu.classList.add('hidden');
|
|
if (toggle) setExpanded(toggle, false);
|
|
});
|
|
});
|
|
}
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key !== 'Escape') return;
|
|
closeAllDropdowns();
|
|
closeMobileMenu();
|
|
});
|
|
|
|
window.addEventListener('resize', function () {
|
|
if (window.matchMedia('(min-width: 768px)').matches) {
|
|
closeMobileMenu();
|
|
}
|
|
});
|
|
})();
|