feat: remote search index
This commit is contained in:
parent
e7389ce4c0
commit
193366e946
8 changed files with 186 additions and 170 deletions
26
package-lock.json
generated
26
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "coryd.dev",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "coryd.dev",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cdransf/api-text": "^1.5.0",
|
||||
|
@ -21,7 +21,7 @@
|
|||
"@11ty/eleventy": "v3.0.0",
|
||||
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0",
|
||||
"@cdransf/eleventy-plugin-tabler-icons": "^2.0.3",
|
||||
"@supabase/supabase-js": "^2.45.5",
|
||||
"@supabase/supabase-js": "^2.45.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cssnano": "^7.0.6",
|
||||
"dotenv-flow": "^4.1.0",
|
||||
|
@ -614,9 +614,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.2.tgz",
|
||||
"integrity": "sha512-dA/CIrSO2YDQ6ABNpbvEg9DwBMMbuKfWaFuZAU9c66PenoLSoIoyXk1Yq/wC2XISgEIqaMHmTrDAAsO80kjHqg==",
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.3.tgz",
|
||||
"integrity": "sha512-HI6dsbW68AKlOPofUjDTaosiDBCtW4XAm0D18pPwxoW3zKOE2Ru13Z69Wuys9fd6iTpfDViNco5sgrtnP0666A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -647,16 +647,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.45.5",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.45.5.tgz",
|
||||
"integrity": "sha512-xTPsv33Hcj6C38SXa4nKobwEwkNQuwcCKtcuBsDT6bvphl1VUAO3x2QoLOuuglJzk2Oaf3WcVsvRcxXNE8PG/g==",
|
||||
"version": "2.45.6",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.45.6.tgz",
|
||||
"integrity": "sha512-qVXSSUhhIqdFnF2VUGgeecPvw1cDW6+avcTbRgur4LaGnzrJCbM3Rx7g81/SSZjjeqYOtmHuKWhiHzV/EN8Ktw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.65.1",
|
||||
"@supabase/functions-js": "2.4.3",
|
||||
"@supabase/node-fetch": "2.6.15",
|
||||
"@supabase/postgrest-js": "1.16.2",
|
||||
"@supabase/postgrest-js": "1.16.3",
|
||||
"@supabase/realtime-js": "2.10.7",
|
||||
"@supabase/storage-js": "2.7.1"
|
||||
}
|
||||
|
@ -709,9 +709,9 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz",
|
||||
"integrity": "sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw==",
|
||||
"version": "22.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.7.tgz",
|
||||
"integrity": "sha512-SRxCrrg9CL/y54aiMCG3edPKdprgMVGDXjA3gB8UmmBW5TcXzRUYAh8EWzTnSJFAd1rgImPELza+A3bJ+qxz8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "coryd.dev",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "The source for my personal site. Built using 11ty (and other tools).",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
@ -39,7 +39,7 @@
|
|||
"@11ty/eleventy": "v3.0.0",
|
||||
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0",
|
||||
"@cdransf/eleventy-plugin-tabler-icons": "^2.0.3",
|
||||
"@supabase/supabase-js": "^2.45.5",
|
||||
"@supabase/supabase-js": "^2.45.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cssnano": "^7.0.6",
|
||||
"dotenv-flow": "^4.1.0",
|
||||
|
|
|
@ -85,12 +85,11 @@ window.addEventListener('load', () => {
|
|||
})
|
||||
})()
|
||||
|
||||
// search logic
|
||||
;(() => {
|
||||
if (!MiniSearch) return
|
||||
const miniSearch = new MiniSearch({
|
||||
fields: ['title', 'text', 'tags', 'type'],
|
||||
})
|
||||
|
||||
const miniSearch = new MiniSearch({ fields: ['title', 'description', 'tags', 'type'] })
|
||||
const $form = document.querySelector('.search__form')
|
||||
const $input = document.querySelector('.search__form--input')
|
||||
const $fallback = document.querySelector('.search__form--fallback')
|
||||
|
@ -105,47 +104,108 @@ window.addEventListener('load', () => {
|
|||
const PAGE_SIZE = 10
|
||||
let currentPage = 1
|
||||
let currentResults = []
|
||||
|
||||
const loadSearchIndex = async () => {
|
||||
try {
|
||||
const response = await fetch('https://coryd.dev/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)
|
||||
const parseMarkdown = markdown => {
|
||||
if (!markdown) return ''
|
||||
return markdown
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2">$1</a>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/[#*_~`]/g, '')
|
||||
}
|
||||
|
||||
$input.addEventListener('input', () => {
|
||||
const query = $input.value
|
||||
const truncateDescription = (markdown, maxLength = 150) => {
|
||||
const htmlDescription = parseMarkdown(markdown)
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = htmlDescription
|
||||
const plainText = tempDiv.textContent || tempDiv.innerText || ''
|
||||
return plainText.length > maxLength ? `${plainText.substring(0, maxLength)}...` : plainText
|
||||
}
|
||||
|
||||
const formatArtistTitle = (title, totalPlays) =>
|
||||
totalPlays > 0 ? `${title} <strong class="highlight-text">${totalPlays} plays</strong>` : title
|
||||
|
||||
const renderSearchResults = results => {
|
||||
if (results.length > 0) {
|
||||
$results.innerHTML = results.map(({ title, url, description, type, total_plays }) => {
|
||||
const truncatedDesc = truncateDescription(description)
|
||||
const formattedTitle = type === 'artist' && total_plays !== undefined
|
||||
? formatArtistTitle(title, total_plays)
|
||||
: title
|
||||
|
||||
return `
|
||||
<li class="search__results--result">
|
||||
<a href="${url}">
|
||||
<h3>${formattedTitle}</h3>
|
||||
</a>
|
||||
<p>${truncatedDesc}</p>
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
$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)
|
||||
const formattedTitle = type === 'artist' && total_plays !== undefined
|
||||
? formatArtistTitle(title, total_plays)
|
||||
: title
|
||||
|
||||
return `
|
||||
<li class="search__results--result">
|
||||
<a href="${url}">
|
||||
<h3>${formattedTitle}</h3>
|
||||
</a>
|
||||
<p>${truncatedDesc}</p>
|
||||
</li>
|
||||
`
|
||||
}).join('')
|
||||
$results.insertAdjacentHTML('beforeend', newResults)
|
||||
}
|
||||
|
||||
const loadSearchIndex = async (query = '', types = []) => {
|
||||
const typeQuery = types.join(',')
|
||||
const response = await fetch(`https://coryd.dev/api/search-beta?q=${query}&type=${typeQuery}`)
|
||||
const index = await response.json()
|
||||
|
||||
resultsById = index.reduce((byId, result) => {
|
||||
byId[result.id] = result
|
||||
return byId
|
||||
}, {})
|
||||
|
||||
miniSearch.removeAll()
|
||||
miniSearch.addAll(index)
|
||||
return resultsById
|
||||
}
|
||||
|
||||
loadSearchIndex().then(loadedResultsById => resultsById = loadedResultsById)
|
||||
|
||||
const getSelectedTypes = () =>
|
||||
Array.from($typeCheckboxes)
|
||||
.filter(checkbox => checkbox.checked)
|
||||
.map(checkbox => checkbox.value)
|
||||
|
||||
$input.addEventListener('input', () => {
|
||||
const query = $input.value.trim()
|
||||
clearTimeout(debounceTimeout)
|
||||
|
||||
if (query.length === 0) {
|
||||
if (!query) {
|
||||
renderSearchResults([])
|
||||
$loadMoreButton.style.display = 'none'
|
||||
return
|
||||
}
|
||||
|
||||
debounceTimeout = setTimeout(() => {
|
||||
const results = (query.length > 1) ? getSearchResults(query) : []
|
||||
debounceTimeout = setTimeout(async () => {
|
||||
resultsById = await loadSearchIndex(query, getSelectedTypes())
|
||||
const results = getSearchResults(query)
|
||||
currentResults = results
|
||||
currentPage = 1
|
||||
|
||||
|
@ -154,14 +214,10 @@ window.addEventListener('load', () => {
|
|||
}, 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)
|
||||
checkbox.addEventListener('change', async () => {
|
||||
resultsById = await loadSearchIndex($input.value.trim(), getSelectedTypes())
|
||||
const results = getSearchResults($input.value.trim())
|
||||
currentResults = results
|
||||
currentPage = 1
|
||||
|
||||
|
@ -178,85 +234,12 @@ window.addEventListener('load', () => {
|
|||
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 } })
|
||||
const getSearchResults = query =>
|
||||
miniSearch.search(query, { prefix: true, fuzzy: 0.2, boost: { title: 2 } })
|
||||
.map(({ id }) => resultsById[id])
|
||||
.filter(result => selectedTypes.includes(result.type))
|
||||
}
|
||||
.filter(result => getSelectedTypes().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)
|
||||
}
|
||||
const getResultsForPage = page =>
|
||||
currentResults.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
})()
|
||||
})
|
|
@ -1,19 +0,0 @@
|
|||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL
|
||||
const SUPABASE_KEY = process.env.SUPABASE_KEY
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY)
|
||||
|
||||
export default async function fetchSearchIndex() {
|
||||
const { data, error } = await supabase
|
||||
.from('optimized_search_index')
|
||||
.select('search_index')
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching search index data:', error)
|
||||
return []
|
||||
}
|
||||
|
||||
const [{ search_index } = {}] = data
|
||||
return search_index || []
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
---
|
||||
permalink: "/api/search"
|
||||
---
|
||||
{{ search | json }}
|
|
@ -62,9 +62,7 @@ WITH search_data AS (
|
|||
|
||||
SELECT
|
||||
'artist' AS type,
|
||||
CONCAT(
|
||||
COALESCE(ar.emoji, ar.genre_emoji, '🎧'), ' ', ar.name
|
||||
) AS title,
|
||||
CONCAT(COALESCE(ar.emoji, ar.genre_emoji, '🎧'), ' ', ar.name) AS title,
|
||||
CONCAT('https://coryd.dev', ar.url) AS url,
|
||||
ar.description AS description,
|
||||
ARRAY[ar.genre_name] AS tags,
|
||||
|
@ -110,17 +108,13 @@ search_data_with_id AS (
|
|||
FROM search_data
|
||||
)
|
||||
SELECT
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', search_data_with_id.id,
|
||||
'url', search_data_with_id.url,
|
||||
'title', search_data_with_id.title,
|
||||
'description', search_data_with_id.description,
|
||||
'tags', search_data_with_id.tags,
|
||||
'genre_name', search_data_with_id.genre_name,
|
||||
'genre_url', search_data_with_id.genre_url,
|
||||
'type', search_data_with_id.type,
|
||||
'total_plays', search_data_with_id.total_plays
|
||||
)
|
||||
) AS search_index
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
tags,
|
||||
genre_name,
|
||||
genre_url,
|
||||
type,
|
||||
total_plays
|
||||
FROM search_data_with_id;
|
50
workers/search/index.js
Normal file
50
workers/search/index.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const allowedOrigin = 'https://coryd.dev'
|
||||
const origin = request.headers.get('Origin') || ''
|
||||
const referer = request.headers.get('Referer') || ''
|
||||
|
||||
if (!origin.startsWith(allowedOrigin) && !referer.startsWith(allowedOrigin)) return new Response('Forbidden', { status: 403 })
|
||||
|
||||
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL
|
||||
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY
|
||||
const supabase = createClient(supabaseUrl, supabaseKey)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = searchParams.get('q') || ''
|
||||
const types = searchParams.get('type')?.split(',') || []
|
||||
const page = parseInt(searchParams.get('page')) || 1
|
||||
const pageSize = parseInt(searchParams.get('page_size')) || 10
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
try {
|
||||
let supabaseQuery = supabase
|
||||
.from('optimized_search_index')
|
||||
.select('*', { count: 'exact' })
|
||||
|
||||
if (types.length > 0) supabaseQuery = supabaseQuery.in('type', types)
|
||||
if (query) supabaseQuery = supabaseQuery.or(`title.ilike.%${query}%,description.ilike.%${query}%`)
|
||||
|
||||
const { data, error, count } = await supabaseQuery.range(offset, offset + pageSize - 1)
|
||||
|
||||
if (error) {
|
||||
console.error('Query error:', error)
|
||||
return new Response('Error fetching data from Supabase', { status: 500 })
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ results: data, total: count, page, pageSize }),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Unexpected error:', error)
|
||||
return new Response('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
12
workers/search/wrangler.template.toml
Normal file
12
workers/search/wrangler.template.toml
Normal file
|
@ -0,0 +1,12 @@
|
|||
name = "search-worker"
|
||||
main = "./index.js"
|
||||
compatibility_date = "2023-01-01"
|
||||
|
||||
account_id = "${CF_ACCOUNT_ID}"
|
||||
workers_dev = true
|
||||
|
||||
[env.production]
|
||||
name = "search-worker-production"
|
||||
routes = [
|
||||
{ pattern = "coryd.dev/api/search*", zone_id = "${CF_ZONE_ID}" },
|
||||
]
|
Reference in a new issue