diff --git a/app/posts/page.tsx b/app/posts/page.tsx
index 9523f55..7d04b71 100644
--- a/app/posts/page.tsx
+++ b/app/posts/page.tsx
@@ -1,5 +1,5 @@
import { getPosts } from '@/lib/posts'
-import { PostCard } from '@/components/blog/PostCard'
+import { PostSearch } from '@/components/blog/PostSearch'
import Template from '../template'
export const metadata = { title: 'Posts' }
@@ -13,14 +13,7 @@ export default async function PostsPage() {
Posts
-
- {posts.map((post) => (
-
- ))}
- {posts.length === 0 && (
-
No posts yet.
- )}
-
+
)
diff --git a/components/blog/PostSearch.tsx b/components/blog/PostSearch.tsx
new file mode 100644
index 0000000..0ddf554
--- /dev/null
+++ b/components/blog/PostSearch.tsx
@@ -0,0 +1,237 @@
+'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.
+ )}
+
+ )
+}