feat: implement Phase 2 - Core conversion engine and UI

Complete Phase 2 implementation with working unit converter:

Core Conversion Engine (lib/units.ts):
- Type-safe wrapper for convert-units library
- Support for all 23 measures with TypeScript types
- getAllMeasures() - Get all available categories
- getUnitsForMeasure() - Get units for specific measure
- getUnitInfo() - Get detailed unit information
- convertUnit() - Convert between two units
- convertToAll() - Convert to all compatible units
- getCategoryColor() - Get Tailwind color class for measure
- formatMeasureName() - Format measure names for display
- searchUnits() - Fuzzy search across all units

Utility Functions (lib/utils.ts):
- cn() - Merge Tailwind classes with clsx and tailwind-merge
- formatNumber() - Smart number formatting with scientific notation
- debounce() - Debounce helper for inputs
- parseNumberInput() - Parse user input to number
- getRelativeTime() - Format timestamps

UI Components:
- Input - Styled input with focus states
- Button - 6 variants (default, destructive, outline, secondary, ghost, link)
- Card - Card container with header, title, description, content, footer

Main Converter Component (components/converter/MainConverter.tsx):
- Real-time conversion as user types
- Category selection with 23 color-coded buttons
- Input field with unit selector
- Grid display of all conversions in selected measure
- Color-coded result cards with category colors
- Responsive layout (1/2/3 column grid)

Homepage Updates:
- Integrated MainConverter component
- Clean header with gradient text
- Uses design system colors

Dependencies Added:
- clsx - Class name utilities
- tailwind-merge - Merge Tailwind classes intelligently

Features Working:
✓ Select from 23 measurement categories
✓ Real-time conversion to all compatible units
✓ Color-coded categories
✓ Formatted number display
✓ Responsive design

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-08 09:34:57 +01:00
parent 365b8ed328
commit 901d9047e2
9 changed files with 652 additions and 17 deletions

View File

@@ -1,23 +1,19 @@
import MainConverter from '@/components/converter/MainConverter';
export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
<div className="min-h-screen bg-background">
<main className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<header className="text-center mb-12">
<h1 className="text-5xl font-bold mb-4 bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
Unit Converter
</h1>
<p className="text-lg text-gray-600 dark:text-gray-300">
Convert between 187 units across 23 measurement categories
</p>
</header>
<header className="text-center mb-12">
<h1 className="text-5xl font-bold mb-4 bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
Unit Converter
</h1>
<p className="text-lg text-muted-foreground">
Convert between 187 units across 23 measurement categories
</p>
</header>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-8">
<p className="text-center text-gray-500 dark:text-gray-400">
Coming soon: Real-time bidirectional conversion with innovative UX
</p>
</div>
</div>
<MainConverter />
</main>
</div>
);

View File

@@ -0,0 +1,144 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
getAllMeasures,
getUnitsForMeasure,
convertToAll,
formatMeasureName,
getCategoryColor,
type Measure,
type ConversionResult,
} from '@/lib/units';
import { parseNumberInput, formatNumber } from '@/lib/utils';
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 measures = getAllMeasures();
const units = getUnitsForMeasure(selectedMeasure);
// Update conversions when input changes
useEffect(() => {
const numValue = parseNumberInput(inputValue);
if (numValue !== null && selectedUnit) {
const results = convertToAll(numValue, selectedUnit);
setConversions(results);
} else {
setConversions([]);
}
}, [inputValue, selectedUnit]);
// Update selected unit when measure changes
useEffect(() => {
const availableUnits = getUnitsForMeasure(selectedMeasure);
if (availableUnits.length > 0) {
setSelectedUnit(availableUnits[0]);
}
}, [selectedMeasure]);
return (
<div className="w-full max-w-6xl mx-auto space-y-6">
{/* Category Selection */}
<Card>
<CardHeader>
<CardTitle>Select Category</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2">
{measures.map((measure) => (
<Button
key={measure}
variant={selectedMeasure === measure ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedMeasure(measure)}
className="justify-start"
style={{
backgroundColor:
selectedMeasure === measure
? `var(--color-${getCategoryColor(measure)})`
: undefined,
}}
>
{formatMeasureName(measure)}
</Button>
))}
</div>
</CardContent>
</Card>
{/* Input Section */}
<Card>
<CardHeader>
<CardTitle>Convert {formatMeasureName(selectedMeasure)}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">Value</label>
<Input
type="text"
inputMode="decimal"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Enter value"
className="text-lg"
/>
</div>
<div className="w-48">
<label className="text-sm font-medium mb-2 block">From Unit</label>
<select
value={selectedUnit}
onChange={(e) => setSelectedUnit(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>
</CardContent>
</Card>
{/* Results */}
<Card>
<CardHeader>
<CardTitle>Conversions</CardTitle>
</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}
</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>
</div>
);
}

