From 0c728b15587539228ad2aebcc9d1a07a57d6b782 Mon Sep 17 00:00:00 2001
From: Cory Dransfeldt <hi@coryd.dev>
Date: Wed, 16 Oct 2024 10:24:22 -0700
Subject: [PATCH] chore: update deps

---
 package-lock.json            |  32 +++----
 package.json                 |   2 +-
 src/assets/scripts/search.js | 176 +++++++++++++++++++++++++++++++++++
 3 files changed, 193 insertions(+), 17 deletions(-)
 create mode 100644 src/assets/scripts/search.js

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, '<strong>$1</strong>')
+      markdown = markdown.replace(/\*(.*?)\*/g, '<em>$1</em>')
+      markdown = markdown.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2">$1</a>')
+      markdown = markdown.replace(/\n/g, '<br>')
+      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} <strong class="highlight-text">${totalPlays} plays</strong>`
+      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 `
+            <li class="search__results--result">
+              <a href="${url}">
+                <h3>${formattedTitle}</h3>
+              </a>
+              <p>${truncatedDesc}</p>
+            </li>
+          `
+        }).join('\n')
+        $results.style.display = 'block'
+      } else {
+        $results.innerHTML = '<li class="search__results--no-results">No results found.</li>'
+        $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 `
+          <li class="search__results--result">
+            <a href="${url}">
+              <h3>${formattedTitle}</h3>
+            </a>
+            <p>${truncatedDesc}</p>
+          </li>
+        `
+      }).join('\n')
+      $results.insertAdjacentHTML('beforeend', newResults)
+    }
+  })()
+})
\ No newline at end of file