mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-04-23 03:01:03 -04:00
feat: global search refactor
global search refactor using endpoint
This commit is contained in:
106
app/api/search/route.ts
Normal file
106
app/api/search/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import algoliasearch from "algoliasearch"
|
||||
|
||||
const appId =
|
||||
process.env.ALGOLIA_APP_ID || process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || ""
|
||||
const apiKey =
|
||||
process.env.ALGOLIA_SEARCH_API_KEY ||
|
||||
process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY ||
|
||||
""
|
||||
const additionalIndexes = (
|
||||
process.env.ALGOLIA_ADDITIONAL_INDEXES ||
|
||||
process.env.NEXT_PUBLIC_ALGOLIA_ADDITIONAL_INDEXES ||
|
||||
""
|
||||
)
|
||||
.split(",")
|
||||
.map((index) => index.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const allIndexes = [...additionalIndexes].filter(Boolean) || [
|
||||
"blog",
|
||||
"projects",
|
||||
]
|
||||
const searchClient = appId && apiKey ? algoliasearch(appId, apiKey) : null
|
||||
|
||||
function transformQuery(query: string) {
|
||||
if (query.toLowerCase().includes("intmax")) {
|
||||
return query.replace(/intmax/i, '"intmax"')
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const query = searchParams.get("query") || ""
|
||||
const indexName = searchParams.get("index") || ""
|
||||
const hitsPerPage = parseInt(searchParams.get("hitsPerPage") || "5", 10)
|
||||
|
||||
if (!query || query.trim() === "") {
|
||||
return NextResponse.json({
|
||||
results: [],
|
||||
status: "empty",
|
||||
availableIndexes: allIndexes,
|
||||
})
|
||||
}
|
||||
|
||||
if (!searchClient) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Search client not initialized - missing Algolia credentials",
|
||||
availableIndexes: [],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const transformedQuery = transformQuery(query)
|
||||
|
||||
// If an index is specified, search only that index
|
||||
if (indexName && indexName.trim() !== "") {
|
||||
const index = searchClient.initIndex(indexName)
|
||||
const response = await index.search(transformedQuery, { hitsPerPage })
|
||||
|
||||
return NextResponse.json({
|
||||
hits: response.hits,
|
||||
status: "success",
|
||||
availableIndexes: allIndexes,
|
||||
})
|
||||
}
|
||||
|
||||
// Otherwise search across all configured indexes
|
||||
const searchPromises = allIndexes.map((idxName) => {
|
||||
return searchClient!
|
||||
.initIndex(idxName)
|
||||
.search(transformedQuery, { hitsPerPage })
|
||||
.then((response) => ({
|
||||
indexName: idxName,
|
||||
hits: response.hits,
|
||||
}))
|
||||
.catch((err) => {
|
||||
console.error(`Search error for index ${idxName}:`, err)
|
||||
return { indexName: idxName, hits: [] }
|
||||
})
|
||||
})
|
||||
|
||||
const indexResults = await Promise.all(searchPromises)
|
||||
const nonEmptyResults = indexResults.filter(
|
||||
(result) => result.hits && result.hits.length > 0
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
results: nonEmptyResults,
|
||||
status: "success",
|
||||
availableIndexes: allIndexes,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error("Global search error:", error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message || "Search failed",
|
||||
availableIndexes: [],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,13 @@
|
||||
|
||||
import { Search } from "lucide-react"
|
||||
import { Button, ButtonProps } from "@/components/ui/button"
|
||||
import { useEffect, useState } from "react"
|
||||
import { searchConfig } from "@/hooks/useGlobalSearch"
|
||||
import { useSearchConfig } from "@/hooks/useGlobalSearch"
|
||||
|
||||
interface SearchButtonProps extends ButtonProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const { allIndexes, searchClient } = searchConfig
|
||||
|
||||
export function SearchButton({ onClick, ...props }: SearchButtonProps) {
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!searchClient || allIndexes.length === 0) {
|
||||
console.warn(
|
||||
"Algolia credentials (NEXT_PUBLIC_ALGOLIA_APP_ID, NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY) or indexes are not configured. Search will not work."
|
||||
)
|
||||
setDisabled(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="search"
|
||||
@@ -29,7 +16,6 @@ export function SearchButton({ onClick, ...props }: SearchButtonProps) {
|
||||
onClick={onClick}
|
||||
aria-label="Open search"
|
||||
className="w-full text-left justify-start"
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -8,12 +8,10 @@ import {
|
||||
useGlobalSearch,
|
||||
useIndexSearch,
|
||||
filterSearchHitsByTerm,
|
||||
searchConfig,
|
||||
useSearchConfig,
|
||||
} from "@/hooks/useGlobalSearch"
|
||||
import { CategoryTag } from "../ui/categoryTag"
|
||||
|
||||
const { allIndexes, searchClient } = searchConfig
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
@@ -39,6 +37,11 @@ interface SearchHit {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface IndexResult {
|
||||
indexName: string
|
||||
hits: SearchHit[]
|
||||
}
|
||||
|
||||
function Hit({
|
||||
hit,
|
||||
setOpen,
|
||||
@@ -129,14 +132,14 @@ function DirectSearchResults({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.results.map((indexResult) => (
|
||||
{data.results.map((indexResult: IndexResult) => (
|
||||
<div key={indexResult.indexName}>
|
||||
{indexResult.hits.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
{indexResult.indexName}
|
||||
</div>
|
||||
{indexResult.hits.map((hit) => (
|
||||
{indexResult.hits.map((hit: SearchHit) => (
|
||||
<Hit
|
||||
key={hit.objectID}
|
||||
hit={hit}
|
||||
@@ -220,7 +223,7 @@ function IndexSearchResults({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hits.map((hit) => (
|
||||
{hits.map((hit: SearchHit) => (
|
||||
<Hit
|
||||
key={hit.objectID}
|
||||
hit={hit}
|
||||
@@ -232,6 +235,34 @@ function IndexSearchResults({
|
||||
)
|
||||
}
|
||||
|
||||
const CustomSearchResult = ({
|
||||
results,
|
||||
setOpen,
|
||||
}: {
|
||||
results: IndexResult[]
|
||||
setOpen: (open: boolean) => void
|
||||
}) => (
|
||||
<div>
|
||||
{results.map((indexResult: IndexResult) => (
|
||||
<div key={indexResult.indexName} className="mb-6">
|
||||
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
{indexResult.indexName.replace(/-/g, " ")}
|
||||
</div>
|
||||
<div>
|
||||
{indexResult.hits.map((hit: SearchHit) => (
|
||||
<Hit
|
||||
key={hit.objectID}
|
||||
hit={hit}
|
||||
setOpen={setOpen}
|
||||
indexName={indexResult.indexName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const MultiIndexSearchView = ({
|
||||
searchQuery,
|
||||
setOpen,
|
||||
@@ -239,6 +270,8 @@ const MultiIndexSearchView = ({
|
||||
searchQuery: string
|
||||
setOpen: (open: boolean) => void
|
||||
}) => {
|
||||
const { allIndexes } = useSearchConfig()
|
||||
|
||||
if (searchQuery.trim() === "") {
|
||||
return (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
@@ -249,7 +282,7 @@ const MultiIndexSearchView = ({
|
||||
|
||||
// Filter out empty indexes to prevent rendering issues
|
||||
const visibleIndexes = allIndexes.filter(
|
||||
(index) => index && index.trim() !== ""
|
||||
(index: string) => index && index.trim() !== ""
|
||||
)
|
||||
|
||||
if (visibleIndexes.length === 0) {
|
||||
@@ -258,7 +291,7 @@ const MultiIndexSearchView = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{visibleIndexes.map((indexName) => (
|
||||
{visibleIndexes.map((indexName: string) => (
|
||||
<div key={indexName} className="mb-6">
|
||||
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
{indexName.replace(/-/g, " ")}
|
||||
@@ -277,15 +310,7 @@ const MultiIndexSearchView = ({
|
||||
export const SearchModal = ({ open, setOpen }: SearchModalProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [directSearchMode, setDirectSearchMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchClient || allIndexes.length === 0) {
|
||||
console.warn(
|
||||
"Algolia credentials (NEXT_PUBLIC_ALGOLIA_APP_ID, NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY) or indexes are not configured. Search will not work."
|
||||
)
|
||||
setDirectSearchMode(true)
|
||||
}
|
||||
}, [])
|
||||
const { allIndexes } = useSearchConfig()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -310,7 +335,7 @@ export const SearchModal = ({ open, setOpen }: SearchModalProps) => {
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!searchClient || allIndexes.length === 0) {
|
||||
if (allIndexes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import algoliasearch, { SearchIndex } from "algoliasearch/lite"
|
||||
|
||||
type SearchHit = {
|
||||
objectID: string
|
||||
@@ -26,26 +25,27 @@ type IndexResult = {
|
||||
hits: SearchHit[]
|
||||
}
|
||||
|
||||
const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || ""
|
||||
const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || ""
|
||||
const additionalIndexes = process.env.NEXT_PUBLIC_ALGOLIA_ADDITIONAL_INDEXES
|
||||
? process.env.NEXT_PUBLIC_ALGOLIA_ADDITIONAL_INDEXES.split(",").map((index) =>
|
||||
index.trim()
|
||||
)
|
||||
: []
|
||||
// Fetch available indexes from the API
|
||||
export const useSearchIndexes = () => {
|
||||
return useQuery({
|
||||
queryKey: ["searchIndexes"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch("/api/search/indexes")
|
||||
|
||||
const allIndexes = [...additionalIndexes].filter(Boolean) ?? [
|
||||
"blog",
|
||||
"projects",
|
||||
]
|
||||
if (!response.ok) {
|
||||
return { indexes: ["blog", "projects"] }
|
||||
}
|
||||
|
||||
const searchClient = appId && apiKey ? algoliasearch(appId, apiKey) : null
|
||||
|
||||
const transformQuery = (query: string) => {
|
||||
if (query.toLowerCase().includes("intmax")) {
|
||||
return query.replace(/intmax/i, '"intmax"')
|
||||
}
|
||||
return query
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch search indexes:", error)
|
||||
return { indexes: ["blog", "projects"] }
|
||||
}
|
||||
},
|
||||
staleTime: 1000 * 60 * 60, // Cache for 1 hour
|
||||
})
|
||||
}
|
||||
|
||||
export const useGlobalSearch = ({
|
||||
@@ -55,6 +55,9 @@ export const useGlobalSearch = ({
|
||||
query: string
|
||||
hitsPerPage?: number
|
||||
}) => {
|
||||
const { data: indexData } = useSearchIndexes()
|
||||
const allIndexes = indexData?.indexes || []
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["globalSearch", query, hitsPerPage, allIndexes],
|
||||
queryFn: async () => {
|
||||
@@ -62,44 +65,26 @@ export const useGlobalSearch = ({
|
||||
return { results: [], status: "empty" }
|
||||
}
|
||||
|
||||
if (!searchClient) {
|
||||
throw new Error(
|
||||
"Search client not initialized - missing Algolia credentials"
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const transformedQuery = transformQuery(query)
|
||||
|
||||
const searchPromises = allIndexes.map((indexName) => {
|
||||
return searchClient
|
||||
.initIndex(indexName)
|
||||
.search<SearchHit>(transformedQuery, { hitsPerPage })
|
||||
.then((response) => ({
|
||||
indexName,
|
||||
hits: response.hits,
|
||||
}))
|
||||
.catch((err) => {
|
||||
console.error(`Search error for index ${indexName}:`, err)
|
||||
return { indexName, hits: [] as SearchHit[] }
|
||||
})
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
hitsPerPage: hitsPerPage.toString(),
|
||||
})
|
||||
|
||||
const indexResults = await Promise.all(searchPromises)
|
||||
const nonEmptyResults = indexResults.filter(
|
||||
(result) => result.hits && result.hits.length > 0
|
||||
)
|
||||
const response = await fetch(`/api/search?${params.toString()}`)
|
||||
|
||||
return {
|
||||
results: nonEmptyResults,
|
||||
status: "success",
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || "Search failed")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error: any) {
|
||||
console.error("Global search error:", error)
|
||||
throw new Error(error.message || "Search failed")
|
||||
}
|
||||
},
|
||||
enabled: Boolean(query) && query.trim() !== "" && Boolean(searchClient),
|
||||
enabled: Boolean(query) && query.trim() !== "",
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 2,
|
||||
})
|
||||
@@ -121,33 +106,27 @@ export const useIndexSearch = ({
|
||||
return { hits: [], status: "empty" }
|
||||
}
|
||||
|
||||
if (!searchClient) {
|
||||
throw new Error(
|
||||
"Search client not initialized - missing Algolia credentials"
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const transformedQuery = transformQuery(query)
|
||||
const index = searchClient.initIndex(indexName)
|
||||
const response = await index.search<SearchHit>(transformedQuery, {
|
||||
hitsPerPage,
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
index: indexName,
|
||||
hitsPerPage: hitsPerPage.toString(),
|
||||
})
|
||||
|
||||
return {
|
||||
hits: response.hits,
|
||||
status: "success",
|
||||
const response = await fetch(`/api/search?${params.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || `Search in ${indexName} failed`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error: any) {
|
||||
console.error(`Index search error for ${indexName}:`, error)
|
||||
throw new Error(error.message || `Search in ${indexName} failed`)
|
||||
}
|
||||
},
|
||||
enabled:
|
||||
Boolean(query) &&
|
||||
query.trim() !== "" &&
|
||||
Boolean(searchClient) &&
|
||||
Boolean(indexName),
|
||||
enabled: Boolean(query) && query.trim() !== "" && Boolean(indexName),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 2,
|
||||
})
|
||||
@@ -169,9 +148,10 @@ export const filterSearchHitsByTerm = (hits: SearchHit[], term: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const searchConfig = {
|
||||
allIndexes,
|
||||
appId,
|
||||
apiKey,
|
||||
searchClient,
|
||||
// Export a helper hook that ensures we have the indexes loaded
|
||||
export const useSearchConfig = () => {
|
||||
const { data } = useSearchIndexes()
|
||||
return {
|
||||
allIndexes: data?.indexes || [],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user