Harbor

branch main
showing the latest snapshot on main
blog-index.js 6.0 KB · JavaScript
public/js/blog-index.js 0644 Raw
const blogIndex = document.querySelector('[data-blog-index]');

if (blogIndex) {
	const searchInput = blogIndex.querySelector('[data-blog-search]');
	const filterDropdown = blogIndex.querySelector('[data-filter-dropdown]');
	const filterToggle = blogIndex.querySelector('[data-filter-toggle]');
	const filterMenu = blogIndex.querySelector('[data-filter-menu]');
	const filterLabel = blogIndex.querySelector('[data-filter-label]');
	const filterOptions = Array.from(blogIndex.querySelectorAll('[data-blog-filter]'));
	const inlineSearchIndex = document.querySelector('[data-blog-search-inline]');
	const list = document.querySelector('[data-blog-list]');
	const cards = Array.from(document.querySelectorAll('[data-blog-card]'));
	const loadMore = document.querySelector('[data-blog-load-more]');
	const emptyMessage = document.querySelector('[data-blog-empty]');
	const pageSize = 4;
	let activeFilter = '';
	let visibleCount = pageSize;
	let searchIndex = cards.map((card, order) => ({
		key: card.dataset.postKey,
		filters: [],
		text: card.textContent,
		order,
	}));

	function normalize(value) {
		return value
			.toLowerCase()
			.normalize('NFKD')
			.replace(/[\u0300-\u036f]/g, '');
	}

	function fuzzyScore(query, text) {
		const needle = normalize(query.trim());
		if (!needle) return 0;

		const haystack = normalize(text);
		const exactIndex = haystack.indexOf(needle);
		if (exactIndex >= 0) return exactIndex;

		let score = 0;
		let needleIndex = 0;
		let lastMatchIndex = -1;

		for (let haystackIndex = 0; haystackIndex < haystack.length; haystackIndex += 1) {
			if (haystack[haystackIndex] !== needle[needleIndex]) continue;

			score += lastMatchIndex >= 0 ? haystackIndex - lastMatchIndex : haystackIndex;
			lastMatchIndex = haystackIndex;
			needleIndex += 1;

			if (needleIndex === needle.length) return score + haystack.length - needle.length;
		}

		return Number.POSITIVE_INFINITY;
	}

	function wordScore(query, text) {
		const needle = normalize(query.trim());
		const haystack = normalize(text);
		const words = haystack.split(/[^a-z0-9]+/).filter(Boolean);

		if (!needle) return 0;
		if (haystack === needle) return 0;
		if (haystack.startsWith(needle)) return 1;

		const exactWordIndex = words.findIndex((word) => word === needle);
		if (exactWordIndex >= 0) return 3 + exactWordIndex;

		const prefixWordIndex = words.findIndex((word) => word.startsWith(needle));
		if (prefixWordIndex >= 0) return 10 + prefixWordIndex;

		const exactIndex = haystack.indexOf(needle);
		if (exactIndex >= 0) return 30 + exactIndex;

		const subsequenceScore = fuzzyScore(query, text);
		return subsequenceScore === Number.POSITIVE_INFINITY
			? Number.POSITIVE_INFINITY
			: 100 + subsequenceScore;
	}

	function fieldScore(query, value, offset) {
		const score = wordScore(query, value || '');
		return score === Number.POSITIVE_INFINITY ? score : offset + score;
	}

	function searchScore(query, entry) {
		if (!query.trim()) return 0;

		const fields = entry.fields || {
			title: entry.title || '',
			description: entry.description || '',
			filters: (entry.filters || []).join(' '),
			body: entry.text || '',
		};

		return Math.min(
			fieldScore(query, fields.title, 0),
			fieldScore(query, fields.description, 100),
			fieldScore(query, fields.filters, 150),
			fieldScore(query, fields.body, 250),
			fieldScore(query, entry.text, 400),
		);
	}

	function updateList(resetVisibleCount = false) {
		if (resetVisibleCount) visibleCount = pageSize;

		const query = searchInput.value;
		const indexedCards = new Map(cards.map((card) => [card.dataset.postKey, card]));
		const matches = searchIndex
			.map((entry) => ({
				entry,
				card: indexedCards.get(entry.key),
				score: searchScore(query, entry),
			}))
			.filter(({ entry, card, score }) => (
				card
				&& score !== Number.POSITIVE_INFINITY
				&& (!activeFilter || entry.filters.includes(activeFilter))
			))
			.sort((a, b) => a.score - b.score || a.entry.order - b.entry.order);
		const visibleMatches = matches.slice(0, visibleCount);

		cards.forEach((card) => {
			card.hidden = true;
		});

		visibleMatches.forEach(({ card }) => {
			card.hidden = false;
			list.appendChild(card);
		});

		emptyMessage.hidden = matches.length > 0;
		loadMore.hidden = matches.length <= visibleCount;
		loadMore.textContent = `Load More${matches.length > visibleCount ? ` (${matches.length - visibleCount})` : ''}`;
		list.hidden = matches.length === 0;
	}

	function setDropdownOpen(isOpen) {
		filterToggle.setAttribute('aria-expanded', String(isOpen));
		filterMenu.hidden = !isOpen;
	}

	function selectFilter(option) {
		activeFilter = option.dataset.blogFilter || '';
		filterLabel.textContent = option.textContent;

		filterOptions.forEach((filterOption) => {
			const isActive = filterOption === option;
			filterOption.classList.toggle('is-active', isActive);
			filterOption.setAttribute('aria-selected', String(isActive));
		});

		setDropdownOpen(false);
		updateList(true);
	}

	function setSearchIndex(entries) {
		searchIndex = entries.map((entry, order) => ({ ...entry, order }));
		updateList(true);
	}

	if (inlineSearchIndex) {
		setSearchIndex(JSON.parse(inlineSearchIndex.textContent));
	} else {
		fetch(blogIndex.dataset.searchIndex)
			.then((response) => {
				if (!response.ok) throw new Error(`Unable to load blog search index: ${response.status}`);
				return response.json();
			})
			.then(setSearchIndex)
			.catch((error) => {
				console.error(error);
				updateList(true);
			});
	}

	searchInput.addEventListener('input', () => updateList(true));

	filterToggle.addEventListener('click', () => {
		setDropdownOpen(filterToggle.getAttribute('aria-expanded') !== 'true');
	});

	filterOptions.forEach((option) => {
		option.addEventListener('click', () => selectFilter(option));
	});

	document.addEventListener('click', (event) => {
		if (!filterDropdown.contains(event.target)) setDropdownOpen(false);
	});

	document.addEventListener('keydown', (event) => {
		if (event.key === 'Escape') setDropdownOpen(false);
	});

	loadMore.addEventListener('click', () => {
		visibleCount += pageSize;
		updateList();
	});

	updateList(true);
}