238 lines
7.6 KiB
TypeScript
238 lines
7.6 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState } from 'react'
|
|
import { PostCard } from './PostCard'
|
|
|
|
type SearchPost = {
|
|
slug: string
|
|
title: string
|
|
date: string
|
|
excerpt: string
|
|
tags: string[]
|
|
author: string | null
|
|
coverImage: string | null
|
|
readingTime: number
|
|
}
|
|
|
|
type TagFilter = {
|
|
slug: string
|
|
label: string
|
|
count: number
|
|
}
|
|
|
|
type ScoredPost = {
|
|
post: SearchPost
|
|
score: number
|
|
index: number
|
|
}
|
|
|
|
interface PostSearchProps {
|
|
posts: SearchPost[]
|
|
}
|
|
|
|
const normalizeSearchText = (value: string): string =>
|
|
value
|
|
.normalize('NFKD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.trim()
|
|
|
|
const tagToClientSlug = (tag: string): string =>
|
|
normalizeSearchText(tag)
|
|
.replace(/\+/g, ' plus ')
|
|
.replace(/#/g, ' sharp ')
|
|
.replace(/[\\/]+/g, ' ')
|
|
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
.replace(/-{2,}/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
|
|
const getOrderedCharacterScore = (field: string, query: string): number => {
|
|
if (!query) return 0
|
|
|
|
let queryIndex = 0
|
|
let gaps = 0
|
|
let lastMatchIndex = -1
|
|
|
|
for (let fieldIndex = 0; fieldIndex < field.length && queryIndex < query.length; fieldIndex += 1) {
|
|
if (field[fieldIndex] !== query[queryIndex]) continue
|
|
|
|
if (lastMatchIndex >= 0) gaps += fieldIndex - lastMatchIndex - 1
|
|
lastMatchIndex = fieldIndex
|
|
queryIndex += 1
|
|
}
|
|
|
|
if (queryIndex !== query.length) return 0
|
|
|
|
return Math.max(1, 80 - gaps - Math.max(0, field.length - query.length) * 0.05)
|
|
}
|
|
|
|
const scoreField = (field: string, query: string, weight: number): number => {
|
|
if (!field || !query) return 0
|
|
|
|
if (field === query) return 1000 * weight
|
|
if (field.startsWith(query)) return 700 * weight + query.length
|
|
if (field.includes(query)) return 500 * weight + query.length
|
|
|
|
return getOrderedCharacterScore(field, query) * weight
|
|
}
|
|
|
|
const scorePost = (post: SearchPost, query: string): number => {
|
|
if (!query) return 0
|
|
|
|
const fields = [
|
|
[post.title, 1.4],
|
|
[post.slug, 1.1],
|
|
[post.excerpt, 0.8],
|
|
[post.author ?? '', 0.75],
|
|
[post.tags.join(' '), 1],
|
|
] as const
|
|
|
|
return fields.reduce(
|
|
(score, [field, weight]) => score + scoreField(normalizeSearchText(field), query, weight),
|
|
0
|
|
)
|
|
}
|
|
|
|
const getTagFilters = (posts: SearchPost[]): TagFilter[] => {
|
|
const tags = new Map<string, TagFilter>()
|
|
|
|
posts.forEach((post) => {
|
|
const postTags = new Set<string>()
|
|
|
|
post.tags.forEach((tag) => {
|
|
const slug = tagToClientSlug(tag)
|
|
if (!slug || postTags.has(slug)) return
|
|
|
|
postTags.add(slug)
|
|
const existing = tags.get(slug)
|
|
|
|
if (existing) {
|
|
existing.count += 1
|
|
if (tag.localeCompare(existing.label) < 0) existing.label = tag
|
|
return
|
|
}
|
|
|
|
tags.set(slug, { slug, label: tag, count: 1 })
|
|
})
|
|
})
|
|
|
|
return Array.from(tags.values()).sort(
|
|
(a, b) => a.label.localeCompare(b.label) || a.slug.localeCompare(b.slug)
|
|
)
|
|
}
|
|
|
|
export function PostSearch({ posts }: PostSearchProps) {
|
|
const [query, setQuery] = useState('')
|
|
const [selectedTag, setSelectedTag] = useState<string | null>(null)
|
|
|
|
const tagFilters = useMemo(() => getTagFilters(posts), [posts])
|
|
const normalizedQuery = normalizeSearchText(query)
|
|
|
|
const results = useMemo(() => {
|
|
const scoredPosts = posts.reduce<ScoredPost[]>((matches, post, index) => {
|
|
if (selectedTag && !post.tags.some((tag) => tagToClientSlug(tag) === selectedTag)) {
|
|
return matches
|
|
}
|
|
|
|
const score = scorePost(post, normalizedQuery)
|
|
if (normalizedQuery && score <= 0) return matches
|
|
|
|
matches.push({ post, score, index })
|
|
return matches
|
|
}, [])
|
|
|
|
return scoredPosts
|
|
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
.map(({ post }) => post)
|
|
}, [normalizedQuery, posts, selectedTag])
|
|
|
|
if (posts.length === 0) {
|
|
return <p className="text-ink-soft">No posts yet.</p>
|
|
}
|
|
|
|
const hasFilters = Boolean(normalizedQuery || selectedTag)
|
|
const selectedTagLabel = tagFilters.find((tag) => tag.slug === selectedTag)?.label
|
|
|
|
return (
|
|
<section aria-labelledby="posts-search-heading" className="space-y-8">
|
|
<div className="space-y-5 rounded-xl border border-border bg-surface/30 p-4 sm:p-5">
|
|
<div className="space-y-2">
|
|
<label id="posts-search-heading" htmlFor="post-search" className="block text-sm font-medium text-ink">
|
|
Search posts
|
|
</label>
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
<input
|
|
id="post-search"
|
|
type="search"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search title, excerpt, tag, author, or slug"
|
|
className="min-w-0 flex-1 rounded-lg border border-border bg-canvas px-3 py-2 text-sm text-ink outline-none transition-colors placeholder:text-ink-soft/70 focus:border-accent focus:ring-2 focus:ring-accent/20"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setQuery('')}
|
|
disabled={!query}
|
|
className="rounded-lg border border-border px-3 py-2 text-sm text-ink-soft transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
Clear search
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{tagFilters.length > 0 && (
|
|
<div className="space-y-2" aria-label="Filter posts by tag">
|
|
<p className="text-sm font-medium text-ink">Filter by tag</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{tagFilters.map((tag) => {
|
|
const isSelected = selectedTag === tag.slug
|
|
|
|
return (
|
|
<button
|
|
key={tag.slug}
|
|
type="button"
|
|
aria-pressed={isSelected}
|
|
onClick={() => setSelectedTag(isSelected ? null : tag.slug)}
|
|
className="rounded-full border border-border bg-canvas px-3 py-1 text-xs text-ink-soft transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent aria-pressed:border-accent aria-pressed:bg-accent/10 aria-pressed:text-accent"
|
|
>
|
|
{tag.label} <span className="font-mono text-ink-soft/70">{tag.count}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-2 text-sm text-ink-soft sm:flex-row sm:items-center sm:justify-between">
|
|
<p aria-live="polite">
|
|
{results.length} {results.length === 1 ? 'post' : 'posts'} found
|
|
{selectedTagLabel ? ` in ${selectedTagLabel}` : ''}
|
|
</p>
|
|
{hasFilters && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setQuery('')
|
|
setSelectedTag(null)
|
|
}}
|
|
className="self-start rounded-lg border border-border px-3 py-1.5 text-sm text-ink-soft transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent sm:self-auto"
|
|
>
|
|
Clear filters
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{results.length > 0 ? (
|
|
<div className="space-y-12">
|
|
{results.map((post, index) => (
|
|
<PostCard key={post.slug} {...post} index={index} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-ink-soft">No posts match your search.</p>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|