feat: implement Phase 4 - Command palette, visual comparison, and polish
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>
This commit is contained in:
21
app/page.tsx
21
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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{/* Header with theme toggle */}
|
||||
<div className="border-b">
|
||||
<div className="border-b sticky top-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-40">
|
||||
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<div className="text-xl font-bold">Units UI</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<main className="container mx-auto px-4 py-8 flex-1">
|
||||
<header className="text-center mb-12">
|
||||
<h1 className="text-5xl font-bold mb-4 bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-400 dark:to-indigo-400 bg-clip-text text-transparent">
|
||||
Unit Converter
|
||||
@@ -20,13 +21,21 @@ export default function Home() {
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Convert between 187 units across 23 measurement categories
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Press <kbd className="px-2 py-1 bg-muted rounded text-xs">/</kbd> to search units
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 mt-3 text-sm text-muted-foreground">
|
||||
<span>Press <kbd className="px-2 py-1 bg-muted rounded text-xs">/</kbd> to search</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-xs">Ctrl</kbd>
|
||||
{' + '}
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-xs">K</kbd> for commands
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MainConverter />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Measure>('length');
|
||||
const [selectedUnit, setSelectedUnit] = useState<string>('m');
|
||||
const [targetUnit, setTargetUnit] = useState<string>('ft');
|
||||
const [inputValue, setInputValue] = useState<string>('1');
|
||||
const [conversions, setConversions] = useState<ConversionResult[]>([]);
|
||||
const [favorites, setFavorites] = useState<string[]>([]);
|
||||
const [copiedUnit, setCopiedUnit] = useState<string | null>(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 (
|
||||
<div className="w-full max-w-6xl mx-auto space-y-6">
|
||||
{/* Command Palette */}
|
||||
<CommandPalette
|
||||
onSelectMeasure={setSelectedMeasure}
|
||||
onSelectUnit={handleSearchSelect}
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex justify-center">
|
||||
<SearchUnits onSelectUnit={handleSearchSelect} />
|
||||
@@ -144,7 +169,7 @@ export default function MainConverter() {
|
||||
<CardTitle>Convert {formatMeasureName(selectedMeasure)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-2 block">Value</label>
|
||||
<Input
|
||||
@@ -156,8 +181,8 @@ export default function MainConverter() {
|
||||
className="text-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<label className="text-sm font-medium mb-2 block">From Unit</label>
|
||||
<div className="w-40">
|
||||
<label className="text-sm font-medium mb-2 block">From</label>
|
||||
<select
|
||||
value={selectedUnit}
|
||||
onChange={(e) => setSelectedUnit(e.target.value)}
|
||||
@@ -170,18 +195,69 @@ export default function MainConverter() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleSwapUnits}
|
||||
className="flex-shrink-0"
|
||||
title="Swap units"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-40">
|
||||
<label className="text-sm font-medium mb-2 block">To</label>
|
||||
<select
|
||||
value={targetUnit}
|
||||
onChange={(e) => setTargetUnit(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{units.map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{unit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick result */}
|
||||
{parseNumberInput(inputValue) !== null && (
|
||||
<div className="p-4 rounded-lg bg-accent/50 border-l-4" style={{
|
||||
borderLeftColor: `var(--color-${getCategoryColor(selectedMeasure)})`,
|
||||
}}>
|
||||
<div className="text-sm text-muted-foreground">Result</div>
|
||||
<div className="text-3xl font-bold mt-1">
|
||||
{formatNumber(convertUnit(parseNumberInput(inputValue)!, selectedUnit, targetUnit))} {targetUnit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Conversions</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>All Conversions</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowVisualComparison(!showVisualComparison)}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
{showVisualComparison ? 'Grid View' : 'Chart View'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{conversions.map((conversion) => {
|
||||
{showVisualComparison ? (
|
||||
<VisualComparison
|
||||
conversions={conversions}
|
||||
color={getCategoryColor(selectedMeasure)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{conversions.map((conversion) => {
|
||||
const isFavorite = favorites.includes(conversion.unit);
|
||||
const isCopied = copiedUnit === conversion.unit;
|
||||
|
||||
@@ -235,7 +311,8 @@ export default function MainConverter() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
60
components/converter/VisualComparison.tsx
Normal file
60
components/converter/VisualComparison.tsx
Normal file
@@ -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 (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Enter a value to see conversions
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{withPercentages.map(item => (
|
||||
<div key={item.unit} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{item.unitInfo.plural}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatNumber(item.value)} {item.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${item.percentage}%`,
|
||||
backgroundColor: `var(--color-${color})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
components/ui/CommandPalette.tsx
Normal file
223
components/ui/CommandPalette.tsx
Normal file
@@ -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<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
components/ui/Footer.tsx
Normal file
94
components/ui/Footer.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Heart, Github, Code2 } from 'lucide-react';
|
||||
|
||||
export default function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="border-t mt-16 bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* About */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Units UI</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A spectacular unit conversion app supporting 23 measurement categories
|
||||
with 187 units. Built with Next.js 16, TypeScript, and Tailwind CSS 4.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Features</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>• Real-time bidirectional conversion</li>
|
||||
<li>• Fuzzy search across all units</li>
|
||||
<li>• Dark mode support</li>
|
||||
<li>• Conversion history</li>
|
||||
<li>• Keyboard shortcuts</li>
|
||||
<li>• Copy & favorite units</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Quick Links</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/units-ui"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
GitHub Repository
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/convert-units/convert-units"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Code2 className="h-4 w-4" />
|
||||
convert-units Library
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="mt-4">
|
||||
<h4 className="font-semibold text-sm mb-2">Keyboard Shortcuts</h4>
|
||||
<ul className="space-y-1 text-xs text-muted-foreground">
|
||||
<li>
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded">/</kbd> Focus search
|
||||
</li>
|
||||
<li>
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded">Ctrl</kbd>
|
||||
{' + '}
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded">K</kbd> Command palette
|
||||
</li>
|
||||
<li>
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded">ESC</kbd> Close dialogs
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="mt-8 pt-8 border-t flex flex-col sm:flex-row justify-between items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
Made with{' '}
|
||||
<Heart className="h-4 w-4 text-red-500 fill-red-500" />{' '}
|
||||
using Next.js 16 & Tailwind CSS 4
|
||||
</div>
|
||||
<div>
|
||||
© {currentYear} Units UI. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user