diff --git a/package.json b/package.json index 2caf356..7c021a4 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "astro": "^5.16.4", + "lunr": "^2.3.9", "marked": "^17.0.1", "react": "^19.2.1", "react-dom": "^19.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f20e465..4ecbeba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: astro: specifier: ^5.16.4 version: 5.16.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3) + lunr: + specifier: ^2.3.9 + version: 2.3.9 marked: specifier: ^17.0.1 version: 17.0.1 @@ -1960,6 +1963,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4626,6 +4632,8 @@ snapshots: dependencies: yallist: 3.1.1 + lunr@2.3.9: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/src/components/BlogCard.astro b/src/components/BlogCard.astro index 9d2bbe3..2844f0b 100644 --- a/src/components/BlogCard.astro +++ b/src/components/BlogCard.astro @@ -11,6 +11,7 @@ interface Props { category?: string; tags?: string[]; href: string; + readTime?: string; variant?: 'default' | 'compact' | 'featured'; class?: string; } @@ -23,14 +24,11 @@ const { category, tags, href, + readTime = '5 min read', variant = 'default', class: className = '', } = Astro.props; -// Compute estimated read time (rough estimate: 200 words per minute) -// For now, we'll show a placeholder since we don't have word count in frontmatter -const readTime = '5 min read'; - const isCompact = variant === 'compact'; const isFeatured = variant === 'featured'; --- diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx new file mode 100644 index 0000000..b2c6617 --- /dev/null +++ b/src/components/SearchDialog.tsx @@ -0,0 +1,371 @@ +import { useEffect, useState, useRef } from 'react'; +import lunr from 'lunr'; + +interface SearchResult { + id: string; + title: string; + description: string; + category: string; + tags: string[]; + url: string; + pubDate: string; +} + +interface IndexedResult extends SearchResult { + score: number; +} + +export default function SearchDialog() { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [searchData, setSearchData] = useState([]); + const [searchIndex, setSearchIndex] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const inputRef = useRef(null); + const resultsRef = useRef(null); + + // Load search data and build index + useEffect(() => { + fetch('/search.json') + .then((res) => res.json()) + .then((data: SearchResult[]) => { + setSearchData(data); + + // Build Lunr index + const idx = lunr(function () { + this.ref('id'); + this.field('title', { boost: 10 }); + this.field('description', { boost: 5 }); + this.field('content'); + this.field('category', { boost: 3 }); + this.field('tags', { boost: 3 }); + + data.forEach((doc) => { + this.add(doc); + }); + }); + + setSearchIndex(idx); + setIsLoading(false); + }) + .catch((err) => { + console.error('Failed to load search data:', err); + setIsLoading(false); + }); + }, []); + + // Keyboard shortcut to open search + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setIsOpen(true); + } + + if (e.key === 'Escape' && isOpen) { + closeSearch(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen]); + + // Focus input when dialog opens + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + }, [isOpen]); + + // Real-time search + useEffect(() => { + if (!query.trim() || !searchIndex || !searchData) { + setResults([]); + setSelectedIndex(0); + return; + } + + try { + // Add wildcards for partial matching + const searchQuery = query + .trim() + .split(/\s+/) + .map((term) => `${term}* ${term}~1`) + .join(' '); + + const searchResults = searchIndex.search(searchQuery); + const matchedResults = searchResults + .map((result) => { + const data = searchData.find((d) => d.id === result.ref); + return data ? { ...data, score: result.score } : null; + }) + .filter((r): r is IndexedResult => r !== null) + .slice(0, 8); + + setResults(matchedResults); + setSelectedIndex(0); + } catch (err) { + // Fallback to simple search if query syntax is invalid + try { + const searchResults = searchIndex.search(query); + const matchedResults = searchResults + .map((result) => { + const data = searchData.find((d) => d.id === result.ref); + return data ? { ...data, score: result.score } : null; + }) + .filter((r): r is IndexedResult => r !== null) + .slice(0, 8); + + setResults(matchedResults); + setSelectedIndex(0); + } catch { + setResults([]); + } + } + }, [query, searchIndex, searchData]); + + const closeSearch = () => { + setIsOpen(false); + setQuery(''); + setResults([]); + setSelectedIndex(0); + }; + + // Keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter' && results[selectedIndex]) { + window.location.href = results[selectedIndex].url; + } + }; + + // Scroll selected item into view + useEffect(() => { + if (resultsRef.current && results.length > 0) { + const selectedElement = resultsRef.current.children[selectedIndex] as HTMLElement; + selectedElement?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, [selectedIndex, results]); + + if (!isOpen) { + return ( + + ); + } + + return ( +
+ {/* Backdrop with scan line effect */} +
+ + {/* Search Dialog */} +
+
+ {/* Header Bar */} +
+
+
+
+
+
+
+ + /// SEARCH_QUERY + +
+ +
+ + {/* Search Input */} +
+
+ + + + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="ENTER SEARCH QUERY..." + className="flex-1 bg-transparent border-none outline-none text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] font-mono text-base tracking-wide uppercase" + /> + {query && ( + + )} +
+
+ + {/* Results */} +
+ {isLoading ? ( +
+
+ /// INITIALIZING SEARCH PROTOCOL +
+
+
+
+
+
+
+ ) : results.length > 0 ? ( + <> + {results.map((result, index) => ( + setSelectedIndex(index)} + > +
+
+

+ {result.title} +

+ {result.category && ( + + {result.category} + + )} +
+

+ {result.description} +

+ {result.tags && result.tags.length > 0 && ( +
+ {result.tags.slice(0, 4).map((tag) => ( + + #{tag} + + ))} +
+ )} +
+
+ ))} + + ) : query ? ( +
+
+ /// NO RESULTS FOUND +
+

+ Query returned 0 matches. Try different keywords. +

+
+ ) : ( +
+
+ /// AWAITING INPUT +
+

+ Begin typing to search all blog content +

+
+ )} +
+ + {/* Footer */} + {results.length > 0 && ( +
+
+ + ↑↓ Navigate + + + Select + + + ESC Close + +
+
+ + {results.length} RESULT{results.length !== 1 ? 'S' : ''} + +
+
+ )} +
+
+
+ ); +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 7aa6a79..4045a29 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -70,7 +70,7 @@ const personSchema = { TIMESTAMP --:--:--
-
+ + +
+
    _____________________
+   /                     \
+  /   SYSTEM FAILURE     \
+ /_________________________\
+ |  ___________________  |
+ | |                   | |
+ | | > FATAL ERROR     | |
+ | | > PAGE NOT FOUND  | |
+ | | > 0x404           | |
+ | | _                 | |
+ | |___________________| |
+ |                       |
+ |  ⚠  SIGNAL LOST  ⚠   |
+ \_______________________/
+
+ +
> DIAGNOSTIC_TOOL --RUN > TRACE COMPLETE > END OF LINE. diff --git a/src/pages/blog/[...slug].astro b/src/pages/blog/[...slug].astro index c3f931a..845806a 100644 --- a/src/pages/blog/[...slug].astro +++ b/src/pages/blog/[...slug].astro @@ -1,6 +1,7 @@ --- import { type CollectionEntry, getCollection, render } from 'astro:content'; import BlogPost from '../../layouts/BlogPost.astro'; +import { calculateReadingTime } from '../../utils/reading-time'; export async function getStaticPaths() { const posts = await getCollection('blog'); @@ -90,11 +91,8 @@ interface Props { const { post, prevPost, nextPost, relatedPosts } = Astro.props; const { Content, headings } = await render(post); -// Calculate reading time (average 200 words per minute) -const wordsPerMinute = 200; -const wordCount = post.body?.split(/\s+/).length || 0; -const readingTime = Math.max(1, Math.ceil(wordCount / wordsPerMinute)); -const readTimeText = `${readingTime} min read`; +// Calculate reading time +const readTimeText = calculateReadingTime(post.body); --- post.data.category).filter - 5 min read + {calculateReadingTime(featuredPost.body)}
@@ -202,6 +203,7 @@ const categories = [...new Set(allPosts.map((post) => post.data.category).filter category={post.data.category} tags={post.data.tags} href={`/blog/${post.id}/`} + readTime={calculateReadingTime(post.body)} />
))} diff --git a/src/pages/search.json.ts b/src/pages/search.json.ts new file mode 100644 index 0000000..e442e4c --- /dev/null +++ b/src/pages/search.json.ts @@ -0,0 +1,23 @@ +import { getCollection } from 'astro:content'; + +export async function GET() { + const posts = await getCollection('blog'); + + const searchData = posts.map((post) => ({ + id: post.id, + title: post.data.title, + description: post.data.description, + content: post.body, + category: post.data.category || '', + tags: post.data.tags || [], + url: `/blog/${post.id}/`, + pubDate: post.data.pubDate.toISOString(), + })); + + return new Response(JSON.stringify(searchData), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/src/utils/reading-time.ts b/src/utils/reading-time.ts new file mode 100644 index 0000000..b2f3c0b --- /dev/null +++ b/src/utils/reading-time.ts @@ -0,0 +1,11 @@ +/** + * Calculate reading time for a given text content + * @param content - The text content to analyze + * @param wordsPerMinute - Reading speed (default: 200 wpm) + * @returns Reading time string (e.g., "5 min read") + */ +export function calculateReadingTime(content: string, wordsPerMinute: number = 200): string { + const wordCount = content?.split(/\s+/).length || 0; + const readingTime = Math.max(1, Math.ceil(wordCount / wordsPerMinute)); + return `${readingTime} min read`; +}