Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/api/search/indexes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"

// These should be the same indexes used in the search route
// to ensure consistency
const allIndexes = ["blog", "projects"]
const allIndexes = ["blog", "projects", "research"]

export async function GET() {
try {
Expand Down
173 changes: 86 additions & 87 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,10 @@
import algoliasearch from "algoliasearch"
import { NextRequest, NextResponse } from "next/server"
import { getArticles, getProjects } from "@/lib/content"
import { searchArticles, searchProjects } from "@/lib/search"

// Cache search results for better performance
export const revalidate = 900 // Revalidate cache after 15 minutes
export const revalidate = 300 // 5 minutes

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
}
const allIndexes = ["blog", "projects", "research"]

export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
Expand All @@ -46,79 +20,104 @@ export async function GET(request: NextRequest) {
})
}

if (!searchClient) {
return NextResponse.json(
{
error: "Search client not initialized - missing Algolia credentials",
availableIndexes: [],
},
{ status: 500 }
)
}

try {
const transformedQuery = transformQuery(query)
const results = []

// 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 })
// Search articles
if (!indexName || indexName === "blog") {
const articles = getArticles()
const matches = searchArticles(articles, query)
.slice(0, hitsPerPage)
.map((article: any) => ({
objectID: article.id,
title: article.title,
content: article.tldr || article.content.slice(0, 200),
url: `/blog/${article.id}`,
}))

return NextResponse.json(
{
hits: response.hits,
status: "success",
availableIndexes: allIndexes,
},
{
headers: {
"Cache-Control":
"public, s-maxage=900, stale-while-revalidate=1800",
},
}
)
if (matches.length > 0) {
results.push({
indexName: "blog",
hits: matches,
})
}
}

// 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: [] }
})
})
// Search projects (applications and devtools only)
if (!indexName || indexName === "projects") {
const allProjects = getProjects()
const projectsOnly = allProjects.filter(
(p: any) =>
p.category?.toLowerCase() === "application" ||
p.category?.toLowerCase() === "devtools"
)
const matches = searchProjects(projectsOnly, query)
.slice(0, hitsPerPage)
.map((project: any) => ({
objectID: project.id,
title: project.name || project.title,
description: project.description || project.tldr,
url: `/projects/${project.id}`,
}))

const indexResults = await Promise.all(searchPromises)
const nonEmptyResults = indexResults.filter(
(result) => result.hits && result.hits.length > 0
if (matches.length > 0) {
results.push({
indexName: "projects",
hits: matches,
})
}
}

// Search research (research category only)
if (!indexName || indexName === "research") {
const allProjects = getProjects()
const researchOnly = allProjects.filter(
(p: any) => p.category?.toLowerCase() === "research"
)
const matches = searchProjects(researchOnly, query)
.slice(0, hitsPerPage)
.map((project: any) => ({
objectID: project.id,
title: project.name || project.title,
description: project.description || project.tldr,
url: `/projects/${project.id}`,
}))

if (matches.length > 0) {
results.push({
indexName: "research",
hits: matches,
})
}
}

// If searching specific index, return single index format
if (indexName) {
const indexResult = results.find((r) => r.indexName === indexName)
return NextResponse.json(
{
results: nonEmptyResults,
hits: indexResult?.hits || [],
status: "success",
availableIndexes: allIndexes,
},
{
headers: {
"Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800",
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
},
}
)
} catch (error: any) {
console.error("Global search error:", error)
return NextResponse.json(
{
error: error.message || "Search failed",
availableIndexes: [],
},
{ status: 500 }
)
}

// Return multi-index format
return NextResponse.json(
{
results,
status: "success",
availableIndexes: allIndexes,
},
{
headers: {
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
},
}
)
}
4 changes: 0 additions & 4 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,6 @@ export default function RootLayout({ children }: RootLayoutProps) {

{/* External service optimization */}
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

{/* Algolia search preconnect for faster search */}
<link rel="preconnect" href="https://latency-dsn.algolia.net" />
<link rel="dns-prefetch" href="https://search.algolia.com" />
</head>
<body suppressHydrationWarning>
<GlobalProviderLayout>
Expand Down
81 changes: 25 additions & 56 deletions app/providers/ProjectsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { LABELS } from "@/app/labels"
import { ProjectCategory, ProjectInterface } from "@/lib/types"
import { uniq } from "@/lib/utils"
import { searchProjects as fuseSearchProjects } from "@/lib/search"
import { useQuery } from "@tanstack/react-query"
import Fuse from "fuse.js"
import {
createContext,
useContext,
Expand Down Expand Up @@ -91,74 +91,43 @@ const filterProjects = ({
findAnyMatch?: boolean
projects?: ProjectInterface[]
}) => {
const projectList = projectListItems.map((project: any) => ({
let projectList = projectListItems.map((project: any) => ({
...project,
id: project?.id?.toLowerCase(),
}))

const keys = [
"name",
"tldr",
"tags.themes",
"tags.keywords",
"tags.builtWith",
"projectStatus",
]
const noActiveFilters =
Object.keys(activeFilters).length === 0 && searchPattern.length === 0
if (noActiveFilters) return projectList

// Apply tag filters first
projectList = projectList.filter((project: any) => {
return Object.entries(activeFilters).every(([filterKey, filterValues]) => {
if (!filterValues || filterValues.length === 0) return true

const tagsFiltersQuery: Record<string, string>[] = []
const projectTags = project.tags?.[filterKey] || []

Object.entries(activeFilters).forEach(([key, values]) => {
values.forEach((value) => {
if (!value) return
tagsFiltersQuery.push({
[`tags.${key}`]: value,
return filterValues.some((filterValue: string) => {
if (Array.isArray(projectTags)) {
return projectTags.some((tag: string) =>
tag.toLowerCase() === filterValue.toLowerCase()
)
}
return false
})
})
})

const noActiveFilters =
tagsFiltersQuery.length === 0 && searchPattern.length === 0
if (noActiveFilters) return projectList

let query: any = {}

if (findAnyMatch) {
query = {
$or: [...tagsFiltersQuery, { name: searchPattern }],
}
} else if (searchPattern?.length === 0) {
query = {
$and: [...tagsFiltersQuery],
}
} else if (tagsFiltersQuery.length === 0) {
query = {
name: searchPattern,
}
} else {
query = {
$and: [
{
$and: [...tagsFiltersQuery],
},
{ name: searchPattern },
],
}
// Apply text search
if (searchPattern.length > 0) {
projectList = fuseSearchProjects(projectList, searchPattern)
}

const fuse = new Fuse(projectList, {
threshold: 0.3,
useExtendedSearch: true,
includeScore: true,
findAllMatches: true,
distance: 200,
keys,
})

const result = fuse.search(query)?.map(({ item, score }) => ({
...item,
score,
// Add score for sorting
return projectList.map((project: any) => ({
...project,
score: 0,
}))
return result ?? []
}

const sortProjectByFn = ({
Expand Down
12 changes: 2 additions & 10 deletions components/blog/articles-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ArticleInEvidenceCard } from "./article-in-evidance-card"
import { ArticleListCard } from "./article-list-card"
import { LABELS } from "@/app/labels"
import { Article, ArticleTag } from "@/lib/content"
import { searchArticles } from "@/lib/search"
import { useQuery } from "@tanstack/react-query"
import { Search as SearchIcon } from "lucide-react"
import { useRouter, useSearchParams } from "next/navigation"
Expand Down Expand Up @@ -77,16 +78,7 @@ export const ArticlesList: React.FC<ArticlesListProps> = ({
if (searchQuery === "all") {
otherArticles = articles
} else if (searchQuery?.length > 0) {
otherArticles = articles.filter((article: Article) => {
const title = article.title.toLowerCase()
const content = article.content.toLowerCase()
const tags =
article.tags?.map((tag: ArticleTag) => tag.name.toLowerCase()) ?? []
return (
title.includes(searchQuery.toLowerCase()) ||
tags.some((tag: string) => tag.includes(searchQuery.toLowerCase()))
)
})
otherArticles = searchArticles(articles, searchQuery)
}

const hasTag = tag !== undefined
Expand Down
Loading