Add comprehensive UX enhancements with innovative features: 🔍 Fuzzy Search Component (SearchUnits.tsx): - Powered by Fuse.js for intelligent fuzzy matching - Searches across unit abbreviations, names, and categories - Real-time dropdown with results - Keyboard shortcut: Press "/" to focus search - Press Escape to close - Click outside to dismiss - Shows measure category with color dot - Top 10 results displayed - Smart weighting: abbr (2x), singular/plural (1.5x), measure (1x) 💾 Conversion History (ConversionHistory.tsx): - LocalStorage persistence (max 50 entries) - Auto-saves conversions as user types - Collapsible history panel - Click to restore previous conversion - Clear all with confirmation - Shows relative time (just now, 5m ago, etc.) - Live updates across tabs with storage events - Custom event dispatch for same-window updates 🌓 Dark Mode Support: - ThemeProvider with light/dark/system modes - Persistent theme preference in localStorage - Smooth theme transitions - ThemeToggle component with animated sun/moon icons - Gradient text adapts to theme - System preference detection ⭐ Favorites & Copy Features: - Star button to favorite units (localStorage) - Copy to clipboard with visual feedback - Hover to reveal action buttons - Check icon confirmation for 2 seconds - Yellow star fill for favorited units ⌨️ Keyboard Shortcuts: - "/" - Focus search input - "Escape" - Close search, blur inputs - More shortcuts ready to add (Tab, Ctrl+K, etc.) 📦 LocalStorage Utilities (lib/storage.ts): - saveToHistory() - Add conversion record - getHistory() - Retrieve history - clearHistory() - Clear all history - getFavorites() / addToFavorites() / removeFromFavorites() - toggleFavorite() - Toggle favorite status - Type-safe ConversionRecord interface - Automatic error handling 🎨 Enhanced MainConverter: - Integrated search at top - Conversion history at bottom - Copy & favorite buttons on each result card - Hover effects with opacity transitions - Auto-save to history on conversion - Click history item to restore conversion - Visual feedback for all interactions 📱 Updated Layout & Page: - ThemeProvider wraps entire app - suppressHydrationWarning for SSR - Top navigation bar with theme toggle - Keyboard hint for search - Dark mode gradient text variants Dependencies Added: - fuse.js 7.1.0 - Fuzzy search engine - lucide-react 0.553.0 - Icon library (Search, Copy, Star, Check, etc.) Features Now Working: ✅ Intelligent fuzzy search across 187 units ✅ Conversion history with persistence ✅ Dark mode with system detection ✅ Copy any result to clipboard ✅ Favorite units for quick access ✅ Keyboard shortcuts (/, Esc) ✅ Smooth animations and transitions ✅ Mobile-responsive design 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
201 lines
5.8 KiB
TypeScript
201 lines
5.8 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { Search, X } from 'lucide-react';
|
|
import Fuse from 'fuse.js';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
getAllMeasures,
|
|
getUnitsForMeasure,
|
|
getUnitInfo,
|
|
formatMeasureName,
|
|
getCategoryColor,
|
|
type Measure,
|
|
type UnitInfo,
|
|
} from '@/lib/units';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface SearchResult {
|
|
unitInfo: UnitInfo;
|
|
measure: Measure;
|
|
}
|
|
|
|
interface SearchUnitsProps {
|
|
onSelectUnit: (unit: string, measure: Measure) => void;
|
|
}
|
|
|
|
export default function SearchUnits({ onSelectUnit }: SearchUnitsProps) {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Build search index
|
|
const searchIndex = useRef<Fuse<SearchResult> | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Build comprehensive search data
|
|
const allData: SearchResult[] = [];
|
|
const measures = getAllMeasures();
|
|
|
|
for (const measure of measures) {
|
|
const units = getUnitsForMeasure(measure);
|
|
|
|
for (const unit of units) {
|
|
const unitInfo = getUnitInfo(unit);
|
|
if (unitInfo) {
|
|
allData.push({
|
|
unitInfo,
|
|
measure,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize Fuse.js for fuzzy search
|
|
searchIndex.current = new Fuse(allData, {
|
|
keys: [
|
|
{ name: 'unitInfo.abbr', weight: 2 },
|
|
{ name: 'unitInfo.singular', weight: 1.5 },
|
|
{ name: 'unitInfo.plural', weight: 1.5 },
|
|
{ name: 'measure', weight: 1 },
|
|
],
|
|
threshold: 0.3,
|
|
includeScore: true,
|
|
});
|
|
}, []);
|
|
|
|
// Perform search
|
|
useEffect(() => {
|
|
if (!query.trim() || !searchIndex.current) {
|
|
setResults([]);
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
|
|
const searchResults = searchIndex.current.search(query);
|
|
setResults(searchResults.map(r => r.item).slice(0, 10));
|
|
setIsOpen(true);
|
|
}, [query]);
|
|
|
|
// Handle click outside
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (
|
|
containerRef.current &&
|
|
!containerRef.current.contains(event.target as Node)
|
|
) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Keyboard shortcut: / to focus search
|
|
useEffect(() => {
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
|
const activeElement = document.activeElement;
|
|
if (
|
|
activeElement?.tagName !== 'INPUT' &&
|
|
activeElement?.tagName !== 'TEXTAREA'
|
|
) {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
}
|
|
|
|
if (e.key === 'Escape') {
|
|
setIsOpen(false);
|
|
inputRef.current?.blur();
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, []);
|
|
|
|
const handleSelectUnit = (unit: string, measure: Measure) => {
|
|
onSelectUnit(unit, measure);
|
|
setQuery('');
|
|
setIsOpen(false);
|
|
inputRef.current?.blur();
|
|
};
|
|
|
|
const clearSearch = () => {
|
|
setQuery('');
|
|
setIsOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative w-full max-w-md">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="Search units (press / to focus)"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onFocus={() => query && setIsOpen(true)}
|
|
className="pl-10 pr-10"
|
|
/>
|
|
{query && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
|
onClick={clearSearch}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Results dropdown */}
|
|
{isOpen && results.length > 0 && (
|
|
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-lg shadow-lg max-h-80 overflow-y-auto">
|
|
{results.map((result, index) => (
|
|
<button
|
|
key={`${result.measure}-${result.unitInfo.abbr}`}
|
|
onClick={() => handleSelectUnit(result.unitInfo.abbr, result.measure)}
|
|
className={cn(
|
|
'w-full px-4 py-3 text-left hover:bg-accent transition-colors',
|
|
'flex items-center justify-between gap-4',
|
|
index !== 0 && 'border-t'
|
|
)}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">
|
|
{result.unitInfo.plural}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
|
<span className="truncate">{result.unitInfo.abbr}</span>
|
|
<span>•</span>
|
|
<span className="truncate">{formatMeasureName(result.measure)}</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
style={{
|
|
backgroundColor: `var(--color-${getCategoryColor(result.measure)})`,
|
|
}}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isOpen && query && results.length === 0 && (
|
|
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-lg shadow-lg p-4 text-center text-muted-foreground">
|
|
No units found for "{query}"
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|