PageSourceSearch

https://oldnorth.com/wp-content/themes/wp_old_north/blocks/query-filter/view.js?ver=1.4.5

js oldnorth.com collected 2026-10-02 03:27:14 UTC 18,921 bytes, 667 lines download raw bytes

1/**
2 * Query Filter Block - Interactivity API Store
3 *
4 * Provides live search and taxonomy filtering for posts
5 */
6
7import { store, getContext, getElement } from '@wordpress/interactivity';
8
9// Debounce utility
10let debounceTimer = null;
11const debounce = (callback, delay = 300) => {
12	clearTimeout(debounceTimer);
13	debounceTimer = setTimeout(callback, delay);
14};
15
16// Decode HTML entities (for REST API responses that contain entities like ’)
17const decodeHtmlEntities = (str) => {
18	if (!str) return '';
19	const txt = document.createElement('textarea');
20	txt.innerHTML = str;
21	return txt.value;
22};
23
24// Simple HTML escaping
25const escapeHtml = (str) => {
26	if (!str) return '';
27	const div = document.createElement('div');
28	div.textContent = str;
29	return div.innerHTML;
30};
31
32// Decode entities then escape for safe HTML output
33const safeText = (str) => {
34	return escapeHtml(decodeHtmlEntities(str));
35};
36
37// Strip HTML tags and decode entities
38const stripHtml = (html) => {
39	const tmp = document.createElement('div');
40	tmp.innerHTML = html;
41	return tmp.textContent || tmp.innerText || '';
42};
43
44// Format date
45const formatDate = (dateStr) => {
46	const date = new Date(dateStr);
47	return date.toLocaleDateString('en-US', {
48		year: 'numeric',
49		month: 'long',
50		day: 'numeric',
51	});
52};
53
54// Card rendering function
55const renderCard = (post) => {
56	const termsHtml = post.terms?.length
57		? `<div class="query-filter__card-terms">
58			${post.terms.map((term) => `<span class="oni-pill">${safeText(term.name)}</span>`).join('')}
59		</div>`
60		: '';
61
62	const imageHtml = post.featuredImage
63		? `<a href="${post.link}" class="query-filter__card-image blog-archive-card__image">
64			<img src="${post.featuredImage}" alt="" loading="lazy" />
65		</a>`
66		: '<div class="query-filter__card-placeholder blog-archive-card__placeholder"></div>';
67
68	const authorHtml = post.author
69		? `<p class="query-filter__card-author">${safeText(post.authorPrefix || 'By')} ${safeText(post.author)}</p>`
70		: '';
71
72	return `
73		<article class="query-filter__card blog-archive-card">
74			${imageHtml}
75			${termsHtml}
76			<h3 class="query-filter__card-title">
77				<a href="${post.link}">${safeText(post.title)}</a>
78			</h3>
79			${authorHtml}
80			<time class="query-filter__card-date" datetime="${post.dateISO}">
81				${safeText(post.date)}
82			</time>
83			<p class="query-filter__card-excerpt">${safeText(post.excerpt)}</p>
84			<a href="${post.link}" class="query-filter__card-link blog-archive-card__link">
85				View article
86			</a>
87		</article>
88	`;
89};
90
91// Map REST API response to our post format
92const mapRestPost = (post) => {
93	const featuredMedia = post._embedded?.['wp:featuredmedia']?.[0];
94	const terms = post._embedded?.['wp:term']?.[0] || [];
95
96	return {
97		id: post.id,
98		title: post.title?.rendered || '',
99		link: post.link,
100		excerpt: stripHtml(post.excerpt?.rendered || '').substring(0, 200) + '...',
101		date: post.event_date_display || formatDate(post.date),
102		dateISO: post.event_date_iso || post.date,
103		featuredImage: featuredMedia?.source_url || '',
104		terms: terms.map((term) => ({
105			id: term.id,
106			name: term.name,
107			link: term.link,
108		})),
109		author: post.acf_speaker_name || post.acf_author_name || post._embedded?.author?.[0]?.name || '',
110		authorPrefix: post.acf_speaker_name ? 'With' : 'By',
111	};
112};
113
114// Update the DOM grid with new posts
115const updateGrid = (block, posts) => {
116	const grid = block.querySelector('.query-filter__grid');
117	const noResults = block.querySelector('.query-filter__no-results');
118
119	if (!grid) return;
120
121	// Clear existing cards
122	grid.innerHTML = '';
123
124	// Render new cards
125	posts.forEach((post) => {
126		grid.insertAdjacentHTML('beforeend', renderCard(post));
127	});
128
129	// Update no results visibility
130	if (noResults) {
131		if (posts.length === 0) {
132			noResults.removeAttribute('hidden');
133			noResults.classList.add('query-filter__no-results--visible');
134		} else {
135			noResults.setAttribute('hidden', '');
136			noResults.classList.remove('query-filter__no-results--visible');
137		}
138	}
139};
140
141// Update term button states
142const updateTermButtons = (block, selectedTerms) => {
143	const allButton = block.querySelector('.query-filter__term-btn[data-wp-on--click="actions.clearTerms"]');
144	const termButtons = block.querySelectorAll('.query-filter__term-btn[data-term-id]');
145
146	if (allButton) {
147		const isAllActive = !selectedTerms || selectedTerms.length === 0;
148		allButton.classList.toggle('query-filter__term-btn--active', isAllActive);
149		allButton.setAttribute('aria-pressed', isAllActive ? 'true' : 'false');
150	}
151
152	termButtons.forEach((btn) => {
153		const termId = parseInt(btn.getAttribute('data-term-id'), 10);
154		const isSelected = selectedTerms && selectedTerms.includes(termId);
155		btn.classList.toggle('query-filter__term-btn--active', isSelected);
156		btn.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
157	});
158};
159
160// Set loading state in DOM
161const setLoadingState = (block, isLoading) => {
162	if (!block) return;
163	const grid = block.querySelector('.query-filter__grid');
164	const loading = block.querySelector('.query-filter__loading');
165	if (grid) grid.classList.toggle('query-filter__grid--loading', isLoading);
166	if (loading) loading.classList.toggle('query-filter__loading--visible', isLoading);
167};
168
169// Map taxonomy to REST API parameter name
170const getTaxonomyRestParam = (taxonomy) => {
171	// WordPress REST API uses plural forms for built-in taxonomies
172	const taxonomyMap = {
173		category: 'categories',
174		post_tag: 'tags',
175	};
176	return taxonomyMap[taxonomy] || taxonomy;
177};
178
179// Perform the fetch operation
180const doFetch = async (contextData) => {
181	const params = new URLSearchParams();
182
183	if (contextData.searchQuery) {
184		params.set('search', contextData.searchQuery);
185	}
186
187	if (contextData.selectedTerms && contextData.selectedTerms.length > 0) {
188		const restParam = getTaxonomyRestParam(contextData.taxonomy);
189		params.set(restParam, contextData.selectedTerms.join(','));
190	}
191
192	params.set('per_page', String(contextData.postsPerPage));
193	params.set('page', String(contextData.currentPage || 1));
194	params.set('_embed', 'true');
195
196	try {
197		const response = await fetch(
198			`${contextData.restUrl}${contextData.restBase}?${params}`
199		);
200		const totalPages = parseInt(response.headers.get('X-WP-TotalPages') || '1', 10);
201		const totalPosts = parseInt(response.headers.get('X-WP-Total') || '0', 10);
202		const data = await response.json();
203		return {
204			posts: data.map(mapRestPost),
205			totalPages,
206			totalPosts,
207		};
208	} catch (error) {
209		console.error('Query Filter: Error fetching posts', error);
210		return { posts: [], totalPages: 0, totalPosts: 0 };
211	}
212};
213
214// Update URL with current filter state
215const updateUrlState = (searchQuery, selectedTerms, page = 1, { replace = false } = {}) => {
216	const url = new URL(window.location);
217
218	if (searchQuery) {
219		url.searchParams.set('s', searchQuery);
220	} else {
221		url.searchParams.delete('s');
222	}
223
224	if (selectedTerms && selectedTerms.length > 0) {
225		url.searchParams.set('tax', selectedTerms.join(','));
226	} else {
227		url.searchParams.delete('tax');
228	}
229
230	if (page > 1) {
231		url.searchParams.set('pg', String(page));
232	} else {
233		url.searchParams.delete('pg');
234	}
235
236	if (replace) {
237		window.history.replaceState({}, '', url);
238	} else {
239		window.history.pushState({}, '', url);
240	}
241};
242
243// Generate pagination numbers
244const generatePaginationPages = (currentPage, totalPages) => {
245	const pages = [];
246	const showEllipsis = totalPages > 7;
247
248	if (!showEllipsis) {
249		for (let i = 1; i <= totalPages; i++) {
250			pages.push(i);
251		}
252	} else {
253		// Always show first 3 pages
254		pages.push(1, 2, 3);
255
256		if (currentPage > 4) {
257			pages.push('...');
258		}
259
260		// Show current page and neighbors if not already included
261		if (currentPage > 3 && currentPage < totalPages - 2) {
262			if (!pages.includes(currentPage)) {
263				pages.push(currentPage);
264			}
265		}
266
267		if (currentPage < totalPages - 3) {
268			pages.push('...');
269		}
270
271		// Always show last 3 pages
272		if (!pages.includes(totalPages - 2)) pages.push(totalPages - 2);
273		if (!pages.includes(totalPages - 1)) pages.push(totalPages - 1);
274		if (!pages.includes(totalPages)) pages.push(totalPages);
275	}
276
277	return pages;
278};
279
280// Update pagination UI
281const updatePagination = (block, currentPage, totalPages) => {
282	const paginationNav = block.querySelector('.query-filter__pagination');
283	const pagesContainer = block.querySelector('.query-filter__pagination-pages');
284	const prevBtn = block.querySelector('.query-filter__pagination-btn--prev');
285	const nextBtn = block.querySelector('.query-filter__pagination-btn--next');
286
287	if (!paginationNav) return;
288
289	// Show/hide pagination
290	if (totalPages <= 1) {
291		paginationNav.setAttribute('hidden', '');
292		return;
293	} else {
294		paginationNav.removeAttribute('hidden');
295	}
296
297	// Update prev/next button states
298	if (prevBtn) prevBtn.disabled = currentPage <= 1;
299	if (nextBtn) nextBtn.disabled = currentPage >= totalPages;
300
301	// Generate page numbers
302	if (pagesContainer) {
303		const pages = generatePaginationPages(currentPage, totalPages);
304		pagesContainer.innerHTML = pages
305			.map((page) => {
306				if (page === '...') {
307					return '<span class="query-filter__pagination-ellipsis">...</span>';
308				}
309				const isActive = page === currentPage;
310				return `<button type="button" class="query-filter__pagination-page${isActive ? ' query-filter__pagination-page--active' : ''}" data-page="${page}"${isActive ? ' aria-current="page"' : ''}>${page}</button>`;
311			})
312			.join('');
313
314		// Add click handlers for page buttons
315		pagesContainer.querySelectorAll('.query-filter__pagination-page').forEach((btn) => {
316			btn.addEventListener('click', (e) => {
317				const page = parseInt(e.target.dataset.page, 10);
318				if (page && !isNaN(page)) {
319					window.dispatchEvent(new CustomEvent('query-filter-go-to-page', { detail: { page, block } }));
320				}
321			});
322		});
323	}
324};
325
326// Store a reference to pending fetch data for debounced calls
327let pendingFetchData = null;
328let pendingBlock = null;
329let pendingContext = null;
330
331const executeFetch = async (resetPage = false) => {
332	if (!pendingFetchData || !pendingBlock) return;
333
334	const contextData = { ...pendingFetchData };
335	if (resetPage) {
336		contextData.currentPage = 1;
337	}
338	const block = pendingBlock;
339
340	setLoadingState(block, true);
341
342	const result = await doFetch(contextData);
343	updateGrid(block, result.posts);
344	updatePagination(block, contextData.currentPage, result.totalPages);
345	updateUrlState(contextData.searchQuery, contextData.selectedTerms, contextData.currentPage);
346
347	// Update context values
348	if (pendingContext) {
349		pendingContext.currentPage = contextData.currentPage;
350		pendingContext.totalPages = result.totalPages;
351		pendingContext.totalPosts = result.totalPosts;
352	}
353
354	setLoadingState(block, false);
355};
356
357const { actions } = store('old-north/query-filter', {
358	state: {
359		get hasNoTerms() {
360			const context = getContext();
361			return !context.selectedTerms || context.selectedTerms.length === 0;
362		},
363		get hasResults() {
364			const context = getContext();
365			return context.posts && context.posts.length > 0;
366		},
367	},
368
369	actions: {
370		handleSearchInput(event) {
371			const context = getContext();
372			const { ref } = getElement();
373			context.searchQuery = event.target.value;
374			context.currentPage = 1;
375
376			// Find the block element
377			const block = ref.closest('[data-wp-interactive="old-north/query-filter"]');
378
379			// Store context data for debounced fetch
380			pendingFetchData = {
381				searchQuery: context.searchQuery,
382				selectedTerms: context.selectedTerms,
383				taxonomy: context.taxonomy,
384				postsPerPage: context.postsPerPage,
385				restBase: context.restBase,
386				restUrl: context.restUrl,
387				currentPage: 1,
388			};
389			pendingBlock = block;
390			pendingContext = context;
391
392			debounce(() => executeFetch(true), 300);
393		},
394
395		toggleTerm(event) {
396			const context = getContext();
397			const { ref } = getElement();
398			const termId = context.termId;
399
400			if (!termId) return;
401
402			if (!context.selectedTerms) {
403				context.selectedTerms = [];
404			}
405
406			const index = context.selectedTerms.indexOf(termId);
407			if (index === -1) {
408				context.selectedTerms = [...context.selectedTerms, termId];
409			} else {
410				context.selectedTerms = context.selectedTerms.filter(
411					(id) => id !== termId
412				);
413			}
414
415			context.currentPage = 1;
416
417			// Find the block element
418			const block = ref.closest('[data-wp-interactive="old-north/query-filter"]');
419
420			// Update button states immediately
421			if (block) {
422				updateTermButtons(block, context.selectedTerms);
423			}
424
425			// Store context data and execute fetch immediately (no debounce for button clicks)
426			pendingFetchData = {
427				searchQuery: context.searchQuery,
428				selectedTerms: context.selectedTerms,
429				taxonomy: context.taxonomy,
430				postsPerPage: context.postsPerPage,
431				restBase: context.restBase,
432				restUrl: context.restUrl,
433				currentPage: 1,
434			};
435			pendingBlock = block;
436			pendingContext = context;
437
438			executeFetch(true);
439		},
440
441		clearTerms(event) {
442			const context = getContext();
443			const { ref } = getElement();
444			context.selectedTerms = [];
445			context.currentPage = 1;
446
447			// Find the block element
448			const block = ref.closest('[data-wp-interactive="old-north/query-filter"]');
449
450			// Update button states immediately
451			if (block) {
452				updateTermButtons(block, []);
453			}
454
455			// Store context data and execute fetch immediately
456			pendingFetchData = {
457				searchQuery: context.searchQuery,
458				selectedTerms: [],
459				taxonomy: context.taxonomy,
460				postsPerPage: context.postsPerPage,
461				restBase: context.restBase,
462				restUrl: context.restUrl,
463				currentPage: 1,
464			};
465			pendingBlock = block;
466			pendingContext = context;
467
468			executeFetch(true);
469		},
470
471		prevPage() {
472			const context = getContext();
473			const { ref } = getElement();
474
475			if (context.currentPage <= 1) return;
476
477			context.currentPage = context.currentPage - 1;
478			const block = ref.closest('[data-wp-interactive="old-north/query-filter"]');
479
480			pendingFetchData = {
481				searchQuery: context.searchQuery,
482				selectedTerms: context.selectedTerms,
483				taxonomy: context.taxonomy,
484				postsPerPage: context.postsPerPage,
485				restBase: context.restBase,
486				restUrl: context.restUrl,
487				currentPage: context.currentPage,
488			};
489			pendingBlock = block;
490			pendingContext = context;
491
492			executeFetch();
493		},
494
495		nextPage() {
496			const context = getContext();
497			const { ref } = getElement();
498
499			if (context.currentPage >= context.totalPages) return;
500
501			context.currentPage = context.currentPage + 1;
502			const block = ref.closest('[data-wp-interactive="old-north/query-filter"]');
503
504			pendingFetchData = {
505				searchQuery: context.searchQuery,
506				selectedTerms: context.selectedTerms,
507				taxonomy: context.taxonomy,
508				postsPerPage: context.postsPerPage,
509				restBase: context.restBase,
510				restUrl: context.restUrl,
511				currentPage: context.currentPage,
512			};
513			pendingBlock = block;
514			pendingContext = context;
515
516			executeFetch();
517		},
518
519		goToPage(page) {
520			const context = getContext();
521			const { ref } = getElement();
522
523			if (page < 1 || page > context.totalPages || page === context.currentPage) return;
524
525			context.currentPage = page;
526			const block = ref.closest('[data-wp-interactive="old-north/query-filter"]');
527
528			pendingFetchData = {
529				searchQuery: context.searchQuery,
530				selectedTerms: context.selectedTerms,
531				taxonomy: context.taxonomy,
532				postsPerPage: context.postsPerPage,
533				restBase: context.restBase,
534				restUrl: context.restUrl,
535				currentPage: context.currentPage,
536			};
537			pendingBlock = block;
538			pendingContext = context;
539
540			executeFetch();
541		},
542	},
543
544	callbacks: {
545		hasNoTerms() {
546			const context = getContext();
547			return !context.selectedTerms || context.selectedTerms.length === 0;
548		},
549
550		isTermSelected() {
551			const context = getContext();
552			if (!context.selectedTerms || !context.termId) return false;
553			return context.selectedTerms.includes(context.termId);
554		},
555
556		hasNoResults() {
557			const context = getContext();
558			return !context.isLoading && (!context.posts || context.posts.length === 0);
559		},
560
561		isFirstPage() {
562			const context = getContext();
563			return context.currentPage <= 1;
564		},
565
566		isLastPage() {
567			const context = getContext();
568			return context.currentPage >= context.totalPages;
569		},
570
571		initPagination() {
572			const context = getContext();
573			const { ref } = getElement();
574			const block = ref.closest('[data-wp-interactive="old-north/query-filter"]');
575
576			if (block && context.totalPages > 1) {
577				updatePagination(block, context.currentPage, context.totalPages);
578			}
579
580			// Set initial history state with replaceState so back button works correctly
581			updateUrlState(context.searchQuery, context.selectedTerms, context.currentPage, { replace: true });
582		},
583	},
584});
585
586// Restore filter state when navigating browser history (back/forward)
587window.addEventListener('popstate', async () => {
588	const block = document.querySelector('[data-wp-interactive="old-north/query-filter"]');
589	if (!block) return;
590
591	const url = new URL(window.location);
592	const searchQuery = url.searchParams.get('s') || '';
593	const taxParam = url.searchParams.get('tax') || '';
594	const selectedTerms = taxParam ? taxParam.split(',').map(Number).filter(Boolean) : [];
595	const page = parseInt(url.searchParams.get('pg') || '1', 10);
596
597	// Read block context for REST config
598	const contextStr = block.getAttribute('data-wp-context');
599	if (!contextStr) return;
600
601	let context;
602	try {
603		context = JSON.parse(contextStr);
604	} catch {
605		return;
606	}
607
608	const fetchData = {
609		searchQuery,
610		selectedTerms,
611		taxonomy: context.taxonomy,
612		postsPerPage: context.postsPerPage,
613		restBase: context.restBase,
614		restUrl: context.restUrl,
615		currentPage: page,
616	};
617
618	setLoadingState(block, true);
619
620	const result = await doFetch(fetchData);
621	updateGrid(block, result.posts);
622	updatePagination(block, page, result.totalPages);
623	updateTermButtons(block, selectedTerms);
624
625	// Update search input
626	const searchInput = block.querySelector('.query-filter__search-input');
627	if (searchInput) searchInput.value = searchQuery;
628
629	setLoadingState(block, false);
630});
631
632// Listen for page click events from dynamically generated buttons
633window.addEventListener('query-filter-go-to-page', (e) => {
634	const { page, block } = e.detail;
635	if (!block) return;
636
637	// Use the last known pending fetch data if available (has current filter state)
638	// Otherwise fall back to parsing the DOM context
639	if (pendingFetchData) {
640		pendingFetchData.currentPage = page;
641		pendingBlock = block;
642		executeFetch();
643	} else {
644		// Get context from block as fallback
645		const contextStr = block.getAttribute('data-wp-context');
646		if (!contextStr) return;
647
648		try {
649			const context = JSON.parse(contextStr);
650
651			pendingFetchData = {
652				searchQuery: context.searchQuery,
653				selectedTerms: context.selectedTerms,
654				taxonomy: context.taxonomy,
655				postsPerPage: context.postsPerPage,
656				restBase: context.restBase,
657				restUrl: context.restUrl,
658				currentPage: page,
659			};
660			pendingBlock = block;
661
662			executeFetch();
663		} catch (err) {
664			console.error('Query Filter: Error parsing context', err);
665		}
666	}
667});

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.