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>
This commit is contained in:
@@ -2,34 +2,25 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
useLighten,
|
||||
useDarken,
|
||||
useSaturate,
|
||||
useDesaturate,
|
||||
useRotate,
|
||||
useComplement
|
||||
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;
|
||||
}
|
||||
|
||||
interface ManipulationRow {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
value: number;
|
||||
setValue: (v: number) => void;
|
||||
format: (v: number) => string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
onApply: () => Promise<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);
|
||||
@@ -53,150 +44,104 @@ export function ManipulationPanel({ color, onColorChange }: ManipulationPanelPro
|
||||
rotateMutation.isPending ||
|
||||
complementMutation.isPending;
|
||||
|
||||
const handleMutation = async (
|
||||
mutationFn: (params: any) => Promise<any>,
|
||||
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,
|
||||
successMsg: string,
|
||||
errorMsg: string
|
||||
msg: string
|
||||
) => {
|
||||
try {
|
||||
const result = await mutationFn(params);
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(successMsg);
|
||||
toast.success(msg);
|
||||
}
|
||||
} catch {
|
||||
toast.error(errorMsg);
|
||||
toast.error('Failed to apply');
|
||||
}
|
||||
};
|
||||
|
||||
const rows: ManipulationRow[] = [
|
||||
const rows = [
|
||||
{
|
||||
label: 'Lighten',
|
||||
icon: <Sun className="h-3.5 w-3.5" />,
|
||||
value: lightenAmount,
|
||||
setValue: setLightenAmount,
|
||||
format: (v) => `${(v * 100).toFixed(0)}%`,
|
||||
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: () => handleMutation(
|
||||
lightenMutation.mutateAsync,
|
||||
{ colors: [color], amount: lightenAmount },
|
||||
`Lightened by ${(lightenAmount * 100).toFixed(0)}%`,
|
||||
'Failed to lighten color'
|
||||
),
|
||||
onApply: () => applyMutation(lightenMutation.mutateAsync, { colors: [color], amount: lightenAmount }, `Lightened ${(lightenAmount * 100).toFixed(0)}%`),
|
||||
},
|
||||
{
|
||||
label: 'Darken',
|
||||
icon: <Moon className="h-3.5 w-3.5" />,
|
||||
value: darkenAmount,
|
||||
setValue: setDarkenAmount,
|
||||
format: (v) => `${(v * 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: () => handleMutation(
|
||||
darkenMutation.mutateAsync,
|
||||
{ colors: [color], amount: darkenAmount },
|
||||
`Darkened by ${(darkenAmount * 100).toFixed(0)}%`,
|
||||
'Failed to darken color'
|
||||
),
|
||||
onApply: () => applyMutation(darkenMutation.mutateAsync, { colors: [color], amount: darkenAmount }, `Darkened ${(darkenAmount * 100).toFixed(0)}%`),
|
||||
},
|
||||
{
|
||||
label: 'Saturate',
|
||||
icon: <Droplets className="h-3.5 w-3.5" />,
|
||||
value: saturateAmount,
|
||||
setValue: setSaturateAmount,
|
||||
format: (v) => `${(v * 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: () => handleMutation(
|
||||
saturateMutation.mutateAsync,
|
||||
{ colors: [color], amount: saturateAmount },
|
||||
`Saturated by ${(saturateAmount * 100).toFixed(0)}%`,
|
||||
'Failed to saturate color'
|
||||
),
|
||||
onApply: () => applyMutation(saturateMutation.mutateAsync, { colors: [color], amount: saturateAmount }, `Saturated ${(saturateAmount * 100).toFixed(0)}%`),
|
||||
},
|
||||
{
|
||||
label: 'Desaturate',
|
||||
icon: <Droplet className="h-3.5 w-3.5" />,
|
||||
value: desaturateAmount,
|
||||
setValue: setDesaturateAmount,
|
||||
format: (v) => `${(v * 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: () => handleMutation(
|
||||
desaturateMutation.mutateAsync,
|
||||
{ colors: [color], amount: desaturateAmount },
|
||||
`Desaturated by ${(desaturateAmount * 100).toFixed(0)}%`,
|
||||
'Failed to desaturate color'
|
||||
),
|
||||
onApply: () => applyMutation(desaturateMutation.mutateAsync, { colors: [color], amount: desaturateAmount }, `Desaturated ${(desaturateAmount * 100).toFixed(0)}%`),
|
||||
},
|
||||
{
|
||||
label: 'Rotate',
|
||||
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||
value: rotateAmount,
|
||||
setValue: setRotateAmount,
|
||||
format: (v) => `${v}°`,
|
||||
label: 'Rotate Hue', icon: <RotateCcw className="w-3 h-3" />,
|
||||
value: rotateAmount, setValue: setRotateAmount,
|
||||
display: `${rotateAmount}°`,
|
||||
min: -180, max: 180, step: 5,
|
||||
onApply: () => handleMutation(
|
||||
rotateMutation.mutateAsync,
|
||||
{ colors: [color], amount: rotateAmount },
|
||||
`Rotated hue by ${rotateAmount}°`,
|
||||
'Failed to rotate hue'
|
||||
),
|
||||
onApply: () => applyMutation(rotateMutation.mutateAsync, { colors: [color], amount: rotateAmount }, `Rotated ${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 {
|
||||
toast.error('Failed to generate complement');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="space-y-2">
|
||||
<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-xs font-medium">
|
||||
<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 tabular-nums">{row.format(row.value)}</span>
|
||||
<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}
|
||||
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}
|
||||
variant="outline"
|
||||
className="shrink-0 w-16"
|
||||
>
|
||||
<button onClick={row.onApply} disabled={isLoading} className={actionBtn}>
|
||||
Apply
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-3 border-t">
|
||||
<Button
|
||||
onClick={handleComplement}
|
||||
<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}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
className={cn(actionBtn, 'w-full justify-center flex items-center gap-1.5 py-2')}
|
||||
>
|
||||
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" />
|
||||
<ArrowLeftRight className="w-3 h-3" />
|
||||
Complementary Color
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user