feat: implement color manipulation tools with interactive sliders
Add complete color manipulation interface to playground: **New Components:** - Slider component - Customizable range input with label and value display - Smooth animations on thumb hover - Keyboard accessible - Shows current value with optional suffix - Tailwind-styled for consistency - ManipulationPanel component - Full color manipulation controls - Lighten/Darken sliders (0-100% range) - Saturate/Desaturate sliders (0-100% range) - Hue rotation slider (-180° to +180°) - Complementary color quick action - All operations integrated with Pastel API - Toast notifications for success/error - Loading states during API calls **Playground Enhancements:** - Replaced placeholder quick action buttons with ManipulationPanel - Full integration with color manipulation mutations - Real-time color updates after each operation - User-friendly feedback with toast messages - Amount controls for precise adjustments **Features:** - Live preview of manipulation results - Configurable amounts before applying - Success/error handling with helpful messages - Disabled state during API operations - Smooth user experience with immediate feedback **API Integration:** - useLighten, useDarken mutations - useSaturate, useDesaturate mutations - useRotate for hue rotation - useComplement for complementary colors - All mutations with proper error handling Build successful! Color manipulation tools fully functional. Next: Navigation, theme system, and additional UI components. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { ColorPicker } from '@/components/color/ColorPicker';
|
||||
import { ColorDisplay } from '@/components/color/ColorDisplay';
|
||||
import { ColorInfo } from '@/components/color/ColorInfo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ManipulationPanel } from '@/components/tools/ManipulationPanel';
|
||||
import { useColorInfo } from '@/lib/api/queries';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
@@ -65,27 +65,8 @@ export default function PlaygroundPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-6 border rounded-lg bg-card">
|
||||
<h2 className="text-xl font-semibold mb-4">Quick Actions</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="outline" className="w-full">
|
||||
Lighten
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
Darken
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
Saturate
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
Desaturate
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
Complement
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
Grayscale
|
||||
</Button>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-4">Color Manipulation</h2>
|
||||
<ManipulationPanel color={color} onColorChange={setColor} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
231
components/tools/ManipulationPanel.tsx
Normal file
231
components/tools/ManipulationPanel.tsx
Normal 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/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.results[0]) {
|
||||
onColorChange(result.results[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.results[0]) {
|
||||
onColorChange(result.results[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.results[0]) {
|
||||
onColorChange(result.results[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.results[0]) {
|
||||
onColorChange(result.results[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.results[0]) {
|
||||
onColorChange(result.results[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.results[0]) {
|
||||
onColorChange(result.results[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>
|
||||
);
|
||||
}
|
||||
65
components/ui/slider.tsx
Normal file
65
components/ui/slider.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string;
|
||||
showValue?: boolean;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
({ className, label, showValue = true, suffix = '', ...props }, ref) => {
|
||||
const [value, setValue] = React.useState(props.value || props.defaultValue || 0);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
props.onChange?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{(label || showValue) && (
|
||||
<div className="flex items-center justify-between">
|
||||
{label && (
|
||||
<label htmlFor={props.id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{showValue && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="range"
|
||||
className={cn(
|
||||
'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4',
|
||||
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary',
|
||||
'[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-all',
|
||||
'[&::-webkit-slider-thumb]:hover:scale-110',
|
||||
'[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full',
|
||||
'[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0',
|
||||
'[&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:transition-all',
|
||||
'[&::-moz-range-thumb]:hover:scale-110',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Slider.displayName = 'Slider';
|
||||
|
||||
export { Slider };
|
||||
Reference in New Issue
Block a user