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:
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';
|
||||
|
||||
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<Measure>('length');
|
||||
const [selectedUnit, setSelectedUnit] = useState<string>('m');
|
||||
const [inputValue, setInputValue] = useState<string>('1');
|
||||
const [conversions, setConversions] = useState<ConversionResult[]>([]);
|
||||
const [favorites, setFavorites] = useState<string[]>([]);
|
||||
const [copiedUnit, setCopiedUnit] = useState<string | null>(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 (
|
||||
<div className="w-full max-w-6xl mx-auto space-y-6">
|
||||
{/* Search */}
|
||||
<div className="flex justify-center">
|
||||
<SearchUnits onSelectUnit={handleSearchSelect} />
|
||||
</div>
|
||||
|
||||
{/* Category Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -116,29 +181,66 @@ export default function MainConverter() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{conversions.map((conversion) => (
|
||||
<div
|
||||
key={conversion.unit}
|
||||
className="p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||
style={{
|
||||
borderLeftWidth: '4px',
|
||||
borderLeftColor: `var(--color-${getCategoryColor(selectedMeasure)})`,
|
||||
}}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{conversion.unitInfo.plural}
|
||||
{conversions.map((conversion) => {
|
||||
const isFavorite = favorites.includes(conversion.unit);
|
||||
const isCopied = copiedUnit === conversion.unit;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conversion.unit}
|
||||
className="group relative p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||
style={{
|
||||
borderLeftWidth: '4px',
|
||||
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 className="text-2xl font-bold">
|
||||
{formatNumber(conversion.value)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{conversion.unit}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Conversion History */}
|
||||
<ConversionHistory onSelectConversion={handleHistorySelect} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user