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.

+ )} +
+ ) +}