feat: implement Figlet, Pastel, and Unit tools with a unified layout

- Add Figlet text converter with font selection and history
- Add Pastel color palette generator and manipulation suite
- Add comprehensive Units converter with category-based logic
- Introduce AppShell with Sidebar and Header for navigation
- Modernize theme system with CSS variables and new animations
- Update project configuration and dependencies
This commit is contained in:
2026-02-22 21:35:53 +01:00
parent ff6bb873eb
commit 2000623c67
540 changed files with 338653 additions and 809 deletions

View 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/units/storage';
import { getRelativeTime, formatNumber } from '@/lib/units/utils';
import { formatMeasureName } from '@/lib/units/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>
);
}

View File

@@ -0,0 +1,345 @@
'use client';
import { useState, useEffect, useCallback } from '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,
convertToAll,
convertUnit,
formatMeasureName,
getCategoryColor,
getCategoryColorHex,
type Measure,
type ConversionResult,
} from '@/lib/units/units';
import { parseNumberInput, formatNumber, cn } from '@/lib/units/utils';
import { saveToHistory, getFavorites, toggleFavorite } from '@/lib/units/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 [isDragging, setIsDragging] = useState(false);
const measures = getAllMeasures();
const units = getUnitsForMeasure(selectedMeasure);
// Load favorites
useEffect(() => {
setFavorites(getFavorites());
}, []);
// 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]);
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 {
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 (but not during dragging)
useEffect(() => {
if (isDragging) return; // Don't save to history while dragging
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, isDragging]);
// 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);
}, []);
// Handle value change from draggable bars
const handleValueChange = useCallback((value: number, unit: string, dragging: boolean) => {
setIsDragging(dragging);
// Convert the dragged unit's value back to the currently selected unit
// This keeps the source unit stable while updating the value
const convertedValue = convertUnit(value, unit, selectedUnit);
setInputValue(convertedValue.toString());
// Keep selectedUnit unchanged
}, [selectedUnit]);
return (
<div className="w-full space-y-6">
{/* Command Palette */}
<CommandPalette
onSelectMeasure={setSelectedMeasure}
onSelectUnit={handleSearchSelect}
/>
{/* Search */}
<div className="flex justify-center">
<SearchUnits onSelectUnit={handleSearchSelect} />
</div>
{/* 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
? getCategoryColorHex(measure)
: undefined,
borderColor: selectedMeasure !== measure
? getCategoryColorHex(measure)
: undefined,
color: selectedMeasure === measure ? 'white' : 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-2 items-end">
<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-40">
<label className="text-sm font-medium mb-2 block">From</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>
<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: getCategoryColorHex(selectedMeasure),
}}>
<div className="text-sm text-muted-foreground">Result</div>
<div className="text-3xl font-bold mt-1" style={{
color: getCategoryColorHex(selectedMeasure),
}}>
{formatNumber(convertUnit(parseNumberInput(inputValue)!, selectedUnit, targetUnit))} {targetUnit}
</div>
</div>
)}
</CardContent>
</Card>
{/* Results */}
<Card>
<CardHeader>
<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>
{showVisualComparison ? (
<VisualComparison
conversions={conversions}
color={getCategoryColorHex(selectedMeasure)}
onValueChange={handleValueChange}
/>
) : (
<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;
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: getCategoryColorHex(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>
)}
</CardContent>
</Card>
{/* Conversion History */}
<ConversionHistory onSelectConversion={handleHistorySelect} />
</div>
);
}

View File

@@ -0,0 +1,201 @@
'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,
getCategoryColorHex,
type Measure,
type UnitInfo,
} from '@/lib/units/units';
import { cn } from '@/lib/units/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: getCategoryColorHex(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>
);
}

View File

