From 7f7bc69d04885a4d0a925007db8eb62c9e1c76c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sat, 8 Nov 2025 10:20:32 +0100 Subject: [PATCH] feat: implement Phase 4 - Command palette, visual comparison, and polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/page.tsx | 21 +- components/converter/MainConverter.tsx | 93 ++++++++- components/converter/VisualComparison.tsx | 60 ++++++ components/ui/CommandPalette.tsx | 223 ++++++++++++++++++++++ components/ui/Footer.tsx | 94 +++++++++ 5 files changed, 477 insertions(+), 14 deletions(-) create mode 100644 components/converter/VisualComparison.tsx create mode 100644 components/ui/CommandPalette.tsx create mode 100644 components/ui/Footer.tsx diff --git a/app/page.tsx b/app/page.tsx index e7947d5..d078f13 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,19 @@ import MainConverter from '@/components/converter/MainConverter'; import { ThemeToggle } from '@/components/ui/ThemeToggle'; +import Footer from '@/components/ui/Footer'; export default function Home() { return ( -
+
{/* Header with theme toggle */} -
+
Units UI
-
+

Unit Converter @@ -20,13 +21,21 @@ export default function Home() {

Convert between 187 units across 23 measurement categories

-

- Press / to search units -

+
+ Press / to search + + + Ctrl + {' + '} + K for commands + +

+ +
); } diff --git a/components/converter/MainConverter.tsx b/components/converter/MainConverter.tsx index 742d232..7415f79 100644 --- a/components/converter/MainConverter.tsx +++ b/components/converter/MainConverter.tsx @@ -1,12 +1,14 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { Copy, Star, Check } from 'lucide-react'; +import { Copy, Star, Check, ArrowLeftRight, BarChart3 } 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 VisualComparison from './VisualComparison'; +import CommandPalette from '@/components/ui/CommandPalette'; import { getAllMeasures, getUnitsForMeasure, @@ -23,10 +25,12 @@ import { saveToHistory, getFavorites, toggleFavorite } from '@/lib/storage'; export default function MainConverter() { const [selectedMeasure, setSelectedMeasure] = useState('length'); const [selectedUnit, setSelectedUnit] = useState('m'); + const [targetUnit, setTargetUnit] = useState('ft'); const [inputValue, setInputValue] = useState('1'); const [conversions, setConversions] = useState([]); const [favorites, setFavorites] = useState([]); const [copiedUnit, setCopiedUnit] = useState(null); + const [showVisualComparison, setShowVisualComparison] = useState(false); const measures = getAllMeasures(); const units = getUnitsForMeasure(selectedMeasure); @@ -52,9 +56,24 @@ export default function MainConverter() { const availableUnits = getUnitsForMeasure(selectedMeasure); if (availableUnits.length > 0) { setSelectedUnit(availableUnits[0]); + setTargetUnit(availableUnits[1] || availableUnits[0]); } }, [selectedMeasure]); + // Swap units + const handleSwapUnits = useCallback(() => { + const temp = selectedUnit; + setSelectedUnit(targetUnit); + setTargetUnit(temp); + + // Convert the value + const numValue = parseNumberInput(inputValue); + if (numValue !== null) { + const converted = convertUnit(numValue, selectedUnit, targetUnit); + setInputValue(converted.toString()); + } + }, [selectedUnit, targetUnit, inputValue]); + // Copy to clipboard const copyToClipboard = useCallback(async (value: number, unit: string) => { try { @@ -105,6 +124,12 @@ export default function MainConverter() { return (
+ {/* Command Palette */} + + {/* Search */}
@@ -144,7 +169,7 @@ export default function MainConverter() { Convert {formatMeasureName(selectedMeasure)} -
+
-
- +
+
+ +
+ + +
+ + {/* Quick result */} + {parseNumberInput(inputValue) !== null && ( +
+
Result
+
+ {formatNumber(convertUnit(parseNumberInput(inputValue)!, selectedUnit, targetUnit))} {targetUnit} +
+
+ )} {/* Results */} - Conversions +
+ All Conversions + +
-
- {conversions.map((conversion) => { + {showVisualComparison ? ( + + ) : ( +
+ {conversions.map((conversion) => { const isFavorite = favorites.includes(conversion.unit); const isCopied = copiedUnit === conversion.unit; @@ -235,7 +311,8 @@ export default function MainConverter() {
); })} -
+
+ )} diff --git a/components/converter/VisualComparison.tsx b/components/converter/VisualComparison.tsx new file mode 100644 index 0000000..024981a --- /dev/null +++ b/components/converter/VisualComparison.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useMemo } from 'react'; +import { type ConversionResult } from '@/lib/units'; +import { formatNumber, cn } from '@/lib/utils'; + +interface VisualComparisonProps { + conversions: ConversionResult[]; + color: string; +} + +export default function VisualComparison({ + conversions, + color, +}: VisualComparisonProps) { + // Calculate percentages for visual bars + const withPercentages = useMemo(() => { + if (conversions.length === 0) return []; + + const maxValue = Math.max(...conversions.map(c => Math.abs(c.value))); + if (maxValue === 0) return conversions.map(c => ({ ...c, percentage: 0 })); + + return conversions.map(c => ({ + ...c, + percentage: (Math.abs(c.value) / maxValue) * 100, + })); + }, [conversions]); + + if (conversions.length === 0) { + return ( +
+ Enter a value to see conversions +
+ ); + } + + return ( +
+ {withPercentages.map(item => ( +
+
+ {item.unitInfo.plural} + + {formatNumber(item.value)} {item.unit} + +
+
+
+
+
+ ))} +
+ ); +} diff --git a/components/ui/CommandPalette.tsx b/components/ui/CommandPalette.tsx new file mode 100644 index 0000000..0a0899f --- /dev/null +++ b/components/ui/CommandPalette.tsx @@ -0,0 +1,223 @@ +'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(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 */} +
setIsOpen(false)} + /> + + {/* Command Palette */} +
+
+ {/* Search Input */} +
+ + setQuery(e.target.value)} + className="flex-1 bg-transparent py-4 px-4 outline-none placeholder:text-muted-foreground" + /> + + ESC + +
+ + {/* Commands List */} +
+ {filteredCommands.length === 0 ? ( +
+ No commands found +
+ ) : ( + filteredCommands.map((command, index) => { + const Icon = command.icon; + return ( + + ); + }) + )} +
+ + {/* Footer */} +
+
+ + + Navigate +
+
+ Enter + Select +
+
+ ESC + Close +
+
+
+
+ + ); +} diff --git a/components/ui/Footer.tsx b/components/ui/Footer.tsx new file mode 100644 index 0000000..edabae4 --- /dev/null +++ b/components/ui/Footer.tsx @@ -0,0 +1,94 @@ +import { Heart, Github, Code2 } from 'lucide-react'; + +export default function Footer() { + const currentYear = new Date().getFullYear(); + + return ( + + ); +}