diff --git a/package-lock.json b/package-lock.json index 177f19c1..37b87064 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "postcss-import-ext-glob": "^2.1.1", "rimraf": "^6.0.1", "slugify": "^1.6.6", - "terser": "^5.34.1", + "terser": "^5.36.0", "truncate-html": "^1.1.2" } }, @@ -728,9 +728,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.13.0.tgz", + "integrity": "sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w==", "dev": true, "license": "MIT", "bin": { @@ -1066,9 +1066,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001668", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001668.tgz", - "integrity": "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==", + "version": "1.0.30001669", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz", + "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==", "dev": true, "funding": [ { @@ -1646,9 +1646,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.38", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.38.tgz", - "integrity": "sha512-VbeVexmZ1IFh+5EfrYz1I0HTzHVIlJa112UEWhciPyeOcKJGeTv6N8WnG4wsQB81DGCaVEGhpSb6o6a8WYFXXg==", + "version": "1.5.39", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.39.tgz", + "integrity": "sha512-4xkpSR6CjuiaNyvwiWDI85N9AxsvbPawB8xc7yzLPonYTuP19BVgYweKyUMFtHEZgIcHWMt1ks5Cqx2m+6/Grg==", "dev": true, "license": "ISC" }, @@ -2562,9 +2562,9 @@ } }, "node_modules/liquidjs": { - "version": "10.17.0", - "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.17.0.tgz", - "integrity": "sha512-M4MC5/nencttIJHirl5jFTkl7Yu+grIDLn3Qgl7BPAD3BsbTCQknDxlG5VXWRwslWIjk8lSZZjVq9LioILDk1Q==", + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.18.0.tgz", + "integrity": "sha512-gCJPmpmZ3oi2rMMHo/c+bW1LaRF+ZAKYTWQmKXPp0uK9EkWMFRmgbk3+Io4LSJGAOnpCZSgHJbNzcygx3kfAAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4461,9 +4461,9 @@ } }, "node_modules/terser": { - "version": "5.34.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.34.1.tgz", - "integrity": "sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA==", + "version": "5.36.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", + "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { diff --git a/package.json b/package.json index b1a1fe08..5da25327 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "postcss-import-ext-glob": "^2.1.1", "rimraf": "^6.0.1", "slugify": "^1.6.6", - "terser": "^5.34.1", + "terser": "^5.36.0", "truncate-html": "^1.1.2" } } diff --git a/src/assets/scripts/search.js b/src/assets/scripts/search.js new file mode 100644 index 00000000..7a324b33 --- /dev/null +++ b/src/assets/scripts/search.js @@ -0,0 +1,176 @@ +window.addEventListener('load', () => { + ;(() => { + if (!MiniSearch) return + const miniSearch = new MiniSearch({ + fields: ['title', 'text', 'tags', 'type'], + }) + + const $form = document.querySelector('.search__form') + const $input = document.querySelector('.search__form--input') + const $fallback = document.querySelector('.search__form--fallback') + const $typeCheckboxes = document.querySelectorAll('.search__form--type input[type="checkbox"]') + const $results = document.querySelector('.search__results') + const $loadMoreButton = document.querySelector('.search__load-more') + + $form.removeAttribute('action') + $form.removeAttribute('method') + $fallback.remove() + + const PAGE_SIZE = 10 + let currentPage = 1 + let currentResults = [] + + const loadSearchIndex = async () => { + try { + const response = await fetch('/api/search') + const index = await response.json() + const resultsById = index.reduce((byId, result) => { + byId[result.id] = result + return byId + }, {}) + miniSearch.addAll(index) + return resultsById + } catch (error) { + console.error('Error fetching search index:', error) + return {} + } + } + + let resultsById = {} + let debounceTimeout + + loadSearchIndex().then(loadedResultsById => resultsById = loadedResultsById) + + const getSelectedTypes = () => { + return Array.from($typeCheckboxes) + .filter(checkbox => checkbox.checked) + .map(checkbox => checkbox.value) + } + + $input.addEventListener('input', () => { + const query = $input.value + + clearTimeout(debounceTimeout) + + if (query.length === 0) { + renderSearchResults([]) + $loadMoreButton.style.display = 'none' + return + } + + debounceTimeout = setTimeout(() => { + const results = (query.length > 1) ? getSearchResults(query) : [] + currentResults = results + currentPage = 1 + + renderSearchResults(getResultsForPage(currentPage)) + $loadMoreButton.style.display = results.length > PAGE_SIZE ? 'block' : 'none' + }, 300) + }) + + $input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') event.preventDefault() + }) + + $typeCheckboxes.forEach(checkbox => { + checkbox.addEventListener('change', () => { + const query = $input.value + const results = getSearchResults(query) + currentResults = results + currentPage = 1 + + renderSearchResults(getResultsForPage(currentPage)) + $loadMoreButton.style.display = results.length > PAGE_SIZE ? 'block' : 'none' + }) + }) + + $loadMoreButton.addEventListener('click', () => { + currentPage++ + const nextResults = getResultsForPage(currentPage) + appendSearchResults(nextResults) + + if (currentPage * PAGE_SIZE >= currentResults.length) $loadMoreButton.style.display = 'none' + }) + + const getSearchResults = (query) => { + const selectedTypes = getSelectedTypes() + + return miniSearch.search(query, { prefix: true, fuzzy: 0.2, boost: { title: 2 } }) + .map(({ id }) => resultsById[id]) + .filter(result => selectedTypes.includes(result.type)) + } + + const getResultsForPage = (page) => { + const start = (page - 1) * PAGE_SIZE + const end = page * PAGE_SIZE + return currentResults.slice(start, end) + } + + const parseMarkdown = (markdown) => { + if (!markdown) return '' + markdown = markdown.replace(/\*\*(.*?)\*\*/g, '$1') + markdown = markdown.replace(/\*(.*?)\*/g, '$1') + markdown = markdown.replace(/\[(.*?)\]\((.*?)\)/g, '$1') + markdown = markdown.replace(/\n/g, '
') + markdown = markdown.replace(/[#*_~`]/g, '') + return markdown + } + + const truncateDescription = (markdown, maxLength = 150) => { + const htmlDescription = parseMarkdown(markdown) + const tempDiv = document.createElement('div') + tempDiv.innerHTML = htmlDescription + const plainText = tempDiv.textContent || tempDiv.innerText || '' + if (plainText.length > maxLength) return plainText.substring(0, maxLength) + '...' + return plainText + } + + const formatArtistTitle = (title, totalPlays) => { + if (totalPlays > 0) return `${title} ${totalPlays} plays` + return `${title}` + } + + const renderSearchResults = (results) => { + if (results.length > 0) { + $results.innerHTML = results.map(({ title, url, description, type, total_plays }) => { + const truncatedDesc = truncateDescription(description) + let formattedTitle = title + + if (type === 'artist' && total_plays !== undefined) formattedTitle = formatArtistTitle(title, total_plays) + + return ` +
  • + +

    ${formattedTitle}

    +
    +

    ${truncatedDesc}

    +
  • + ` + }).join('\n') + $results.style.display = 'block' + } else { + $results.innerHTML = '
  • No results found.
  • ' + $results.style.display = 'block' + } + } + + const appendSearchResults = (results) => { + const newResults = results.map(({ title, url, description, type, total_plays }) => { + const truncatedDesc = truncateDescription(description) + let formattedTitle = title + + if (type === 'artist' && total_plays !== undefined) formattedTitle = formatArtistTitle(title, total_plays) + + return ` +
  • + +

    ${formattedTitle}

    +
    +

    ${truncatedDesc}

    +
  • + ` + }).join('\n') + $results.insertAdjacentHTML('beforeend', newResults) + } + })() +}) \ No newline at end of file