feat: add tag discovery analytics and reporting

This commit is contained in:
2026-03-17 18:23:38 +01:00
parent b3fc889452
commit 2728644477
29 changed files with 2660 additions and 112 deletions

View File

@@ -154,6 +154,8 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
{!loading &&
results.map((tag) => {
const isSelected = selected.some((t) => t.id === tag.id)
const recentClicks = Number(tag.recent_clicks || 0)
const usageCount = Number(tag.usage_count || 0)
return (
<button
key={tag.id}
@@ -174,7 +176,9 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
/>
{tag.name}
</span>
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
<span className="text-xs text-slate-500">
{recentClicks > 0 ? `${recentClicks.toLocaleString()} recent clicks` : `${usageCount.toLocaleString()} uses`}
</span>
</button>
)
})}

View File

@@ -0,0 +1,63 @@
import React from 'react'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import BulkTagModal from './BulkTagModal'
describe('BulkTagModal', () => {
beforeEach(() => {
document.head.innerHTML = '<meta name="csrf-token" content="test-token">'
global.fetch = vi.fn(async (url) => {
const requestUrl = String(url)
if (requestUrl.includes('?q=high')) {
return {
json: async () => ([
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
]),
}
}
return {
json: async () => ([
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
]),
}
})
})
afterEach(() => {
vi.restoreAllMocks()
document.head.innerHTML = ''
})
it('shows recent click momentum for initial results', async () => {
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={() => {}} />)
await waitFor(() => {
expect(screen.getByText('Popular Pick')).not.toBeNull()
})
expect(screen.getByText('9 recent clicks')).not.toBeNull()
})
it('returns selected tag ids and shows recent click momentum in search results', async () => {
const onConfirm = vi.fn()
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={onConfirm} />)
const input = screen.getByPlaceholderText('Search tags…')
await userEvent.type(input, 'high')
await waitFor(() => {
expect(screen.getByText('18 recent clicks')).not.toBeNull()
})
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
await userEvent.click(screen.getByRole('button', { name: /Add 1 tag/i }))
expect(onConfirm).toHaveBeenCalledWith([2])
})
})

View File

