From 0727ec76753727fdf36483652c1a4563d3de1039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 1 Mar 2026 08:15:33 +0100 Subject: [PATCH] refactor: refactor color tool to match calculate blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/color/ColorInfo.tsx | 96 ++--- components/color/ColorManipulation.tsx | 574 ++++++++++++------------- components/color/ColorPicker.tsx | 54 +-- components/color/ColorSwatch.tsx | 69 ++- components/color/ExportMenu.tsx | 188 ++++---- components/color/ManipulationPanel.tsx | 159 +++---- components/color/PaletteGrid.tsx | 8 +- 7 files changed, 496 insertions(+), 652 deletions(-) diff --git a/components/color/ColorInfo.tsx b/components/color/ColorInfo.tsx index 3a26585..6bcf572 100644 --- a/components/color/ColorInfo.tsx +++ b/components/color/ColorInfo.tsx @@ -1,7 +1,6 @@ 'use client'; import { ColorInfo as ColorInfoType } from '@/lib/color/api/types'; -import { Button } from '@/components/ui/button'; import { Copy } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils/cn'; @@ -12,79 +11,70 @@ interface ColorInfoProps { } export function ColorInfo({ info, className }: ColorInfoProps) { - const copyToClipboard = (value: string, label: string) => { + const copy = (value: string, label: string) => { navigator.clipboard.writeText(value); - toast.success(`Copied ${label} to clipboard`); + toast.success(`Copied ${label}`); }; - 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 formatRgb = (rgb: { r: number; g: number; b: number; a?: number }) => + rgb.a !== undefined && rgb.a < 1 + ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})` + : `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 formatOkLab = (oklab: { l: number; a: number; b: number }) => { - return `oklab(${(oklab.l * 100).toFixed(1)}% ${oklab.a.toFixed(3)} ${oklab.b.toFixed(3)})`; - }; + const formatHsl = (hsl: { h: number; s: number; l: number; a?: number }) => + hsl.a !== undefined && hsl.a < 1 + ? `hsla(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%, ${hsl.a})` + : `hsl(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%)`; const formats = [ - { label: 'Hex', value: info.hex }, + { 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: formatOkLab(info.oklab) }, + { label: 'Lab', value: `lab(${info.lab.l.toFixed(1)} ${info.lab.a.toFixed(1)} ${info.lab.b.toFixed(1)})` }, + { label: 'OkLab', value: `oklab(${(info.oklab.l * 100).toFixed(1)}% ${info.oklab.a.toFixed(3)} ${info.oklab.b.toFixed(3)})` }, ]; return (
-
- {formats.map((format) => ( + {/* Format rows */} +
+ {formats.map((fmt) => (
- {format.label} - {format.value} + + {fmt.label} + + {fmt.value}
- + +
))}
-
-
-
Brightness
-
{(info.brightness * 100).toFixed(1)}%
-
-
-
Luminance
-
{(info.luminance * 100).toFixed(1)}%
-
-
-
{info.name && typeof info.name === 'string' ? 'Name' : 'Type'}
-
{info.name && typeof info.name === 'string' ? info.name : (info.is_light ? 'Light' : 'Dark')}
-
+ {/* Metadata row */} +
+ {[ + { label: 'Brightness', value: `${(info.brightness * 100).toFixed(1)}%` }, + { label: 'Luminance', value: `${(info.luminance * 100).toFixed(1)}%` }, + { + label: info.name && typeof info.name === 'string' ? 'Name' : 'Type', + value: info.name && typeof info.name === 'string' ? info.name : (info.is_light ? 'Light' : 'Dark'), + }, + ].map((m) => ( +
+
{m.label}
+
{m.value}
+
+ ))}
); diff --git a/components/color/ColorManipulation.tsx b/components/color/ColorManipulation.tsx index 88adf23..5e611d4 100644 --- a/components/color/ColorManipulation.tsx +++ b/components/color/ColorManipulation.tsx @@ -7,27 +7,32 @@ import { ColorInfo } from '@/components/color/ColorInfo'; import { ManipulationPanel } from '@/components/color/ManipulationPanel'; import { PaletteGrid } from '@/components/color/PaletteGrid'; import { ExportMenu } from '@/components/color/ExportMenu'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries'; -import { Loader2, Share2, Palette, Plus, X, Layers } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import { Loader2, Share2, Plus, X, Palette, Layers } from 'lucide-react'; import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; -type HarmonyType = - | 'monochromatic' - | 'analogous' - | 'complementary' - | 'triadic' - | 'tetradic'; +type HarmonyType = 'monochromatic' | 'analogous' | 'complementary' | 'triadic' | 'tetradic'; +type RightTab = 'info' | 'adjust' | 'harmony' | 'gradient'; +type MobileTab = 'pick' | 'explore'; + +const HARMONY_OPTS: { value: HarmonyType; label: string; desc: string }[] = [ + { value: 'monochromatic', label: 'Mono', desc: 'Single hue, varied lightness' }, + { value: 'analogous', label: 'Analogous', desc: 'Adjacent colors ±30°' }, + { value: 'complementary', label: 'Complement', desc: 'Opposite on wheel 180°' }, + { value: 'triadic', label: 'Triadic', desc: 'Three equal 120° steps' }, + { value: 'tetradic', label: 'Tetradic', desc: 'Four equal 90° steps' }, +]; + +const RIGHT_TABS: { value: RightTab; label: string }[] = [ + { value: 'info', label: 'Info' }, + { value: 'adjust', label: 'Adjust' }, + { value: 'harmony', label: 'Harmony' }, + { value: 'gradient', label: 'Gradient' }, +]; + +const actionBtn = + 'flex items-center gap-1 px-2.5 py-1 text-xs 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'; function ColorManipulationContent() { const searchParams = useSearchParams(); @@ -37,24 +42,23 @@ function ColorManipulationContent() { return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099'; }); - // Harmony state + const [rightTab, setRightTab] = useState('info'); + const [mobileTab, setMobileTab] = useState('pick'); + + // Harmony const [harmonyType, setHarmonyType] = useState('complementary'); const [palette, setPalette] = useState([]); const paletteMutation = useGeneratePalette(); - // Gradient state + // Gradient const [stops, setStops] = useState(['#ff0099', '#0099ff']); const [gradientCount, setGradientCount] = useState(10); const [gradientResult, setGradientResult] = useState([]); const gradientMutation = useGenerateGradient(); - const { data, isLoading, isError, error } = useColorInfo({ - colors: [color], - }); - + const { data, isLoading } = useColorInfo({ colors: [color] }); const colorInfo = data?.colors[0]; - // Update URL when color changes useEffect(() => { const hex = color.replace('#', ''); if (hex.length === 6 || hex.length === 3) { @@ -64,301 +68,289 @@ function ColorManipulationContent() { // Sync first gradient stop with active color useEffect(() => { - const newStops = [...stops]; - newStops[0] = color; - setStops(newStops); + setStops((prev) => [color, ...prev.slice(1)]); }, [color]); const handleShare = () => { - const url = `${window.location.origin}/color?color=${color.replace('#', '')}`; - navigator.clipboard.writeText(url); - toast.success('Link copied to clipboard!'); + navigator.clipboard.writeText(`${window.location.origin}/color?color=${color.replace('#', '')}`); + toast.success('Link copied!'); }; const generateHarmony = async () => { try { - const result = await paletteMutation.mutateAsync({ - base: color, - scheme: harmonyType, - }); - - const colors = [result.palette.primary, ...result.palette.secondary]; - setPalette(colors); - toast.success(`Generated ${harmonyType} harmony palette`); - } catch (error) { - toast.error('Failed to generate harmony palette'); - console.error(error); - } + const result = await paletteMutation.mutateAsync({ base: color, scheme: harmonyType }); + setPalette([result.palette.primary, ...result.palette.secondary]); + toast.success(`Generated ${harmonyType} palette`); + } catch { toast.error('Failed to generate palette'); } }; const generateGradient = async () => { try { - const result = await gradientMutation.mutateAsync({ - stops, - count: gradientCount, - }); + const result = await gradientMutation.mutateAsync({ stops, count: gradientCount }); setGradientResult(result.gradient); toast.success(`Generated ${result.gradient.length} colors`); - } catch (error) { - toast.error('Failed to generate gradient'); - } + } catch { toast.error('Failed to generate gradient'); } }; - const addStop = () => { - setStops([...stops, '#000000']); - }; - - const removeStop = (index: number) => { - if (index === 0) return; - if (stops.length > 2) { - setStops(stops.filter((_, i) => i !== index)); - } - }; - - const updateStop = (index: number, colorValue: string) => { - const newStops = [...stops]; - newStops[index] = colorValue; - setStops(newStops); - if (index === 0) setColor(colorValue); - }; - - const harmonyDescriptions: Record = { - monochromatic: 'Single color with variations', - analogous: 'Colors adjacent on the color wheel (±30°)', - complementary: 'Colors opposite on the color wheel (180°)', - triadic: 'Three colors evenly spaced on the color wheel (120°)', - tetradic: 'Four colors evenly spaced on the color wheel (90°)', + const updateStop = (i: number, v: string) => { + const next = [...stops]; + next[i] = v; + setStops(next); + if (i === 0) setColor(v); }; return ( -
- {/* Row 1: Workspace */} -
- {/* Main Workspace: Color Picker and Information */} -
- - - Color Picker - - - -
-
- -
+
-
- {isLoading && ( -
- -
- )} - - {isError && ( -
-

Error loading color information

-

{error?.message || 'Unknown error'}

-
- )} - - {colorInfo && } -
-
- - -
- - {/* Sidebar: Color Manipulation */} -
- - - Adjustments - - - - - -
+ {/* ── Mobile tab switcher ────────────────────────────────── */} +
+ {(['pick', 'explore'] as MobileTab[]).map((t) => ( + + ))}
- {/* Row 2: Harmony Generator */} -
- {/* Harmony Controls */} -
- - - Harmony - - - + {/* ── Main layout ────────────────────────────────────────── */} +
-

