+
Unit Converter
Convert between 187 units across 23 measurement categories
++ Press / to search units +
+
+
+ Recent Conversions
+
+
+
+
+ {history.length > 0 && (
+
+ )}
+
+
+ {history.map((record) => (
+
+ ))}
+
+
+ {/* Search */}
+
@@ -116,29 +181,66 @@ export default function MainConverter() {
([]);
+ 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 (
+ (
+ 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
+
+
+
{/* Category Selection */}
- {conversions.map((conversion) => (
-
);
}
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
-
+
+ {/* Conversion History */}
+
- {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}
-
-
+
+ );
+}
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
+
+ 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}"
+
+ )}
+