feat: implement Phase 3 - Advanced UX features and interactivity
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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import { ThemeProvider } from '@/components/providers/ThemeProvider';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -12,9 +13,11 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
{children}
|
<ThemeProvider defaultTheme="system" storageKey="units-ui-theme">
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
14
app/page.tsx
14
app/page.tsx
@@ -1,16 +1,28 @@
|
|||||||
import MainConverter from '@/components/converter/MainConverter';
|
import MainConverter from '@/components/converter/MainConverter';
|
||||||
|
import { ThemeToggle } from '@/components/ui/ThemeToggle';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Header with theme toggle */}
|
||||||
|
<div className="border-b">
|
||||||
|
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<div className="text-xl font-bold">Units UI</div>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<header className="text-center mb-12">
|
<header className="text-center mb-12">
|
||||||
<h1 className="text-5xl font-bold mb-4 bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
<h1 className="text-5xl font-bold mb-4 bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-400 dark:to-indigo-400 bg-clip-text text-transparent">
|
||||||
Unit Converter
|
Unit Converter
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
Convert between 187 units across 23 measurement categories
|
Convert between 187 units across 23 measurement categories
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Press <kbd className="px-2 py-1 bg-muted rounded text-xs">/</kbd> to search units
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<MainConverter />
|
<MainConverter />
|
||||||
|
|||||||
122
components/converter/ConversionHistory.tsx
Normal file
122
components/converter/ConversionHistory.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { History, Trash2, ArrowRight } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
getHistory,
|
||||||
|
clearHistory,
|
||||||
|
type ConversionRecord,
|
||||||
|
} from '@/lib/storage';
|
||||||
|
import { getRelativeTime, formatNumber } from '@/lib/utils';
|
||||||
|
import { formatMeasureName } from '@/lib/units';
|
||||||
|
|
||||||
|
interface ConversionHistoryProps {
|
||||||
|
onSelectConversion?: (record: ConversionRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversionHistory({
|
||||||
|
onSelectConversion,
|
||||||
|
}: ConversionHistoryProps) {
|
||||||
|
const [history, setHistory] = useState<ConversionRecord[]>([]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory();
|
||||||
|
|
||||||
|
// Listen for storage changes
|
||||||
|
const handleStorageChange = () => {
|
||||||
|
loadHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
// Also listen for custom event from same window
|
||||||
|
window.addEventListener('historyUpdated', handleStorageChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', handleStorageChange);
|
||||||
|
window.removeEventListener('historyUpdated', handleStorageChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadHistory = () => {
|
||||||
|
setHistory(getHistory());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearHistory = () => {
|
||||||
|
if (confirm('Clear all conversion history?')) {
|
||||||
|
clearHistory();
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<History className="h-5 w-5" />
|
||||||
|
Recent Conversions
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{isOpen ? 'Hide' : `Show (${history.length})`}
|
||||||
|
</Button>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleClearHistory}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{history.map((record) => (
|
||||||
|
<button
|
||||||
|
key={record.id}
|
||||||
|
onClick={() => onSelectConversion?.(record)}
|
||||||
|
className="w-full p-3 rounded-lg border hover:bg-accent transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<span className="truncate">
|
||||||
|
{formatNumber(record.from.value)} {record.from.unit}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{formatNumber(record.to.value)} {record.to.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
|
||||||
|
<span>{formatMeasureName(record.measure as any)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{getRelativeTime(record.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,41 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Copy, Star, Check } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import SearchUnits from './SearchUnits';
|
||||||
|
import ConversionHistory from './ConversionHistory';
|
||||||
import {
|
import {
|
||||||
getAllMeasures,
|
getAllMeasures,
|
||||||
getUnitsForMeasure,
|
getUnitsForMeasure,
|
||||||
convertToAll,
|
convertToAll,
|
||||||
|
convertUnit,
|
||||||
formatMeasureName,
|
formatMeasureName,
|
||||||
getCategoryColor,
|
getCategoryColor,
|
||||||
type Measure,
|
type Measure,
|
||||||
type ConversionResult,
|
type ConversionResult,
|
||||||
} from '@/lib/units';
|
} from '@/lib/units';
|
||||||
import { parseNumberInput, formatNumber } from '@/lib/utils';
|
import { parseNumberInput, formatNumber, cn } from '@/lib/utils';
|
||||||
|
import { saveToHistory, getFavorites, toggleFavorite } from '@/lib/storage';
|
||||||
|
|
||||||
export default function MainConverter() {
|
export default function MainConverter() {
|
||||||
const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length');
|
const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length');
|
||||||
const [selectedUnit, setSelectedUnit] = useState<string>('m');
|
const [selectedUnit, setSelectedUnit] = useState<string>('m');
|
||||||
const [inputValue, setInputValue] = useState<string>('1');
|
const [inputValue, setInputValue] = useState<string>('1');
|
||||||
const [conversions, setConversions] = useState<ConversionResult[]>([]);
|
const [conversions, setConversions] = useState<ConversionResult[]>([]);
|
||||||
|
const [favorites, setFavorites] = useState<string[]>([]);
|
||||||
|
const [copiedUnit, setCopiedUnit] = useState<string | null>(null);
|
||||||
|
|
||||||
const measures = getAllMeasures();
|
const measures = getAllMeasures();
|
||||||
const units = getUnitsForMeasure(selectedMeasure);
|
const units = getUnitsForMeasure(selectedMeasure);
|
||||||
|
|
||||||
|
// Load favorites
|
||||||
|
useEffect(() => {
|
||||||
|
setFavorites(getFavorites());
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Update conversions when input changes
|
// Update conversions when input changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const numValue = parseNumberInput(inputValue);
|
const numValue = parseNumberInput(inputValue);
|
||||||
@@ -43,8 +55,61 @@ export default function MainConverter() {
|
|||||||
}
|
}
|
||||||
}, [selectedMeasure]);
|
}, [selectedMeasure]);
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
const copyToClipboard = useCallback(async (value: number, unit: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`${formatNumber(value)} ${unit}`);
|
||||||
|
setCopiedUnit(unit);
|
||||||
|
setTimeout(() => setCopiedUnit(null), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle favorite
|
||||||
|
const handleToggleFavorite = useCallback((unit: string) => {
|
||||||
|
const isFavorite = toggleFavorite(unit);
|
||||||
|
setFavorites(getFavorites());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save to history when conversion happens
|
||||||
|
useEffect(() => {
|
||||||
|
const numValue = parseNumberInput(inputValue);
|
||||||
|
if (numValue !== null && selectedUnit && conversions.length > 0) {
|
||||||
|
// Save first conversion to history
|
||||||
|
const firstConversion = conversions.find(c => c.unit !== selectedUnit);
|
||||||
|
if (firstConversion) {
|
||||||
|
saveToHistory({
|
||||||
|
from: { value: numValue, unit: selectedUnit },
|
||||||
|
to: { value: firstConversion.value, unit: firstConversion.unit },
|
||||||
|
measure: selectedMeasure,
|
||||||
|
});
|
||||||
|
// Dispatch custom event for same-window updates
|
||||||
|
window.dispatchEvent(new Event('historyUpdated'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [inputValue, selectedUnit, conversions, selectedMeasure]);
|
||||||
|
|
||||||
|
// Handle search selection
|
||||||
|
const handleSearchSelect = useCallback((unit: string, measure: Measure) => {
|
||||||
|
setSelectedMeasure(measure);
|
||||||
|
setSelectedUnit(unit);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle history selection
|
||||||
|
const handleHistorySelect = useCallback((record: any) => {
|
||||||
|
setInputValue(record.from.value.toString());
|
||||||
|
setSelectedMeasure(record.measure);
|
||||||
|
setSelectedUnit(record.from.unit);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto space-y-6">
|
<div className="w-full max-w-6xl mx-auto space-y-6">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<SearchUnits onSelectUnit={handleSearchSelect} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Category Selection */}
|
{/* Category Selection */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -116,29 +181,66 @@ export default function MainConverter() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{conversions.map((conversion) => (
|
{conversions.map((conversion) => {
|
||||||
<div
|
const isFavorite = favorites.includes(conversion.unit);
|
||||||
key={conversion.unit}
|
const isCopied = copiedUnit === conversion.unit;
|
||||||
className="p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
|
||||||
style={{
|
return (
|
||||||
borderLeftWidth: '4px',
|
<div
|
||||||
borderLeftColor: `var(--color-${getCategoryColor(selectedMeasure)})`,
|
key={conversion.unit}
|
||||||
}}
|
className="group relative p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||||
>
|
style={{
|
||||||
<div className="text-sm text-muted-foreground mb-1">
|
borderLeftWidth: '4px',
|
||||||
{conversion.unitInfo.plural}
|
borderLeftColor: `var(--color-${getCategoryColor(selectedMeasure)})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Favorite & Copy buttons */}
|
||||||
|
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleToggleFavorite(conversion.unit)}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4',
|
||||||
|
isFavorite && 'fill-yellow-400 text-yellow-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => copyToClipboard(conversion.value, conversion.unit)}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground mb-1">
|
||||||
|
{conversion.unitInfo.plural}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(conversion.value)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">
|
||||||
|
{conversion.unit}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold">
|
);
|
||||||
{formatNumber(conversion.value)}
|
})}
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground mt-1">
|
|
||||||
{conversion.unit}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Conversion History */}
|
||||||
|
<ConversionHistory onSelectConversion={handleHistorySelect} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
200
components/converter/SearchUnits.tsx
Normal file
200
components/converter/SearchUnits.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
components/providers/ThemeProvider.tsx
Normal file
77
components/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light' | 'system';
|
||||||
|
|
||||||
|
interface ThemeProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultTheme?: Theme;
|
||||||
|
storageKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeProviderState {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
defaultTheme = 'system',
|
||||||
|
storageKey = 'units-ui-theme',
|
||||||
|
}: ThemeProviderProps) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(defaultTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load theme from localStorage
|
||||||
|
const stored = localStorage.getItem(storageKey) as Theme | null;
|
||||||
|
if (stored) {
|
||||||
|
setTheme(stored);
|
||||||
|
}
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
|
||||||
|
root.classList.add(systemTheme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
localStorage.setItem(storageKey, theme);
|
||||||
|
setTheme(theme);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeProviderContext);
|
||||||
|
|
||||||
|
if (context === undefined)
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
30
components/ui/ThemeToggle.tsx
Normal file
30
components/ui/ThemeToggle.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
if (theme === 'dark') {
|
||||||
|
setTheme('light');
|
||||||
|
} else {
|
||||||
|
setTheme('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
lib/storage.ts
Normal file
115
lib/storage.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* LocalStorage utilities for persisting user data
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ConversionRecord {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
from: {
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
};
|
||||||
|
to: {
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
};
|
||||||
|
measure: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_KEY = 'units-ui-history';
|
||||||
|
const FAVORITES_KEY = 'units-ui-favorites';
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save conversion to history
|
||||||
|
*/
|
||||||
|
export function saveToHistory(record: Omit<ConversionRecord, 'id' | 'timestamp'>): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const history = getHistory();
|
||||||
|
const newRecord: ConversionRecord = {
|
||||||
|
...record,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to beginning and limit size
|
||||||
|
const updated = [newRecord, ...history].slice(0, MAX_HISTORY);
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get conversion history
|
||||||
|
*/
|
||||||
|
export function getHistory(): ConversionRecord[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(HISTORY_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear conversion history
|
||||||
|
*/
|
||||||
|
export function clearHistory(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem(HISTORY_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get favorite units
|
||||||
|
*/
|
||||||
|
export function getFavorites(): string[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add unit to favorites
|
||||||
|
*/
|
||||||
|
export function addToFavorites(unit: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const favorites = getFavorites();
|
||||||
|
if (!favorites.includes(unit)) {
|
||||||
|
favorites.push(unit);
|
||||||
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove unit from favorites
|
||||||
|
*/
|
||||||
|
export function removeFromFavorites(unit: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const favorites = getFavorites();
|
||||||
|
const filtered = favorites.filter(u => u !== unit);
|
||||||
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(filtered));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle favorite status
|
||||||
|
*/
|
||||||
|
export function toggleFavorite(unit: string): boolean {
|
||||||
|
const favorites = getFavorites();
|
||||||
|
const isFavorite = favorites.includes(unit);
|
||||||
|
|
||||||
|
if (isFavorite) {
|
||||||
|
removeFromFavorites(unit);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
addToFavorites(unit);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"convert-units": "^2.3.4",
|
"convert-units": "^2.3.4",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -14,6 +14,12 @@ importers:
|
|||||||
convert-units:
|
convert-units:
|
||||||
specifier: ^2.3.4
|
specifier: ^2.3.4
|
||||||
version: 2.3.4
|
version: 2.3.4
|
||||||
|
fuse.js:
|
||||||
|
specifier: ^7.1.0
|
||||||
|
version: 7.1.0
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^0.553.0
|
||||||
|
version: 0.553.0(react@19.2.0)
|
||||||
next:
|
next:
|
||||||
specifier: ^16.0.0
|
specifier: ^16.0.0
|
||||||
version: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -1118,6 +1124,10 @@ packages:
|
|||||||
functions-have-names@1.2.3:
|
functions-have-names@1.2.3:
|
||||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||||
|
|
||||||
|
fuse.js@7.1.0:
|
||||||
|
resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
generator-function@2.0.1:
|
generator-function@2.0.1:
|
||||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1530,6 +1540,11 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
|
lucide-react@0.553.0:
|
||||||
|
resolution: {integrity: sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@@ -3197,6 +3212,8 @@ snapshots:
|
|||||||
|
|
||||||
functions-have-names@1.2.3: {}
|
functions-have-names@1.2.3: {}
|
||||||
|
|
||||||
|
fuse.js@7.1.0: {}
|
||||||
|
|
||||||
generator-function@2.0.1: {}
|
generator-function@2.0.1: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
@@ -3613,6 +3630,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lucide-react@0.553.0(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.0
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|||||||
Reference in New Issue
Block a user