@@ -0,0 +1,314 @@
'use client';
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
import { type ConversionResult } from '@/lib/units/units';
import { formatNumber, cn } from '@/lib/units/utils';
interface VisualComparisonProps {
conversions: ConversionResult[];
color: string;
onValueChange?: (value: number, unit: string, dragging: boolean) => void;
}
export default function VisualComparison({
conversions,
color,
onValueChange,
}: VisualComparisonProps) {
const [draggingUnit, setDraggingUnit] = useState<string | null>(null);
const [draggedPercentage, setDraggedPercentage] = useState<number | null>(null);
const dragStartX = useRef<number>(0);
const dragStartWidth = useRef<number>(0);
const activeBarRef = useRef<HTMLDivElement | null>(null);
const lastUpdateTime = useRef<number>(0);
const baseConversionsRef = useRef<ConversionResult[]>([]);
// Calculate percentages for visual bars using logarithmic scale
const withPercentages = useMemo(() => {
if (conversions.length === 0) return [];
// Use base conversions for scale if we're dragging (keeps scale stable)
const scaleSource = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
// Get all values from the SCALE SOURCE (not current conversions)
const values = scaleSource.map(c => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
if (maxValue === 0 || !isFinite(maxValue)) {
return conversions.map(c => ({ ...c, percentage: 0 }));
}
// Use logarithmic scale for better visualization
return conversions.map(c => {
const absValue = Math.abs(c.value);
if (absValue === 0 || !isFinite(absValue)) {
return { ...c, percentage: 2 }; // Show minimal bar
}
// Logarithmic scale
const logValue = Math.log10(absValue);
const logMax = Math.log10(maxValue);
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6; // 6 orders of magnitude range
const logRange = logMax - logMin;
let percentage: number;
if (logRange === 0) {
percentage = 100;
} else {
percentage = ((logValue - logMin) / logRange) * 100;
// Ensure bars are visible - minimum 3%, maximum 100%
percentage = Math.max(3, Math.min(100, percentage));
}
return {
...c,
percentage,
};
});
}, [conversions]);
// Calculate value from percentage (reverse logarithmic scale)
const calculateValueFromPercentage = useCallback((
percentage: number,
minValue: number,
maxValue: number
): number => {
const logMax = Math.log10(maxValue);
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
const logRange = logMax - logMin;
// Convert percentage back to log value
const logValue = logMin + (percentage / 100) * logRange;
// Convert log value back to actual value
return Math.pow(10, logValue);
}, []);
// Mouse drag handlers
const handleMouseDown = useCallback((e: React.MouseEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
if (!onValueChange) return;
e.preventDefault();
setDraggingUnit(unit);
setDraggedPercentage(currentPercentage);
dragStartX.current = e.clientX;
dragStartWidth.current = currentPercentage;
activeBarRef.current = barElement;
// Save the current conversions as reference
baseConversionsRef.current = [...conversions];
}, [onValueChange, conversions]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
// Throttle updates to every 16ms (~60fps)
const now = Date.now();
if (now - lastUpdateTime.current < 16) return;
lastUpdateTime.current = now;
const barWidth = activeBarRef.current.offsetWidth;
const deltaX = e.clientX - dragStartX.current;
const deltaPercentage = (deltaX / barWidth) * 100;
let newPercentage = dragStartWidth.current + deltaPercentage;
newPercentage = Math.max(3, Math.min(100, newPercentage));
// Update visual percentage immediately
setDraggedPercentage(newPercentage);
// Use the base conversions (from when drag started) for scale calculation
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
// Calculate min/max values for the scale from BASE conversions
const values = baseConversions.map(c => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
// Calculate new value from percentage
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
onValueChange(newValue, draggingUnit, true); // true = currently dragging
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
const handleMouseUp = useCallback(() => {
if (draggingUnit && onValueChange) {
// Find the current value for the dragged unit
const conversion = conversions.find(c => c.unit === draggingUnit);
if (conversion) {
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
}
}
setDraggingUnit(null);
// Don't clear draggedPercentage yet - let it clear when conversions update
activeBarRef.current = null;
// baseConversionsRef cleared after conversions update
}, [draggingUnit, conversions, onValueChange]);
// Touch drag handlers
const handleTouchStart = useCallback((e: React.TouchEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
if (!onValueChange) return;
const touch = e.touches[0];
setDraggingUnit(unit);
setDraggedPercentage(currentPercentage);
dragStartX.current = touch.clientX;
dragStartWidth.current = currentPercentage;
activeBarRef.current = barElement;
// Save the current conversions as reference
baseConversionsRef.current = [...conversions];
}, [onValueChange, conversions]);
const handleTouchMove = useCallback((e: TouchEvent) => {
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
// Throttle updates to every 16ms (~60fps)
const now = Date.now();
if (now - lastUpdateTime.current < 16) return;
lastUpdateTime.current = now;
e.preventDefault(); // Prevent scrolling while dragging
const touch = e.touches[0];
const barWidth = activeBarRef.current.offsetWidth;
const deltaX = touch.clientX - dragStartX.current;
const deltaPercentage = (deltaX / barWidth) * 100;
let newPercentage = dragStartWidth.current + deltaPercentage;
newPercentage = Math.max(3, Math.min(100, newPercentage));
// Update visual percentage immediately
setDraggedPercentage(newPercentage);
// Use the base conversions (from when drag started) for scale calculation
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
const values = baseConversions.map(c => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
onValueChange(newValue, draggingUnit, true); // true = currently dragging
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
const handleTouchEnd = useCallback(() => {
if (draggingUnit && onValueChange) {
// Find the current value for the dragged unit
const conversion = conversions.find(c => c.unit === draggingUnit);
if (conversion) {
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
}
}
setDraggingUnit(null);
// Don't clear draggedPercentage yet - let it clear when conversions update
activeBarRef.current = null;
// baseConversionsRef cleared after conversions update
}, [draggingUnit, conversions, onValueChange]);
// Add/remove global event listeners for drag
useEffect(() => {
if (draggingUnit) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
}, [draggingUnit, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
// Clear drag state when conversions update after drag ends
useEffect(() => {
if (!draggingUnit && draggedPercentage !== null) {
// Drag has ended, conversions have updated, now clear visual state
setDraggedPercentage(null);
baseConversionsRef.current = [];
}
}, [conversions, draggingUnit, draggedPercentage]);
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 => {
const isDragging = draggingUnit === item.unit;
const isDraggable = !!onValueChange;
// Use draggedPercentage if this bar is being dragged
const displayPercentage = isDragging && draggedPercentage !== null ? draggedPercentage : item.percentage;
return (
<div key={item.unit} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-4">
<span className="text-sm font-medium text-foreground min-w-0 flex-shrink">
{item.unitInfo.plural}
</span>
<span className="text-lg font-bold tabular-nums flex-shrink-0">
{formatNumber(item.value)}
<span className="text-sm font-normal text-muted-foreground ml-1">
{item.unit}
</span>
</span>
</div>
{/* Progress bar */}
<div
className={cn(
"w-full h-8 bg-muted rounded-lg overflow-hidden border border-border relative",
"transition-all duration-200",
isDraggable && "cursor-grab active:cursor-grabbing",
isDragging && "ring-2 ring-ring ring-offset-2 ring-offset-background scale-105"
)}
onMouseDown={(e) => {
if (isDraggable && e.currentTarget instanceof HTMLDivElement) {
handleMouseDown(e, item.unit, item.percentage, e.currentTarget);
}
}}
onTouchStart={(e) => {
if (isDraggable && e.currentTarget instanceof HTMLDivElement) {
handleTouchStart(e, item.unit, item.percentage, e.currentTarget);
}
}}
>
{/* Colored fill */}
<div
className={cn(
"absolute inset-y-0 left-0",
draggingUnit ? "transition-none" : "transition-all duration-500 ease-out"
)}
style={{
width: `${displayPercentage}%`,
backgroundColor: color,
}}
/>
{/* Percentage label overlay */}
<div className="absolute inset-0 flex items-center px-3 text-xs font-bold pointer-events-none">
<span className="text-foreground drop-shadow-sm">
{Math.round(displayPercentage)}%
</span>
</div>
{/* Drag hint on hover */}
{isDraggable && !isDragging && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-background/10 backdrop-blur-[1px]">
<span className="text-xs font-semibold text-foreground drop-shadow-md">
Drag to adjust
</span>
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View 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;
};

View File

@@ -0,0 +1,103 @@
import { Heart, Github, Code2 } from 'lucide-react';
import Link from 'next/link';
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="border-t mt-16 bg-background">
<div className="w-full max-w-7xl 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>
<Link
href="/"
className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2"
>
Back to Kit Home
</Link>
</li>
<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>
);
}