- {harmonyDescriptions[harmonyType]} -

+ {/* Left panel: Picker + ColorInfo */} +
+ {/* Color picker card */} +
+
+ + Color + + +
+ +
- - - -
- - {/* Harmony Results */} -
- - - - Palette {palette.length > 0 && ({palette.length})} - - - - {palette.length > 0 ? ( -
- -
- -
+ {/* Color info card */} +
+ + Info + +
+ {isLoading ? ( +
+
- ) : ( -
- -

Generate a harmony palette from the current color

-
- )} - - -
-
- - {/* Row 3: Gradient Generator */} -
- {/* Gradient Controls */} -
- - - Gradient - - -
- - {stops.map((stop, index) => ( -
- updateStop(index, e.target.value)} - className="w-9 h-9 p-1 shrink-0 cursor-pointer" - /> - updateStop(index, e.target.value)} - className="font-mono text-xs flex-1" - /> - {index !== 0 && stops.length > 2 && ( - - )} -
- ))} - -
- -
- - setGradientCount(parseInt(e.target.value))} - /> -
- - -
-
+ ) : colorInfo ? ( + + ) : null} +
+
- {/* Gradient Results */} -
- - - - Gradient {gradientResult.length > 0 && ({gradientResult.length})} - - - - {gradientResult.length > 0 ? ( -
+ {/* Right panel: tabbed tools */} +
+
+ + {/* Tab switcher */} +
+ {RIGHT_TABS.map(({ value, label }) => ( + + ))} +
+ + {/* Tab content */} +
+ + {/* ── Info tab ─────────────────────────────── */} + {rightTab === 'info' && ( +
+ {/* Large color preview */}
- -
- -
-
- ) : ( -
- -

Add color stops and generate a smooth gradient

+ {isLoading ? ( +
+ +
+ ) : colorInfo ? ( + + ) : null}
)} - - + + {/* ── Adjust tab ───────────────────────────── */} + {rightTab === 'adjust' && ( + + )} + + {/* ── Harmony tab ──────────────────────────── */} + {rightTab === 'harmony' && ( +
+ {/* Scheme selector */} +
+ + Scheme + +
+ {HARMONY_OPTS.map((opt) => ( + + ))} +
+

+ {HARMONY_OPTS.find((o) => o.value === harmonyType)?.desc} +

+
+ + + + {palette.length > 0 && ( +
+ +
+ +
+
+ )} +
+ )} + + {/* ── Gradient tab ─────────────────────────── */} + {rightTab === 'gradient' && ( +
+ {/* Color stops */} +
+ + Stops + + {stops.map((stop, i) => ( +
+ updateStop(i, e.target.value)} + className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5" + /> + updateStop(i, e.target.value)} + className="flex-1 bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors" + /> + {i !== 0 && stops.length > 2 && ( + + )} +
+ ))} + +
+ + {/* Steps */} +
+ + Steps + + setGradientCount(parseInt(e.target.value))} + className="w-20 bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono text-center outline-none focus:border-primary/50 transition-colors" + /> +
+ + + + {gradientResult.length > 0 && ( +
+ {/* Gradient preview bar */} +
+ +
+ +
+
+ )} +
+ )} + +
+
@@ -369,7 +361,7 @@ export function ColorManipulation() { return ( - +
}> diff --git a/components/color/ColorPicker.tsx b/components/color/ColorPicker.tsx index a0147df..509d4bb 100644 --- a/components/color/ColorPicker.tsx +++ b/components/color/ColorPicker.tsx @@ -1,8 +1,6 @@ 'use client'; import { HexColorPicker } from 'react-colorful'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils/cn'; import { hexToRgb } from '@/lib/color/utils/color'; @@ -13,45 +11,23 @@ interface ColorPickerProps { } export function ColorPicker({ color, onChange, className }: ColorPickerProps) { - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - // Allow partial input while typing - onChange(value); - }; - - // Determine text color based on background brightness - const getContrastColor = (hex: string) => { - const rgb = hexToRgb(hex); - if (!rgb) return 'inherit'; - const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000; - return brightness > 128 ? '#000000' : '#ffffff'; - }; - - const textColor = getContrastColor(color); + const rgb = hexToRgb(color); + const brightness = rgb ? (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000 : 0; + const textColor = brightness > 128 ? '#000000' : '#ffffff'; + const borderColor = brightness > 128 ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.2)'; return ( -
-
- -
- - -
-
+
+ + onChange(e.target.value)} + placeholder="#ff0099" + className="w-full font-mono text-xs rounded-lg px-3 py-2 outline-none transition-colors duration-200 border" + style={{ backgroundColor: color, color: textColor, borderColor }} + spellCheck={false} + />
); } diff --git a/components/color/ColorSwatch.tsx b/components/color/ColorSwatch.tsx index 32083ea..4433818 100644 --- a/components/color/ColorSwatch.tsx +++ b/components/color/ColorSwatch.tsx @@ -13,54 +13,43 @@ interface ColorSwatchProps { className?: string; } -export function ColorSwatch({ - color, - size = 'md', - showLabel = true, - onClick, - className, -}: ColorSwatchProps) { +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(); + const handleClick = () => { + if (onClick) { onClick(); return; } navigator.clipboard.writeText(color); setCopied(true); toast.success(`Copied ${color}`); - setTimeout(() => setCopied(false), 2000); + setTimeout(() => setCopied(false), 1500); }; return ( -
- - {showLabel && ( - {color} + ); } diff --git a/components/color/ExportMenu.tsx b/components/color/ExportMenu.tsx index 9f22c48..7da319f 100644 --- a/components/color/ExportMenu.tsx +++ b/components/color/ExportMenu.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Button } from '@/components/ui/button'; +import { useState, useEffect } from 'react'; import { Select, SelectContent, @@ -8,7 +8,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useState, useEffect } from 'react'; import { Download, Copy, Check, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { @@ -21,6 +20,7 @@ import { type ExportColor, } from '@/lib/color/utils/export'; import { colorAPI } from '@/lib/color/api/client'; +import { cn } from '@/lib/utils/cn'; interface ExportMenuProps { colors: string[]; @@ -30,6 +30,9 @@ interface ExportMenuProps { type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript'; type ColorSpace = 'hex' | 'rgb' | 'hsl' | 'lab' | 'oklab' | 'lch' | 'oklch'; +const actionBtn = + 'flex items-center gap-1.5 px-3 py-1.5 text-xs 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 ExportMenu({ colors, className }: ExportMenuProps) { const [format, setFormat] = useState('css'); const [colorSpace, setColorSpace] = useState('hex'); @@ -39,152 +42,105 @@ export function ExportMenu({ colors, className }: ExportMenuProps) { useEffect(() => { async function convertColors() { - if (colorSpace === 'hex') { - setConvertedColors(colors); - return; - } - + if (colorSpace === 'hex') { setConvertedColors(colors); return; } setIsConverting(true); try { - const response = await colorAPI.convertFormat({ - colors, - format: colorSpace, - }); - + const response = await colorAPI.convertFormat({ colors, format: colorSpace }); if (response.success) { - setConvertedColors(response.data.conversions.map(c => c.output)); + setConvertedColors(response.data.conversions.map((c) => c.output)); } - } catch (error) { - console.error('Failed to convert colors:', error); - toast.error('Failed to convert colors to selected space'); + } catch { + toast.error('Failed to convert colors'); } finally { setIsConverting(false); } } - convertColors(); }, [colors, colorSpace]); const exportColors: ExportColor[] = convertedColors.map((value) => ({ value })); - const getExportContent = (): string => { + const getContent = (): 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); + 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 getExt = () => ({ css: 'css', scss: 'scss', tailwind: 'js', json: 'json', javascript: 'js' }[format]); const handleCopy = () => { - const content = getExportContent(); - navigator.clipboard.writeText(content); + navigator.clipboard.writeText(getContent()); setCopied(true); - toast.success('Copied to clipboard!'); + toast.success('Copied!'); setTimeout(() => setCopied(false), 2000); }; const handleDownload = () => { - const content = getExportContent(); - const extension = getFileExtension(); - downloadAsFile(content, `palette.${extension}`, 'text/plain'); + downloadAsFile(getContent(), `palette.${getExt()}`, 'text/plain'); toast.success('Downloaded!'); }; - if (colors.length === 0) { - return null; - } + if (colors.length === 0) return null; return ( -
-
-
- +
+ Export - -
+ {/* Selectors */} +
+ + +
-
- {isConverting ? ( -
- -
- ) : null} -
-            {getExportContent()}
-          
-
+ {/* Code preview */} +
+ {isConverting && ( +
+ +
+ )} +
+          {getContent()}
+        
+
-
- - -
+ {/* Actions */} +
+ +
); diff --git a/components/color/ManipulationPanel.tsx b/components/color/ManipulationPanel.tsx index bc443cf..a8f531a 100644 --- a/components/color/ManipulationPanel.tsx +++ b/components/color/ManipulationPanel.tsx @@ -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; -} +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, + 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: , - value: lightenAmount, - setValue: setLightenAmount, - format: (v) => `${(v * 100).toFixed(0)}%`, + label: 'Lighten', icon: , + 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: , - value: darkenAmount, - setValue: setDarkenAmount, - format: (v) => `${(v * 100).toFixed(0)}%`, + label: 'Darken', icon: , + 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: , - value: saturateAmount, - setValue: setSaturateAmount, - format: (v) => `${(v * 100).toFixed(0)}%`, + label: 'Saturate', icon: , + 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: , - value: desaturateAmount, - setValue: setDesaturateAmount, - format: (v) => `${(v * 100).toFixed(0)}%`, + label: 'Desaturate', icon: , + 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: , - value: rotateAmount, - setValue: setRotateAmount, - format: (v) => `${v}°`, + label: 'Rotate Hue', icon: , + 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 (
{rows.map((row) => ( -
+
-
+
{row.icon} {row.label}
- {row.format(row.value)} + {row.display}
row.setValue(vals[0])} className="flex-1" /> - +
))} -
- +
); diff --git a/components/color/PaletteGrid.tsx b/components/color/PaletteGrid.tsx index 50e68c7..ecdf54f 100644 --- a/components/color/PaletteGrid.tsx +++ b/components/color/PaletteGrid.tsx @@ -19,16 +19,12 @@ export function PaletteGrid({ colors, onColorClick, className }: PaletteGridProp } return ( -
+
{colors.map((color, index) => ( onColorClick(color) : undefined} /> ))}