Hero sections: - Redesign blog, dev, and contact page heroes with sleek modern style - Add gradient text titles, floating accent orbs, stats rows - Remove heavy terminal-style elements for cleaner aesthetic Buttons: - Add rounded-full to all buttons sitewide for consistent pill shape - Update btn-primary and btn-ghost utilities in global.css HubertChat: - Move Hubert to dedicated /hubert page - Add framer-motion for smooth input transitions - Support markdown rendering with marked + DOMPurify - Add multiline textarea with Shift+Enter support - Fix light mode visibility for input and message bubbles - Extract useHubertChat hook for cleaner state management Fonts: - Update to Sora (sans) and IBM Plex Mono (mono) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
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<IndexedResult[]>([]);
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [searchData, setSearchData] = useState<SearchResult[]>([]);
|
|
const [searchIndex, setSearchIndex] = useState<lunr.Index | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const resultsRef = useRef<HTMLDivElement>(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 (
|
|
<button
|
|
onClick={() => setIsOpen(true)}
|
|
className="hidden md:flex items-center gap-3 px-4 py-2 border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] transition-all duration-300 text-xs rounded-full"
|
|
aria-label="Open search"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
<span className="font-mono text-[10px] uppercase tracking-wider">Search</span>
|
|
<kbd className="px-2 py-1 bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-secondary)] font-mono text-[9px] text-[var(--theme-text-subtle)]">
|
|
⌘K
|
|
</kbd>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] px-4">
|
|
{/* Backdrop with scan line effect */}
|
|
<div
|
|
className="absolute inset-0 bg-[var(--theme-bg-primary)]/95 backdrop-blur-md"
|
|
onClick={closeSearch}
|
|
style={{
|
|
backgroundImage: 'linear-gradient(0deg, transparent 50%, rgba(221, 65, 50, 0.02) 50%)',
|
|
backgroundSize: '100% 4px',
|
|
}}
|
|
/>
|
|
|
|
{/* Search Dialog */}
|
|
<div className="relative w-full max-w-3xl animate-on-scroll fade-in is-visible">
|
|
<div className="bg-[var(--theme-bg-secondary)] border-2 border-[var(--theme-border-primary)] shadow-2xl">
|
|
{/* Header Bar */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b-2 border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)]">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex gap-1.5">
|
|
<div className="w-2 h-2 bg-brand-accent animate-pulse" />
|
|
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
|
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
|
</div>
|
|
<span className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-brand-accent">
|
|
/// SEARCH_QUERY
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={closeSearch}
|
|
className="text-[9px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
|
|
>
|
|
[ESC]
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search Input */}
|
|
<div className="px-6 py-5 border-b border-[var(--theme-border-secondary)]">
|
|
<div className="flex items-center gap-4">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="text-brand-accent flex-shrink-0"
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => 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 && (
|
|
<button
|
|
onClick={() => {
|
|
setQuery('');
|
|
inputRef.current?.focus();
|
|
}}
|
|
className="text-[10px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
|
|
>
|
|
[CLR]
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results */}
|
|
<div
|
|
ref={resultsRef}
|
|
className="max-h-[55vh] overflow-y-auto"
|
|
>
|
|
{isLoading ? (
|
|
<div className="p-16 text-center">
|
|
<div className="text-brand-accent font-mono text-sm uppercase tracking-widest mb-3 animate-pulse">
|
|
/// INITIALIZING SEARCH PROTOCOL
|
|
</div>
|
|
<div className="flex justify-center gap-1">
|
|
<div className="w-2 h-2 bg-brand-accent animate-pulse" style={{ animationDelay: '0ms' }} />
|
|
<div className="w-2 h-2 bg-brand-accent animate-pulse" style={{ animationDelay: '150ms' }} />
|
|
<div className="w-2 h-2 bg-brand-accent animate-pulse" style={{ animationDelay: '300ms' }} />
|
|
</div>
|
|
</div>
|
|
) : results.length > 0 ? (
|
|
<>
|
|
{results.map((result, index) => (
|
|
<a
|
|
key={result.id}
|
|
href={result.url}
|
|
className={`block border-l-4 transition-all duration-200 ${
|
|
index === selectedIndex
|
|
? 'border-brand-accent bg-[var(--theme-hover-bg-strong)]'
|
|
: 'border-transparent hover:border-[var(--theme-border-strong)] hover:bg-[var(--theme-hover-bg)]'
|
|
}`}
|
|
onMouseEnter={() => setSelectedIndex(index)}
|
|
>
|
|
<div className="px-6 py-5 border-b border-[var(--theme-border-secondary)]">
|
|
<div className="flex items-start justify-between gap-4 mb-3">
|
|
<h3 className="text-base font-bold text-[var(--theme-text-primary)] uppercase tracking-tight leading-tight">
|
|
{result.title}
|
|
</h3>
|
|
{result.category && (
|
|
<span className="px-2.5 py-1 text-[9px] font-mono font-bold uppercase tracking-widest border border-brand-accent/50 text-brand-accent whitespace-nowrap">
|
|
{result.category}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-[var(--theme-text-secondary)] line-clamp-2 mb-3 leading-relaxed">
|
|
{result.description}
|
|
</p>
|
|
{result.tags && result.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{result.tags.slice(0, 4).map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="text-[9px] font-mono text-[var(--theme-text-muted)] uppercase"
|
|
>
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</a>
|
|
))}
|
|
</>
|
|
) : query ? (
|
|
<div className="p-16 text-center">
|
|
<div className="text-[var(--theme-text-muted)] font-mono text-sm uppercase tracking-widest mb-3">
|
|
/// NO RESULTS FOUND
|
|
</div>
|
|
<p className="text-[var(--theme-text-secondary)] text-sm font-mono">
|
|
Query returned 0 matches. Try different keywords.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="p-16 text-center">
|
|
<div className="text-[var(--theme-text-muted)] font-mono text-sm uppercase tracking-widest mb-3">
|
|
/// AWAITING INPUT
|
|
</div>
|
|
<p className="text-[var(--theme-text-secondary)] text-sm font-mono">
|
|
Begin typing to search all blog content
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
{results.length > 0 && (
|
|
<div className="px-6 py-4 bg-[var(--theme-hover-bg)] border-t-2 border-[var(--theme-border-primary)] flex items-center justify-between">
|
|
<div className="flex items-center gap-6 text-[9px] font-mono text-[var(--theme-text-muted)] uppercase tracking-wider">
|
|
<span className="flex items-center gap-2">
|
|
<span className="text-brand-accent">↑↓</span> Navigate
|
|
</span>
|
|
<span className="flex items-center gap-2">
|
|
<span className="text-brand-accent">↵</span> Select
|
|
</span>
|
|
<span className="flex items-center gap-2">
|
|
<span className="text-brand-accent">ESC</span> Close
|
|
</span>
|
|
</div>
|
|
<div className="px-3 py-1.5 bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)]">
|
|
<span className="text-[9px] font-mono font-bold text-brand-accent uppercase tracking-wider">
|
|
{results.length} RESULT{results.length !== 1 ? 'S' : ''}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|