'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() posts.forEach((post) => { const postTags = new Set() 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(null) const tagFilters = useMemo(() => getTagFilters(posts), [posts]) const normalizedQuery = normalizeSearchText(query) const results = useMemo(() => { const scoredPosts = posts.reduce((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

No posts yet.

} const hasFilters = Boolean(normalizedQuery || selectedTag) const selectedTagLabel = tagFilters.find((tag) => tag.slug === selectedTag)?.label return (
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" />
{tagFilters.length > 0 && (

Filter by tag

{tagFilters.map((tag) => { const isSelected = selectedTag === tag.slug return ( ) })}
)}

{results.length} {results.length === 1 ? 'post' : 'posts'} found {selectedTagLabel ? ` in ${selectedTagLabel}` : ''}

{hasFilters && ( )}
{results.length > 0 ? (
{results.map((post, index) => ( ))}
) : (

No posts match your search.

)}
) }