diff --git a/app/layout.tsx b/app/layout.tsx index 0c04191..893dfaf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { ThemeProvider } from '@/components/providers/ThemeProvider'; import './globals.css'; export const metadata: Metadata = { @@ -12,9 +13,11 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + + {children} + ); diff --git a/app/page.tsx b/app/page.tsx index 3671909..e7947d5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,16 +1,28 @@ import MainConverter from '@/components/converter/MainConverter'; +import { ThemeToggle } from '@/components/ui/ThemeToggle'; export default function Home() { return (
+ {/* Header with theme toggle */} +
+
+
Units UI
+ +
+
+
-

+

Unit Converter

Convert between 187 units across 23 measurement categories

+

+ Press / to search units +

diff --git a/components/converter/ConversionHistory.tsx b/components/converter/ConversionHistory.tsx new file mode 100644 index 0000000..dac2fe0 --- /dev/null +++ b/components/converter/ConversionHistory.tsx @@ -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([]); + 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 ( + + +
+ + + Recent Conversions + +
+ + {history.length > 0 && ( + + )} +
+
+
+ + {isOpen && ( + +
+ {history.map((record) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/components/converter/MainConverter.tsx b/components/converter/MainConverter.tsx index 4cd58d5..742d232 100644 --- a/components/converter/MainConverter.tsx +++ b/components/converter/MainConverter.tsx @@ -1,29 +1,41 @@ '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 { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import SearchUnits from './SearchUnits'; +import ConversionHistory from './ConversionHistory'; import { getAllMeasures, getUnitsForMeasure, convertToAll, + convertUnit, formatMeasureName, getCategoryColor, type Measure, type ConversionResult, } 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() { const [selectedMeasure, setSelectedMeasure] = useState('length'); const [selectedUnit, setSelectedUnit] = useState('m'); const [inputValue, setInputValue] = useState('1'); const [conversions, setConversions] = useState([]); + const [favorites, setFavorites] = useState([]); + const [copiedUnit, setCopiedUnit] = useState(null); const measures = getAllMeasures(); const units = getUnitsForMeasure(selectedMeasure); + // Load favorites + useEffect(() => { + setFavorites(getFavorites()); + }, []); + // Update conversions when input changes useEffect(() => { const numValue = parseNumberInput(inputValue); @@ -43,8 +55,61 @@ export default function MainConverter() { } }, [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 (
+ {/* Search */} +
+ +
+ {/* Category Selection */} @@ -116,29 +181,66 @@ export default function MainConverter() {
- {conversions.map((conversion) => ( -
-
- {conversion.unitInfo.plural} + {conversions.map((conversion) => { + const isFavorite = favorites.includes(conversion.unit); + const isCopied = copiedUnit === conversion.unit; + + return ( +
+ {/* Favorite & Copy buttons */} +
+ + +
+ +
+ {conversion.unitInfo.plural} +
+
+ {formatNumber(conversion.value)} +
+
+ {conversion.unit} +
-
- {formatNumber(conversion.value)} -
-
- {conversion.unit} -
-
- ))} + ); + })}
+ + {/* Conversion History */} +
); } diff --git a/components/converter/SearchUnits.tsx b/components/converter/SearchUnits.tsx new file mode 100644 index 0000000..4dadeee --- /dev/null +++ b/components/converter/SearchUnits.tsx @@ -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([]); + const [isOpen, setIsOpen] = useState(false); + const inputRef = useRef(null); + const containerRef = useRef(null); + + // Build search index + const searchIndex = useRef | 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 ( +
+
+ + setQuery(e.target.value)} + onFocus={() => query && setIsOpen(true)} + className="pl-10 pr-10" + /> + {query && ( + + )} +
+ + {/* Results dropdown */} + {isOpen && results.length > 0 && ( +
+ {results.map((result, index) => ( + + ))} +
+ )} + + {isOpen && query && results.length === 0 && ( +
+ No units found for "{query}" +
+ )} +
+ ); +} diff --git a/components/providers/ThemeProvider.tsx b/components/providers/ThemeProvider.tsx new file mode 100644 index 0000000..3187c57 --- /dev/null +++ b/components/providers/ThemeProvider.tsx @@ -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( + undefined +); + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'units-ui-theme', +}: ThemeProviderProps) { + const [theme, setTheme] = useState(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 ( + + {children} + + ); +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + + if (context === undefined) + throw new Error('useTheme must be used within a ThemeProvider'); + + return context; +}; diff --git a/components/ui/ThemeToggle.tsx b/components/ui/ThemeToggle.tsx new file mode 100644 index 0000000..ae8f770 --- /dev/null +++ b/components/ui/ThemeToggle.tsx @@ -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 ( + + ); +} diff --git a/lib/storage.ts b/lib/storage.ts new file mode 100644 index 0000000..5411af8 --- /dev/null +++ b/lib/storage.ts @@ -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): 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; + } +} diff --git a/package.json b/package.json index 98a7378..093ea57 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "dependencies": { "clsx": "^2.1.1", "convert-units": "^2.3.4", + "fuse.js": "^7.1.0", + "lucide-react": "^0.553.0", "next": "^16.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b554879..58cc479 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: convert-units: specifier: ^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: 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) @@ -1118,6 +1124,10 @@ packages: functions-have-names@1.2.3: 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: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -1530,6 +1540,11 @@ packages: lru-cache@5.1.1: 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: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3197,6 +3212,8 @@ snapshots: functions-have-names@1.2.3: {} + fuse.js@7.1.0: {} + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -3613,6 +3630,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@0.553.0(react@19.2.0): + dependencies: + react: 19.2.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5