@@ -52,7 +52,8 @@ function toSuggestionItems(raw) {
key: item?.id ?? tag,
label: item?.name || item?.tag || item?.slug || tag,
tag,
usageCount: typeof item?.usage_count === 'number' ? item.usage_count : null,
usageCount: Number.isFinite(Number(item?.usage_count)) ? Number(item.usage_count) : null,
recentClicks: Number.isFinite(Number(item?.recent_clicks)) ? Number(item.recent_clicks) : 0,
isAi: Boolean(item?.is_ai || item?.source === 'ai'),
}
})
@@ -173,6 +174,9 @@ function SuggestionDropdown({
{!loading && !error && suggestions.map((item, index) => {
const active = highlightedIndex === index
const detailLabel = item.recentClicks > 0
? `${item.recentClicks.toLocaleString()} recent`
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
return (
<li
key={item.key}
@@ -192,8 +196,8 @@ function SuggestionDropdown({
</span>
)}
</div>
{typeof item.usageCount === 'number' && (
<span className="shrink-0 text-[11px] text-white/50">{item.usageCount}</span>
{detailLabel && (
<span className="shrink-0 text-[11px] text-white/50">{detailLabel}</span>
)}
</li>
)

View File

@@ -26,8 +26,8 @@ describe('TagInput', () => {
return {
data: {
data: [
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10 },
{ id: 2, name: 'city', slug: 'city', usage_count: 30 },
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10, recent_clicks: 2 },
{ id: 2, name: 'city', slug: 'city', usage_count: 30, recent_clicks: 14 },
],
},
}
@@ -74,6 +74,17 @@ describe('TagInput', () => {
expect(screen.getByRole('button', { name: /Remove tag/i })).not.toBeNull()
})
it('shows recent click momentum when suggestions provide it', async () => {
render(<Harness />)
const input = screen.getByLabelText('Tag input')
await userEvent.type(input, 'city')
await waitFor(() => {
expect(screen.getByText('14 recent')).not.toBeNull()
})
})
it('supports comma-separated paste', async () => {
render(<Harness />)

View File

@@ -37,15 +37,18 @@ function toListItem(item) {
if (!item) return null
if (typeof item === 'string') {
const slug = normalizeSlug(item)
return slug ? { key: slug, slug, name: slug, usageCount: null, isAi: false } : null
return slug ? { key: slug, slug, name: slug, usageCount: null, recentClicks: 0, isAi: false } : null
}
const slug = normalizeSlug(item.slug || item.tag || item.name || '')
if (!slug) return null
const usageCount = Number(item.usage_count)
const recentClicks = Number(item.recent_clicks)
return {
key: String(item.id ?? slug),
slug,
name: item.name || item.tag || item.slug || slug,
usageCount: typeof item.usage_count === 'number' ? item.usage_count : null,
usageCount: Number.isFinite(usageCount) ? usageCount : null,
recentClicks: Number.isFinite(recentClicks) ? recentClicks : 0,
isAi: Boolean(item.is_ai || item.source === 'ai'),
}
}
@@ -123,6 +126,10 @@ function AddNewRow({ label, onAdd, disabled }) {
}
function ListRow({ item, isSelected, onToggle, disabled }) {
const detailLabel = item.recentClicks > 0
? `${item.recentClicks.toLocaleString()} recent clicks`
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
return (
<button
type="button"
@@ -154,9 +161,9 @@ function ListRow({ item, isSelected, onToggle, disabled }) {
)}
</span>
{typeof item.usageCount === 'number' && (
{detailLabel && (
<span className="ml-3 shrink-0 text-[11px] text-white/40">
{item.usageCount.toLocaleString()} uses
{detailLabel}
</span>
)}
</button>

View File

@@ -0,0 +1,76 @@
import React from 'react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TagPicker from './TagPicker'
function Harness({ initial = [] }) {
const [tags, setTags] = React.useState(initial)
return (
<TagPicker
value={tags}
onChange={setTags}
suggestedTags={['sunset']}
searchEndpoint="/api/tags/search"
popularEndpoint="/api/tags/popular"
/>
)
}
describe('TagPicker', () => {
beforeEach(() => {
window.axios = {
get: vi.fn(async (url) => {
if (url.startsWith('/api/tags/search')) {
return {
data: {
data: [
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
],
},
}
}
return {
data: {
data: [
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
],
},
}
}),
}
})
afterEach(() => {
vi.restoreAllMocks()
})
it('shows recent click momentum for popular tags on mount', async () => {
render(<Harness />)
await waitFor(() => {
expect(screen.getByText('Popular Pick')).not.toBeNull()
})
expect(screen.getByText('9 recent clicks')).not.toBeNull()
})
it('shows recent click momentum in search results and lets the user select a tag', async () => {
render(<Harness />)
const input = screen.getByLabelText('Search or add tags')
await userEvent.type(input, 'high')
await waitFor(() => {
expect(screen.getByText('18 recent clicks')).not.toBeNull()
})
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
expect(screen.getByText('High Contrast')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag High Contrast' })).not.toBeNull()
})
})

View File

@@ -0,0 +1,19 @@
export function sendTagInteractionEvent(payload) {
const endpoint = '/api/analytics/tags'
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
const body = JSON.stringify(payload)
fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...(csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}),
},
body,
keepalive: true,
credentials: 'same-origin',
}).catch(() => {
})
}

View File

@@ -14,6 +14,7 @@ if (!window.Alpine) {
// Gallery navigation context: stores artwork list for prev/next on artwork page
import './lib/nav-context.js';
import { sendTagInteractionEvent } from './lib/tagAnalytics';
function mountStoryEditor() {
var storyEditorRoot = document.getElementById('story-editor-react-root');
@@ -61,6 +62,475 @@ function mountStoryEditor() {
mountStoryEditor();
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 + '">'
+ '&times;'
+ '</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]';