46
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,46 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
return (
<button
className={cn(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
{
'bg-primary text-primary-foreground hover:bg-primary/90':
variant === 'default',
'bg-destructive text-destructive-foreground hover:bg-destructive/90':
variant === 'destructive',
'border border-input bg-background hover:bg-accent hover:text-accent-foreground':
variant === 'outline',
'bg-secondary text-secondary-foreground hover:bg-secondary/80':
variant === 'secondary',
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
'text-primary underline-offset-4 hover:underline': variant === 'link',
},
{
'h-10 px-4 py-2': size === 'default',
'h-9 rounded-md px-3': size === 'sm',
'h-11 rounded-md px-8': size === 'lg',
'h-10 w-10': size === 'icon',
},
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button };

78
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,78 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

28
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

218
lib/units.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* Unit conversion service wrapper for convert-units library
* Provides type-safe conversion utilities and metadata
*/
import convert from 'convert-units';
export type Measure =
| 'angle'
| 'apparentPower'
| 'area'
| 'current'
| 'digital'
| 'each'
| 'energy'
| 'frequency'
| 'illuminance'
| 'length'
| 'mass'
| 'pace'
| 'partsPer'
| 'power'
| 'pressure'
| 'reactiveEnergy'
| 'reactivePower'
| 'speed'
| 'temperature'
| 'time'
| 'voltage'
| 'volume'
| 'volumeFlowRate';
export interface UnitInfo {
abbr: string;
measure: Measure;
system: 'metric' | 'imperial' | 'bits' | 'bytes' | string;
singular: string;
plural: string;
}
export interface ConversionResult {
value: number;
unit: string;
unitInfo: UnitInfo;
}
/**
* Get all available measures/categories
*/
export function getAllMeasures(): Measure[] {
return convert().measures() as Measure[];
}
/**
* Get all units for a specific measure
*/
export function getUnitsForMeasure(measure: Measure): string[] {
return convert().possibilities(measure);
}
/**
* Get detailed information about a unit
*/
export function getUnitInfo(unit: string): UnitInfo | null {
try {
const description = convert().describe(unit);
return description as UnitInfo;
} catch {
return null;
}
}
/**
* Convert a value from one unit to another
*/
export function convertUnit(
value: number,
fromUnit: string,
toUnit: string
): number {
try {
return convert(value).from(fromUnit).to(toUnit);
} catch (error) {
console.error('Conversion error:', error);
return value;
}
}
/**
* Convert a value to all compatible units in the same measure
*/
export function convertToAll(
value: number,
fromUnit: string
): ConversionResult[] {
try {
const unitInfo = getUnitInfo(fromUnit);
if (!unitInfo) return [];
const compatibleUnits = getUnitsForMeasure(unitInfo.measure);
return compatibleUnits.map(toUnit => {
const convertedValue = convertUnit(value, fromUnit, toUnit);
const toUnitInfo = getUnitInfo(toUnit);
return {
value: convertedValue,
unit: toUnit,
unitInfo: toUnitInfo!,
};
});
} catch (error) {
console.error('Conversion error:', error);
return [];
}
}
/**
* Get category color for a measure
*/
export function getCategoryColor(measure: Measure): string {
const colorMap: Record<Measure, string> = {
angle: 'category-angle',
apparentPower: 'category-apparent-power',
area: 'category-area',
current: 'category-current',
digital: 'category-digital',
each: 'category-each',
energy: 'category-energy',
frequency: 'category-frequency',
illuminance: 'category-illuminance',
length: 'category-length',
mass: 'category-mass',
pace: 'category-pace',
partsPer: 'category-parts-per',
power: 'category-power',
pressure: 'category-pressure',
reactiveEnergy: 'category-reactive-energy',
reactivePower: 'category-reactive-power',
speed: 'category-speed',
temperature: 'category-temperature',
time: 'category-time',
voltage: 'category-voltage',
volume: 'category-volume',
volumeFlowRate: 'category-volume-flow-rate',
};
return colorMap[measure];
}
/**
* Format measure name for display
*/
export function formatMeasureName(measure: Measure): string {
const nameMap: Record<Measure, string> = {
angle: 'Angle',
apparentPower: 'Apparent Power',
area: 'Area',
current: 'Current',
digital: 'Digital Storage',
each: 'Each',
energy: 'Energy',
frequency: 'Frequency',
illuminance: 'Illuminance',
length: 'Length',
mass: 'Mass',
pace: 'Pace',
partsPer: 'Parts Per',
power: 'Power',
pressure: 'Pressure',
reactiveEnergy: 'Reactive Energy',
reactivePower: 'Reactive Power',
speed: 'Speed',
temperature: 'Temperature',
time: 'Time',
voltage: 'Voltage',
volume: 'Volume',
volumeFlowRate: 'Volume Flow Rate',
};
return nameMap[measure];
}
/**
* Search units by query string (fuzzy search)
*/
export function searchUnits(query: string): UnitInfo[] {
if (!query) return [];
const allMeasures = getAllMeasures();
const results: UnitInfo[] = [];
const lowerQuery = query.toLowerCase();
for (const measure of allMeasures) {
const units = getUnitsForMeasure(measure);
for (const unit of units) {
const info = getUnitInfo(unit);
if (!info) continue;
const searchableText = [
info.abbr,
info.singular,
info.plural,
measure,
formatMeasureName(measure),
]
.join(' ')
.toLowerCase();
if (searchableText.includes(lowerQuery)) {
results.push(info);
}
}
}
return results;
}

106
lib/utils.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Utility functions for the application
*/
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merge Tailwind CSS classes with clsx
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Format a number for display with proper precision
*/
export function formatNumber(
value: number,
options: {
maxDecimals?: number;
minDecimals?: number;
notation?: 'standard' | 'scientific' | 'engineering' | 'compact';
} = {}
): string {
const {
maxDecimals = 6,
minDecimals = 0,
notation = 'standard',
} = options;
// Handle edge cases
if (!isFinite(value)) return value.toString();
if (value === 0) return '0';
// Use scientific notation for very large or very small numbers
const absValue = Math.abs(value);
const useScientific =
notation === 'scientific' ||
(notation === 'standard' && (absValue >= 1e10 || absValue < 1e-6));
if (useScientific) {
return value.toExponential(maxDecimals);
}
// Format with appropriate decimal places
const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: minDecimals,
maximumFractionDigits: maxDecimals,
notation: notation === 'compact' ? 'compact' : 'standard',
}).format(value);
return formatted;
}
/**
* Debounce function for input handling
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Parse a number input string
*/
export function parseNumberInput(input: string): number | null {
if (!input || input.trim() === '') return null;
// Remove spaces and replace comma with dot
const cleaned = input.replace(/\s/g, '').replace(',', '.');
const parsed = parseFloat(cleaned);
return isNaN(parsed) ? null : parsed;
}
/**
* Get relative time from timestamp
*/
export function getRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}

View File

@@ -9,10 +9,12 @@
"lint": "next lint"
},
"dependencies": {
"clsx": "^2.1.1",
"convert-units": "^2.3.4",
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.17",

17
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
clsx:
specifier: ^2.1.1
version: 2.1.1
convert-units:
specifier: ^2.3.4
version: 2.3.4
@@ -20,6 +23,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.0(react@19.2.0)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.1.17
@@ -802,6 +808,10 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1855,6 +1865,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwindcss@4.1.17:
resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==}
@@ -2721,6 +2734,8 @@ snapshots:
client-only@0.0.1: {}
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -4001,6 +4016,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tailwind-merge@3.3.1: {}
tailwindcss@4.1.17: {}
tapable@2.3.0: {}