Add final layer of advanced features for spectacular UX: ⌨️ Command Palette (CommandPalette.tsx): - Trigger with Ctrl/Cmd+K keyboard shortcut - Search and execute commands instantly - Quick access to all 23 measurement categories - Theme switching commands (light/dark/system) - Keyboard navigation (↑/↓ arrows, Enter to select) - Escape to close - Color-coded category commands - Backdrop blur overlay - Animated scale-in entrance - Footer with keyboard hints - Smart filtering by keywords 📊 Visual Comparison (VisualComparison.tsx): - Toggle between grid and chart view - Horizontal bar chart showing magnitude differences - Animated bars with 500ms transitions - Auto-calculated percentages relative to max value - Color-coded bars matching category colors - Tabular number formatting - Clean, minimal design 🔄 Unit Swap Functionality: - Swap button between From/To units - ArrowLeftRight icon - Automatically converts value on swap - Two-unit quick converter mode - Large result display with color accent - Responsive layout 📱 Footer Component (Footer.tsx): - Three-column grid layout (About, Features, Links) - GitHub repository link - convert-units library attribution - Keyboard shortcuts reference - Feature highlights - Made with ❤️ message - Responsive design (stacks on mobile) - Copyright notice with current year - Smooth hover transitions 🎨 Enhanced MainConverter: - From/To unit selector with swap button - Quick result display between selectors - Toggle between grid and chart views - BarChart3 icon for view switcher - Integrated command palette - Better spacing and layout - Target unit state management - Auto-update target unit on measure change ✨ Enhanced Page Layout: - Sticky header with backdrop blur - Flexbox layout for footer at bottom - Keyboard shortcuts hint (/ and Ctrl+K) - Improved header spacing - Better visual hierarchy Features Now Complete: ✅ Command palette with Ctrl+K ✅ Visual comparison bar charts ✅ Unit swap functionality ✅ Professional footer ✅ From/To quick converter ✅ Chart/Grid view toggle ✅ Sticky navigation header ✅ Full keyboard navigation The app is now feature-complete with: - 23 measurement categories - 187 individual units - Real-time conversion - Fuzzy search (/) - Command palette (Ctrl+K) - Dark mode - Conversion history - Favorites & copy - Visual comparisons - Unit swapping - Complete footer 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { Command, Hash, Clock, Star, Moon, Sun } from 'lucide-react';
|
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
|
import {
|
|
getAllMeasures,
|
|
formatMeasureName,
|
|
getCategoryColor,
|
|
type Measure,
|
|
} from '@/lib/units';
|
|
import { getHistory, getFavorites } from '@/lib/storage';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface CommandPaletteProps {
|
|
onSelectMeasure: (measure: Measure) => void;
|
|
onSelectUnit: (unit: string, measure: Measure) => void;
|
|
}
|
|
|
|
export default function CommandPalette({
|
|
onSelectMeasure,
|
|
onSelectUnit,
|
|
}: CommandPaletteProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [query, setQuery] = useState('');
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const { theme, setTheme } = useTheme();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Commands
|
|
const commands = [
|
|
{
|
|
id: 'theme-light',
|
|
label: 'Switch to Light Mode',
|
|
icon: Sun,
|
|
action: () => setTheme('light'),
|
|
keywords: ['theme', 'light', 'mode'],
|
|
},
|
|
{
|
|
id: 'theme-dark',
|
|
label: 'Switch to Dark Mode',
|
|
icon: Moon,
|
|
action: () => setTheme('dark'),
|
|
keywords: ['theme', 'dark', 'mode'],
|
|
},
|
|
{
|
|
id: 'theme-system',
|
|
label: 'Use System Theme',
|
|
icon: Command,
|
|
action: () => setTheme('system'),
|
|
keywords: ['theme', 'system', 'auto'],
|
|
},
|
|
];
|
|
|
|
// Add measure commands
|
|
const measures = getAllMeasures();
|
|
const measureCommands = measures.map(measure => ({
|
|
id: `measure-${measure}`,
|
|
label: `Convert ${formatMeasureName(measure)}`,
|
|
icon: Hash,
|
|
action: () => onSelectMeasure(measure),
|
|
keywords: ['convert', measure, formatMeasureName(measure).toLowerCase()],
|
|
color: getCategoryColor(measure),
|
|
}));
|
|
|
|
const allCommands = [...commands, ...measureCommands];
|
|
|
|
// Filter commands
|
|
const filteredCommands = query
|
|
? allCommands.filter(cmd =>
|
|
cmd.keywords.some(kw => kw.toLowerCase().includes(query.toLowerCase())) ||
|
|
cmd.label.toLowerCase().includes(query.toLowerCase())
|
|
)
|
|
: allCommands;
|
|
|
|
// Keyboard shortcut to open
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
setIsOpen(prev => !prev);
|
|
}
|
|
|
|
if (e.key === 'Escape') {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, []);
|
|
|
|
// Focus input when opened
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
inputRef.current?.focus();
|
|
setQuery('');
|
|
setSelectedIndex(0);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Keyboard navigation
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSelectedIndex(prev =>
|
|
prev < filteredCommands.length - 1 ? prev + 1 : prev
|
|
);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const command = filteredCommands[selectedIndex];
|
|
if (command) {
|
|
command.action();
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, selectedIndex, filteredCommands]);
|
|
|
|
// Reset selected index when query changes
|
|
useEffect(() => {
|
|
setSelectedIndex(0);
|
|
}, [query]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50"
|
|
onClick={() => setIsOpen(false)}
|
|
/>
|
|
|
|
{/* Command Palette */}
|
|
<div className="fixed left-1/2 top-1/4 -translate-x-1/2 w-full max-w-2xl z-50 animate-scale-in">
|
|
<div className="bg-popover border rounded-lg shadow-2xl overflow-hidden">
|
|
{/* Search Input */}
|
|
<div className="flex items-center border-b px-4">
|
|
<Command className="h-5 w-5 text-muted-foreground" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="Type a command or search..."
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
className="flex-1 bg-transparent py-4 px-4 outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
<kbd className="hidden sm:inline-block px-2 py-1 text-xs bg-muted rounded">
|
|
ESC
|
|
</kbd>
|
|
</div>
|
|
|
|
{/* Commands List */}
|
|
<div className="max-h-96 overflow-y-auto p-2">
|
|
{filteredCommands.length === 0 ? (
|
|
<div className="py-8 text-center text-muted-foreground">
|
|
No commands found
|
|
</div>
|
|
) : (
|
|
filteredCommands.map((command, index) => {
|
|
const Icon = command.icon;
|
|
return (
|
|
<button
|
|
key={command.id}
|
|
onClick={() => {
|
|
command.action();
|
|
setIsOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors text-left',
|
|
index === selectedIndex
|
|
? 'bg-accent text-accent-foreground'
|
|
: 'hover:bg-accent/50'
|
|
)}
|
|
>
|
|
{command.color ? (
|
|
<div
|
|
className="w-5 h-5 rounded flex-shrink-0"
|
|
style={{
|
|
backgroundColor: `var(--color-${command.color})`,
|
|
}}
|
|
/>
|
|
) : (
|
|
<Icon className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="flex-1">{command.label}</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="border-t px-4 py-2 text-xs text-muted-foreground flex items-center gap-4">
|
|
<div className="flex items-center gap-1">
|
|
<kbd className="px-1.5 py-0.5 bg-muted rounded">↑</kbd>
|
|
<kbd className="px-1.5 py-0.5 bg-muted rounded">↓</kbd>
|
|
<span>Navigate</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<kbd className="px-1.5 py-0.5 bg-muted rounded">Enter</kbd>
|
|
<span>Select</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<kbd className="px-1.5 py-0.5 bg-muted rounded">ESC</kbd>
|
|
<span>Close</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|