Files
pastel-ui/components/tools/ManipulationPanel.tsx
Sebastian Krüger 072851a762 feat: make color manipulation sliders reactive with auto-apply
Remove redundant "Apply" buttons and make all manipulation sliders (lighten, darken, saturate, desaturate, rotate) update the color in real-time with 300ms debouncing. This provides a smoother, more intuitive user experience.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 22:02:57 +01:00

249 lines
6.6 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } 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();
// Debounce timers
const lightenTimer = useRef<NodeJS.Timeout>();
const darkenTimer = useRef<NodeJS.Timeout>();
const saturateTimer = useRef<NodeJS.Timeout>();
const desaturateTimer = useRef<NodeJS.Timeout>();
const rotateTimer = useRef<NodeJS.Timeout>();
// Reactive lighten
useEffect(() => {
if (lightenTimer.current) clearTimeout(lightenTimer.current);
lightenTimer.current = setTimeout(async () => {
if (lightenAmount > 0) {
try {
const result = await lightenMutation.mutateAsync({
colors: [color],
amount: lightenAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
}
} catch (error) {
// Silent error - user is still adjusting
}
}
}, 300);
}, [lightenAmount]);
// Reactive darken
useEffect(() => {
if (darkenTimer.current) clearTimeout(darkenTimer.current);
darkenTimer.current = setTimeout(async () => {
if (darkenAmount > 0) {
try {
const result = await darkenMutation.mutateAsync({
colors: [color],
amount: darkenAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
}
} catch (error) {
// Silent error - user is still adjusting
}
}
}, 300);
}, [darkenAmount]);
// Reactive saturate
useEffect(() => {
if (saturateTimer.current) clearTimeout(saturateTimer.current);
saturateTimer.current = setTimeout(async () => {
if (saturateAmount > 0) {
try {
const result = await saturateMutation.mutateAsync({
colors: [color],
amount: saturateAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
}
} catch (error) {
// Silent error - user is still adjusting
}
}
}, 300);
}, [saturateAmount]);
// Reactive desaturate
useEffect(() => {
if (desaturateTimer.current) clearTimeout(desaturateTimer.current);
desaturateTimer.current = setTimeout(async () => {
if (desaturateAmount > 0) {
try {
const result = await desaturateMutation.mutateAsync({
colors: [color],
amount: desaturateAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
}
} catch (error) {
// Silent error - user is still adjusting
}
}
}, 300);
}, [desaturateAmount]);
// Reactive rotate
useEffect(() => {
if (rotateTimer.current) clearTimeout(rotateTimer.current);
rotateTimer.current = setTimeout(async () => {
if (rotateAmount !== 0) {
try {
const result = await rotateMutation.mutateAsync({
colors: [color],
amount: rotateAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
}
} catch (error) {
// Silent error - user is still adjusting
}
}
}, 300);
}, [rotateAmount]);
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>
<Slider
label="Lighten"
min={0}
max={1}
step={0.05}
value={lightenAmount}
onChange={(e) => setLightenAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
</div>
{/* Darken */}
<div>
<Slider
label="Darken"
min={0}
max={1}
step={0.05}
value={darkenAmount}
onChange={(e) => setDarkenAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
</div>
{/* Saturate */}
<div>
<Slider
label="Saturate"
min={0}
max={1}
step={0.05}
value={saturateAmount}
onChange={(e) => setSaturateAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
</div>
{/* Desaturate */}
<div>
<Slider
label="Desaturate"
min={0}
max={1}
step={0.05}
value={desaturateAmount}
onChange={(e) => setDesaturateAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
</div>
{/* Rotate Hue */}
<div>
<Slider
label="Rotate Hue"
min={-180}
max={180}
step={5}
value={rotateAmount}
onChange={(e) => setRotateAmount(parseInt(e.target.value))}
suffix="°"
showValue
/>
</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>
);
}