From 173d81d9d4bc898cc45c26eb09a0687d5eecdadd Mon Sep 17 00:00:00 2001 From: valknarness Date: Fri, 7 Nov 2025 11:05:23 +0100 Subject: [PATCH] feat: implement color playground with interactive picker and info display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete color manipulation interface: **Color Components:** - ColorPicker - Interactive hex color picker with text input - Uses react-colorful for visual selection - Manual input support for any color format - Real-time updates - ColorDisplay - Large color preview swatch - Configurable sizes (sm, md, lg, xl) - Optional border styling - Accessible with ARIA labels - ColorInfo - Comprehensive color information display - Shows all formats: Hex, RGB, HSL, Lab, OkLab - Copy to clipboard functionality for each format - Displays brightness, luminance, light/dark type - Shows named color matches **API Integration:** - React Query hooks for all Pastel API endpoints - useColorInfo - Get color information - useConvertFormat - Format conversion - useLighten, useDarken, useSaturate, etc. - Manipulations - useGenerateRandom, useGenerateDistinct, useGenerateGradient - useNamedColors - Cached named colors list - Automatic error handling and loading states **Playground Page (/playground):** - Two-column layout: picker + info - Live color preview with large swatch - Real-time API integration with loading states - Error handling with user-friendly messages - Quick action buttons (ready for implementation) - Responsive grid layout **Features:** - Toast notifications for clipboard copy - Loading spinners during API calls - Error display with helpful messages - Accessible keyboard navigation - Smooth transitions and animations Build successful with playground page rendering! Ready for color manipulation actions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/playground/page.tsx | 95 ++++++++++++++ components/color/ColorDisplay.tsx | 38 ++++++ components/color/ColorInfo.tsx | 92 +++++++++++++ components/color/ColorPicker.tsx | 38 ++++++ lib/api/queries.ts | 206 ++++++++++++++++++++++++++++++ 5 files changed, 469 insertions(+) create mode 100644 app/playground/page.tsx create mode 100644 components/color/ColorDisplay.tsx create mode 100644 components/color/ColorInfo.tsx create mode 100644 components/color/ColorPicker.tsx create mode 100644 lib/api/queries.ts diff --git a/app/playground/page.tsx b/app/playground/page.tsx new file mode 100644 index 0000000..4b33cb2 --- /dev/null +++ b/app/playground/page.tsx @@ -0,0 +1,95 @@ +'use client'; + +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 { useColorInfo } from '@/lib/api/queries'; +import { Loader2 } from 'lucide-react'; + +export default function PlaygroundPage() { + const [color, setColor] = useState('#ff0099'); + + const { data, isLoading, isError, error } = useColorInfo({ + colors: [color], + }); + + const colorInfo = data?.colors[0]; + + return ( +
+
+
+

Color Playground

+

+ Interactive color manipulation and analysis tool +

+
+ +
+ {/* Left Column: Color Picker and Display */} +
+
+

Color Picker

+ +
+ +
+

Preview

+
+ +
+
+
+ + {/* Right Column: Color Information */} +
+
+

Color Information

+ + {isLoading && ( +
+ +
+ )} + + {isError && ( +
+

Error loading color information

+

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

+
+ )} + + {colorInfo && } +
+ +
+

Quick Actions

+
+ + + + + + +
+
+
+
+
+
+ ); +} diff --git a/components/color/ColorDisplay.tsx b/components/color/ColorDisplay.tsx new file mode 100644 index 0000000..c7c3615 --- /dev/null +++ b/components/color/ColorDisplay.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { cn } from '@/lib/utils/cn'; + +interface ColorDisplayProps { + color: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; + className?: string; + showBorder?: boolean; +} + +export function ColorDisplay({ + color, + size = 'lg', + className, + showBorder = true, +}: ColorDisplayProps) { + const sizeClasses = { + sm: 'h-16 w-16', + md: 'h-32 w-32', + lg: 'h-48 w-48', + xl: 'h-64 w-64', + }; + + return ( +
+ ); +} diff --git a/components/color/ColorInfo.tsx b/components/color/ColorInfo.tsx new file mode 100644 index 0000000..08ea95a --- /dev/null +++ b/components/color/ColorInfo.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { ColorInfo as ColorInfoType } from '@/lib/api/types'; +import { Button } from '@/components/ui/button'; +import { Copy } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils/cn'; + +interface ColorInfoProps { + info: ColorInfoType; + className?: string; +} + +export function ColorInfo({ info, className }: ColorInfoProps) { + const copyToClipboard = (value: string, label: string) => { + navigator.clipboard.writeText(value); + toast.success(`Copied ${label} to clipboard`); + }; + + 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 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 formats = [ + { 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: formatLab(info.oklab) }, + ]; + + return ( +
+
+ {formats.map((format) => ( +
+
+
{format.label}
+
{format.value}
+
+ +
+ ))} +
+ +
+
+
Brightness
+
{(info.brightness * 100).toFixed(1)}%
+
+
+
Luminance
+
{(info.luminance * 100).toFixed(1)}%
+
+
+
Type
+
{info.is_light ? 'Light' : 'Dark'}
+
+ {info.name && ( +
+
Named
+
{info.name}
+
+ )} +
+
+ ); +} diff --git a/components/color/ColorPicker.tsx b/components/color/ColorPicker.tsx new file mode 100644 index 0000000..b089b0c --- /dev/null +++ b/components/color/ColorPicker.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { HexColorPicker } from 'react-colorful'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils/cn'; + +interface ColorPickerProps { + color: string; + onChange: (color: string) => void; + className?: string; +} + +export function ColorPicker({ color, onChange, className }: ColorPickerProps) { + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + // Allow partial input while typing + onChange(value); + }; + + return ( +
+ +
+ + +
+
+ ); +} diff --git a/lib/api/queries.ts b/lib/api/queries.ts new file mode 100644 index 0000000..235549e --- /dev/null +++ b/lib/api/queries.ts @@ -0,0 +1,206 @@ +'use client'; + +import { useQuery, useMutation, UseQueryOptions } from '@tanstack/react-query'; +import { pastelAPI } from './client'; +import type { + ColorInfoRequest, + ColorInfoData, + ConvertFormatRequest, + ConvertFormatData, + ColorManipulationRequest, + ColorManipulationData, + ColorMixRequest, + ColorMixData, + RandomColorsRequest, + RandomColorsData, + DistinctColorsRequest, + DistinctColorsData, + GradientRequest, + GradientData, + NamedColorsData, + HealthData, +} from './types'; + +// Color Information +export const useColorInfo = ( + request: ColorInfoRequest, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: ['colorInfo', request.colors], + queryFn: async () => { + const response = await pastelAPI.getColorInfo(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + enabled: request.colors.length > 0 && request.colors.every((c) => c.length > 0), + ...options, + }); +}; + +// Format Conversion +export const useConvertFormat = () => { + return useMutation({ + mutationFn: async (request: ConvertFormatRequest) => { + const response = await pastelAPI.convertFormat(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +// Color Manipulation +export const useLighten = () => { + return useMutation({ + mutationFn: async (request: ColorManipulationRequest) => { + const response = await pastelAPI.lighten(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +export const useDarken = () => { + return useMutation({ + mutationFn: async (request: ColorManipulationRequest) => { + const response = await pastelAPI.darken(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +export const useSaturate = () => { + return useMutation({ + mutationFn: async (request: ColorManipulationRequest) => { + const response = await pastelAPI.saturate(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +export const useDesaturate = () => { + return useMutation({ + mutationFn: async (request: ColorManipulationRequest) => { + const response = await pastelAPI.desaturate(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +export const useRotate = () => { + return useMutation({ + mutationFn: async (request: ColorManipulationRequest) => { + const response = await pastelAPI.rotate(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +export const useComplement = () => { + return useMutation({ + mutationFn: async (colors: string[]) => { + const response = await pastelAPI.complement(colors); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +export const useMixColors = () => { + return useMutation({ + mutationFn: async (request: ColorMixRequest) => { + const response = await pastelAPI.mix(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +// Color Generation +export const useGenerateRandom = () => { + return useMutation({ + mutationFn: async (request: RandomColorsRequest) => { + const response = await pastelAPI.generateRandom(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +export const useGenerateDistinct = () => { + return useMutation({ + mutationFn: async (request: DistinctColorsRequest) => { + const response = await pastelAPI.generateDistinct(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +export const useGenerateGradient = () => { + return useMutation({ + mutationFn: async (request: GradientRequest) => { + const response = await pastelAPI.generateGradient(request); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + }); +}; + +// Named Colors +export const useNamedColors = () => { + return useQuery({ + queryKey: ['namedColors'], + queryFn: async () => { + const response = await pastelAPI.getNamedColors(); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + staleTime: Infinity, // Named colors never change + }); +}; + +// Health Check +export const useHealth = () => { + return useQuery({ + queryKey: ['health'], + queryFn: async () => { + const response = await pastelAPI.getHealth(); + if (!response.success) { + throw new Error(response.error.message); + } + return response.data; + }, + refetchInterval: 60000, // Check every minute + }); +};