Files
kit-ui/components/color/ManipulationPanel.tsx
Sebastian Krüger 0727ec7675 refactor: refactor color tool to match calculate blueprint
Rewrites all color components to use the glass panel design language,
fixed-height two-panel layout, and tab-based navigation.

- ColorManipulation: lg:grid-cols-5 split — left 2/5 shows ColorPicker
  + ColorInfo always; right 3/5 has Info/Adjust/Harmony/Gradient tabs;
  mobile 'Pick | Explore' switcher
- ColorPicker: removes shadcn Input/Label, native input with dynamic
  contrast color matching the picked hue
- ColorInfo: removes shadcn Button, native copy buttons on hover,
  metadata chips with bg-primary/5 background
- ManipulationPanel: keeps Slider, replaces Button with glass action
  buttons, tighter spacing and muted labels
- ExportMenu: keeps Select, replaces Buttons with glass action buttons,
  code preview in dark terminal box (#06060e)
- ColorSwatch: rectangular full-width design for palette grids,
  hover reveals copy icon, hex label at bottom
- PaletteGrid: denser grid (4→5 cols), smaller swatch height

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:15:33 +01:00

149 lines
5.5 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Slider } from '@/components/ui/slider';
import {
useLighten,
useDarken,
useSaturate,
useDesaturate,
useRotate,
useComplement,
} from '@/lib/color/api/queries';
import { toast } from 'sonner';
import { Sun, Moon, Droplets, Droplet, RotateCcw, ArrowLeftRight } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
interface ManipulationPanelProps {
color: string;
onColorChange: (color: string) => void;
}
const actionBtn =
'shrink-0 px-3 py-1 text-[10px] font-mono glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
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 isLoading =
lightenMutation.isPending ||
darkenMutation.isPending ||
saturateMutation.isPending ||
desaturateMutation.isPending ||
rotateMutation.isPending ||
complementMutation.isPending;
const applyMutation = async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mutationFn: (p: any) => Promise<{ colors: { output: string }[] }>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params: any,
msg: string
) => {
try {
const result = await mutationFn(params);
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(msg);
}
} catch {
toast.error('Failed to apply');
}
};
const rows = [
{
label: 'Lighten', icon: <Sun className="w-3 h-3" />,
value: lightenAmount, setValue: setLightenAmount,
display: `${(lightenAmount * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => applyMutation(lightenMutation.mutateAsync, { colors: [color], amount: lightenAmount }, `Lightened ${(lightenAmount * 100).toFixed(0)}%`),
},
{
label: 'Darken', icon: <Moon className="w-3 h-3" />,
value: darkenAmount, setValue: setDarkenAmount,
display: `${(darkenAmount * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => applyMutation(darkenMutation.mutateAsync, { colors: [color], amount: darkenAmount }, `Darkened ${(darkenAmount * 100).toFixed(0)}%`),
},
{
label: 'Saturate', icon: <Droplets className="w-3 h-3" />,
value: saturateAmount, setValue: setSaturateAmount,
display: `${(saturateAmount * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => applyMutation(saturateMutation.mutateAsync, { colors: [color], amount: saturateAmount }, `Saturated ${(saturateAmount * 100).toFixed(0)}%`),
},
{
label: 'Desaturate', icon: <Droplet className="w-3 h-3" />,
value: desaturateAmount, setValue: setDesaturateAmount,
display: `${(desaturateAmount * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => applyMutation(desaturateMutation.mutateAsync, { colors: [color], amount: desaturateAmount }, `Desaturated ${(desaturateAmount * 100).toFixed(0)}%`),
},
{
label: 'Rotate Hue', icon: <RotateCcw className="w-3 h-3" />,
value: rotateAmount, setValue: setRotateAmount,
display: `${rotateAmount}°`,
min: -180, max: 180, step: 5,
onApply: () => applyMutation(rotateMutation.mutateAsync, { colors: [color], amount: rotateAmount }, `Rotated ${rotateAmount}°`),
},
];
return (
<div className="space-y-4">
{rows.map((row) => (
<div key={row.label} className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{row.icon}
<span>{row.label}</span>
</div>
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{row.display}</span>
</div>
<div className="flex items-center gap-2">
<Slider
min={row.min} max={row.max} step={row.step}
value={[row.value]}
onValueChange={(vals) => row.setValue(vals[0])}
className="flex-1"
/>
<button onClick={row.onApply} disabled={isLoading} className={actionBtn}>
Apply
</button>
</div>
</div>
))}
<div className="pt-3 border-t border-border/25">
<button
onClick={async () => {
try {
const result = await complementMutation.mutateAsync([color]);
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success('Complementary color applied');
}
} catch { toast.error('Failed'); }
}}
disabled={isLoading}
className={cn(actionBtn, 'w-full justify-center flex items-center gap-1.5 py-2')}
>
<ArrowLeftRight className="w-3 h-3" />
Complementary Color
</button>
</div>
</div>
);
}