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

View File

@@ -0,0 +1,203 @@
'use client';
import Link from 'next/link';
import { Github, Heart, ExternalLink } from 'lucide-react';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="border-t bg-card/50 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* About */}
<div className="space-y-3">
<h3 className="font-semibold text-lg">Pastel UI</h3>
<p className="text-sm text-muted-foreground">
A modern, interactive web application for color manipulation, palette generation,
and accessibility analysis
</p>
</div>
{/* Resources */}
<div className="space-y-3">
<h3 className="font-semibold">Resources</h3>
<ul className="space-y-2 text-sm">
<li>
<Link href="/pastel/playground" className="text-muted-foreground hover:text-foreground transition-colors">
Color Playground
</Link>
</li>
<li>
<Link href="/pastel/palettes" className="text-muted-foreground hover:text-foreground transition-colors">
Palette Generator
</Link>
</li>
<li>
<Link href="/pastel/accessibility" className="text-muted-foreground hover:text-foreground transition-colors">
Accessibility Tools
</Link>
</li>
<li>
<Link href="/pastel/batch" className="text-muted-foreground hover:text-foreground transition-colors">
Batch Operations
</Link>
</li>
</ul>
</div>
{/* Documentation */}
<div className="space-y-3">
<h3 className="font-semibold">Documentation</h3>
<ul className="space-y-2 text-sm">
<li>
<a
href="https://github.com/valknarness/pastel-ui#readme"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
Getting Started
<ExternalLink className="h-3 w-3" />
</a>
</li>
<li>
<a
href="https://github.com/valknarness/pastel-api#readme"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
API Documentation
<ExternalLink className="h-3 w-3" />
</a>
</li>
<li>
<a
href="https://github.com/sharkdp/pastel"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
Pastel CLI
<ExternalLink className="h-3 w-3" />
</a>
</li>
</ul>
</div>
{/* Community */}
<div className="space-y-3">
<h3 className="font-semibold">Community</h3>
<ul className="space-y-2 text-sm">
<li>
<a
href="https://github.com/valknarness/pastel-ui"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
>
<Github className="h-4 w-4" />
Pastel UI
</a>
</li>
<li>
<a
href="https://github.com/valknarness/pastel-api"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
>
<Github className="h-4 w-4" />
Pastel API
</a>
</li>
<li>
<a
href="https://github.com/valknarness/pastel-ui/issues"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Report an Issue
</a>
</li>
<li>
<a
href="https://github.com/valknarness/pastel-ui/discussions"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Discussions
</a>
</li>
</ul>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-12 pt-8 border-t">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="text-sm text-muted-foreground">
<p className="inline-flex items-center gap-1">
© {currentYear} Pastel UI. Built with
<Heart className="h-4 w-4 text-red-500 fill-red-500" />
using
<a
href="https://nextjs.org"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors underline"
>
Next.js
</a>
and
<a
href="https://tailwindcss.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors underline"
>
Tailwind CSS
</a>
</p>
</div>
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<p>
Based on{' '}
<a
href="https://github.com/sharkdp/pastel"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors underline"
>
Pastel
</a>
{' '}by{' '}
<a
href="https://github.com/sharkdp"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors underline"
>
David Peter
</a>
</p>
<span></span>
<a
href="https://github.com/valknarness/pastel-ui/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
MIT License
</a>
</div>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ThemeToggle } from './ThemeToggle';
import { cn } from '@/lib/utils/cn';
import { Palette } from 'lucide-react';
const navigation = [
{ name: 'Home', href: '/pastel' },
{ name: 'Playground', href: '/pastel/playground' },
{ name: 'Palettes', href: '/pastel/palettes' },
{ name: 'Accessibility', href: '/pastel/accessibility' },
{ name: 'Named Colors', href: '/pastel/names' },
{ name: 'Batch', href: '/pastel/batch' },
];
export function Navbar() {
const pathname = usePathname();
return (
<nav className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="max-w-7xl mx-auto px-8">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2 font-bold text-xl">
<Palette className="h-6 w-6 text-primary" />
<span className="bg-gradient-to-r from-pink-500 via-purple-500 to-blue-500 bg-clip-text text-transparent">
Pastel UI
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-1">
{navigation.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
pathname === item.href
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
{item.name}
</Link>
))}
</div>
{/* Right side */}
<div className="flex items-center space-x-2">
<ThemeToggle />
</div>
</div>
{/* Mobile Navigation */}
<div className="md:hidden pb-3 space-y-1">
{navigation.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'block px-3 py-2 rounded-md text-sm font-medium transition-colors',
pathname === item.href
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
{item.name}
</Link>
))}
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '@/components/providers/ThemeProvider';
import { Button } from '@/components/ui/Button';
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const toggleTheme = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
>
{resolvedTheme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner';
import { useState } from 'react';
import { ThemeProvider } from './ThemeProvider';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
);
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" richColors />
</QueryClientProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
// Load theme from localStorage
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) {
setTheme(stored);
}
}, []);
useEffect(() => {
const root = window.document.documentElement;
// Remove previous theme classes
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
setResolvedTheme(systemTheme);
} else {
root.classList.add(theme);
setResolvedTheme(theme);
}
// Save to localStorage
localStorage.setItem('theme', theme);
}, [theme]);
// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
setResolvedTheme(systemTheme);
window.document.documentElement.classList.remove('light', 'dark');
window.document.documentElement.classList.add(systemTheme);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@@ -0,0 +1,125 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { useState } from 'react';
import { Download, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import {
exportAsCSS,
exportAsSCSS,
exportAsTailwind,
exportAsJSON,
exportAsJavaScript,
downloadAsFile,
type ExportColor,
} from '@/lib/pastel/utils/export';
interface ExportMenuProps {
colors: string[];
className?: string;
}
type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript';
export function ExportMenu({ colors, className }: ExportMenuProps) {
const [format, setFormat] = useState<ExportFormat>('css');
const [copied, setCopied] = useState(false);
const exportColors: ExportColor[] = colors.map((hex) => ({ hex }));
const getExportContent = (): string => {
switch (format) {
case 'css':
return exportAsCSS(exportColors);
case 'scss':
return exportAsSCSS(exportColors);
case 'tailwind':
return exportAsTailwind(exportColors);
case 'json':
return exportAsJSON(exportColors);
case 'javascript':
return exportAsJavaScript(exportColors);
}
};
const getFileExtension = (): string => {
switch (format) {
case 'css':
return 'css';
case 'scss':
return 'scss';
case 'tailwind':
return 'js';
case 'json':
return 'json';
case 'javascript':
return 'js';
}
};
const handleCopy = () => {
const content = getExportContent();
navigator.clipboard.writeText(content);
setCopied(true);
toast.success('Copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
};
const handleDownload = () => {
const content = getExportContent();
const extension = getFileExtension();
downloadAsFile(content, `palette.${extension}`, 'text/plain');
toast.success('Downloaded!');
};
if (colors.length === 0) {
return null;
}
return (
<div className={className}>
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium mb-2">Export Palette</h3>
<Select
value={format}
onChange={(e) => setFormat(e.target.value as ExportFormat)}
>
<option value="css">CSS Variables</option>
<option value="scss">SCSS Variables</option>
<option value="tailwind">Tailwind Config</option>
<option value="json">JSON</option>
<option value="javascript">JavaScript Array</option>
</Select>
</div>
<div className="p-4 bg-muted rounded-lg">
<pre className="text-xs overflow-x-auto">
<code>{getExportContent()}</code>
</pre>
</div>
<div className="flex gap-2">
<Button onClick={handleCopy} variant="outline" className="flex-1">
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy
</>
)}
</Button>
<Button onClick={handleDownload} variant="default" className="flex-1">
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
'use client';
import { useState } from 'react';
import { Slider } from '@/components/ui/Slider';
import { Button } from '@/components/ui/Button';
import {
useLighten,
useDarken,
useSaturate,
useDesaturate,
useRotate,
useComplement
} from '@/lib/pastel/api/queries';
import { toast } from 'sonner';
interface ManipulationPanelProps {
color: string;
onColorChange: (color: string) => void;
}
export function ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) {
const [lightenAmount, setLightenAmount] = useState(0.2);
const [darkenAmount, setDarkenAmount] = useState(0.2);
const [saturateAmount, setSaturateAmount] = useState(0.2);
const [desaturateAmount, setDesaturateAmount] = useState(0.2);
const [rotateAmount, setRotateAmount] = useState(30);
const lightenMutation = useLighten();
const darkenMutation = useDarken();
const saturateMutation = useSaturate();
const desaturateMutation = useDesaturate();
const rotateMutation = useRotate();
const complementMutation = useComplement();
const handleLighten = async () => {
try {
const result = await lightenMutation.mutateAsync({
colors: [color],
amount: lightenAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(`Lightened by ${(lightenAmount * 100).toFixed(0)}%`);
}
} catch (error) {
toast.error('Failed to lighten color');
}
};
const handleDarken = async () => {
try {
const result = await darkenMutation.mutateAsync({
colors: [color],
amount: darkenAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(`Darkened by ${(darkenAmount * 100).toFixed(0)}%`);
}
} catch (error) {
toast.error('Failed to darken color');
}
};
const handleSaturate = async () => {
try {
const result = await saturateMutation.mutateAsync({
colors: [color],
amount: saturateAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(`Saturated by ${(saturateAmount * 100).toFixed(0)}%`);
}
} catch (error) {
toast.error('Failed to saturate color');
}
};
const handleDesaturate = async () => {
try {
const result = await desaturateMutation.mutateAsync({
colors: [color],
amount: desaturateAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(`Desaturated by ${(desaturateAmount * 100).toFixed(0)}%`);
}
} catch (error) {
toast.error('Failed to desaturate color');
}
};
const handleRotate = async () => {
try {
const result = await rotateMutation.mutateAsync({
colors: [color],
amount: rotateAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(`Rotated hue by ${rotateAmount}°`);
}
} catch (error) {
toast.error('Failed to rotate hue');
}
};
const handleComplement = async () => {
try {
const result = await complementMutation.mutateAsync([color]);
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success('Generated complementary color');
}
} catch (error) {
toast.error('Failed to generate complement');
}
};
const isLoading =
lightenMutation.isPending ||
darkenMutation.isPending ||
saturateMutation.isPending ||
desaturateMutation.isPending ||
rotateMutation.isPending ||
complementMutation.isPending;
return (
<div className="space-y-6">
{/* Lighten */}
<div className="space-y-3">
<Slider
label="Lighten"
min={0}
max={1}
step={0.05}
value={lightenAmount}
onChange={(e) => setLightenAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
<Button onClick={handleLighten} disabled={isLoading} className="w-full">
Apply Lighten
</Button>
</div>
{/* Darken */}
<div className="space-y-3">
<Slider
label="Darken"
min={0}
max={1}
step={0.05}
value={darkenAmount}
onChange={(e) => setDarkenAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
<Button onClick={handleDarken} disabled={isLoading} className="w-full">
Apply Darken
</Button>
</div>
{/* Saturate */}
<div className="space-y-3">
<Slider
label="Saturate"
min={0}
max={1}
step={0.05}
value={saturateAmount}
onChange={(e) => setSaturateAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
<Button onClick={handleSaturate} disabled={isLoading} className="w-full">
Apply Saturate
</Button>
</div>
{/* Desaturate */}
<div className="space-y-3">
<Slider
label="Desaturate"
min={0}
max={1}
step={0.05}
value={desaturateAmount}
onChange={(e) => setDesaturateAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
<Button onClick={handleDesaturate} disabled={isLoading} className="w-full">
Apply Desaturate
</Button>
</div>
{/* Rotate Hue */}
<div className="space-y-3">
<Slider
label="Rotate Hue"
min={-180}
max={180}
step={5}
value={rotateAmount}
onChange={(e) => setRotateAmount(parseInt(e.target.value))}
suffix="°"
showValue
/>
<Button onClick={handleRotate} disabled={isLoading} className="w-full">
Apply Rotation
</Button>
</div>
{/* Quick Actions */}
<div className="pt-4 border-t space-y-2">
<h3 className="text-sm font-medium mb-3">Quick Actions</h3>
<Button
onClick={handleComplement}
disabled={isLoading}
variant="outline"
className="w-full"
>
Get Complementary Color
</Button>
</div>
</div>
);
}