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,38 @@
'use client';
import { cn } from '@/lib/utils/cn';
interface ColorDisplayProps {
color: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
showBorder?: boolean;
}
export function ColorDisplay({
color,
size = 'lg',
className,
showBorder = true,
}: ColorDisplayProps) {
const sizeClasses = {
sm: 'h-16 w-16',
md: 'h-32 w-32',
lg: 'h-48 w-48',
xl: 'h-64 w-64',
};
return (
<div
className={cn(
'rounded-lg transition-all',
showBorder && 'ring-2 ring-border',
sizeClasses[size],
className
)}
style={{ backgroundColor: color }}
role="img"
aria-label={`Color swatch: ${color}`}
/>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import { ColorInfo as ColorInfoType } from '@/lib/pastel/api/types';
import { Button } from '@/components/ui/Button';
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn';
interface ColorInfoProps {
info: ColorInfoType;
className?: string;
}
export function ColorInfo({ info, className }: ColorInfoProps) {
const copyToClipboard = (value: string, label: string) => {
navigator.clipboard.writeText(value);
toast.success(`Copied ${label} to clipboard`);
};
const formatRgb = (rgb: { r: number; g: number; b: number; a?: number }) => {
if (rgb.a !== undefined && rgb.a < 1) {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})`;
}
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
};
const formatHsl = (hsl: { h: number; s: number; l: number; a?: number }) => {
if (hsl.a !== undefined && hsl.a < 1) {
return `hsla(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%, ${hsl.a})`;
}
return `hsl(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%)`;
};
const formatLab = (lab: { l: number; a: number; b: number }) => {
return `lab(${lab.l.toFixed(1)}, ${lab.a.toFixed(1)}, ${lab.b.toFixed(1)})`;
};
const formats = [
{ label: 'Hex', value: info.hex },
{ label: 'RGB', value: formatRgb(info.rgb) },
{ label: 'HSL', value: formatHsl(info.hsl) },
{ label: 'Lab', value: formatLab(info.lab) },
{ label: 'OkLab', value: formatLab(info.oklab) },
];
return (
<div className={cn('space-y-4', className)}>
<div className="grid grid-cols-1 gap-3">
{formats.map((format) => (
<div
key={format.label}
className="flex items-center justify-between p-3 bg-muted rounded-lg"
>
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">{format.label}</div>
<div className="font-mono text-sm">{format.value}</div>
</div>
<Button
size="icon"
variant="ghost"
onClick={() => copyToClipboard(format.value, format.label)}
aria-label={`Copy ${format.label} value`}
>
<Copy className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Brightness</div>
<div className="text-sm font-medium">{(info.brightness * 100).toFixed(1)}%</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Luminance</div>
<div className="text-sm font-medium">{(info.luminance * 100).toFixed(1)}%</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Type</div>
<div className="text-sm font-medium">{info.is_light ? 'Light' : 'Dark'}</div>
</div>
{info.name && typeof info.name === 'string' && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Named</div>
<div className="text-sm font-medium">{info.name}</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { HexColorPicker } from 'react-colorful';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/utils/cn';
interface ColorPickerProps {
color: string;
onChange: (color: string) => void;
className?: string;
}
export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Allow partial input while typing
onChange(value);
};
return (
<div className={cn('space-y-4', className)}>
<HexColorPicker color={color} onChange={onChange} className="w-full" />
<div className="space-y-2">
<label htmlFor="color-input" className="text-sm font-medium">
Color Value
</label>
<Input
id="color-input"
type="text"
value={color}
onChange={handleInputChange}
placeholder="#ff0099 or rgb(255, 0, 153)"
className="font-mono"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { cn } from '@/lib/utils/cn';
import { Check, Copy } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
interface ColorSwatchProps {
color: string;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
onClick?: () => void;
className?: string;
}
export function ColorSwatch({
color,
size = 'md',
showLabel = true,
onClick,
className,
}: ColorSwatchProps) {
const [copied, setCopied] = useState(false);
const sizeClasses = {
sm: 'h-12 w-12',
md: 'h-16 w-16',
lg: 'h-24 w-24',
};
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(color);
setCopied(true);
toast.success(`Copied ${color}`);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className={cn('flex flex-col items-center gap-2', className)}>
<button
className={cn(
'relative rounded-lg ring-2 ring-border transition-all duration-200',
'hover:scale-110 hover:ring-primary hover:shadow-lg',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'group active:scale-95',
sizeClasses[size]
)}
style={{ backgroundColor: color }}
onClick={onClick || handleCopy}
aria-label={`Color ${color}`}
>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 bg-black/30 rounded-lg backdrop-blur-sm">
{copied ? (
<Check className="h-5 w-5 text-white animate-scale-in" />
) : (
<Copy className="h-5 w-5 text-white" />
)}
</div>
</button>
{showLabel && (
<span className="text-xs font-mono text-muted-foreground">{color}</span>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { ColorSwatch } from './ColorSwatch';
import { cn } from '@/lib/utils/cn';
interface PaletteGridProps {
colors: string[];
onColorClick?: (color: string) => void;
className?: string;
}
export function PaletteGrid({ colors, onColorClick, className }: PaletteGridProps) {
if (colors.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
No colors in palette yet
</div>
);
}
return (
<div
className={cn(
'grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4',
className
)}
>
{colors.map((color, index) => (
<ColorSwatch
key={`${color}-${index}`}
color={color}
onClick={onColorClick ? () => onColorClick(color) : undefined}
/>
))}
</div>
);
}