diff --git a/README.md b/README.md index f5d4cd4..8683d66 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A comprehensive suite of developer tools built with React, Vite, and TypeScript. ## Features -QuickDevTools provides 10 essential developer tools in a single, fast, and privacy-focused application. +QuickDevTools provides 11 essential developer tools in a single, fast, and privacy-focused application. ### 1. Config Formatter - **Auto-detection**: Automatically detects JSON or YAML format @@ -30,6 +30,13 @@ QuickDevTools provides 10 essential developer tools in a single, fast, and priva - `Esc` to edit - **GitHub-style**: Matches GitHub's diff visualization +### 4. Image Beautifier +- **Background Options**: Solid colors or gradients +- **Customizable**: Border radius, padding, gradient rotation +- **Live Preview**: Real-time preview of beautified images +- **Download**: Export beautified images as PNG +- **Perfect for Social Media**: Ideal for sharing screenshots on X, LinkedIn, etc. + ### 5. Regex Checker - **Pattern Testing**: Test regular expressions against any text - **Flag Support**: All regex flags (g, i, m, s, u, y) with descriptions @@ -72,12 +79,10 @@ QuickDevTools provides 10 essential developer tools in a single, fast, and priva - **Validation**: Validate URL format and structure - **Visual Breakdown**: Clear visual representation of URL parts -### 11. Image Beautifier -- **Background Options**: Solid colors or gradients -- **Customizable**: Border radius, padding, gradient rotation -- **Live Preview**: Real-time preview of beautified images -- **Download**: Export beautified images as PNG -- **Perfect for Social Media**: Ideal for sharing screenshots on X, LinkedIn, etc. +### 11. Color Utility +- **Automatic Conversion**: Paste HEX, RGB(A), or HSL(A) and instantly see all formats +- **Color Picker**: Visually pick a color and preview the converted values +- **Copy Ready**: One-click copy for each supported format ## Tech Stack @@ -160,6 +165,7 @@ The built files will be in the `dist` directory. - `Ctrl/Cmd + 8`: Switch to UUID/Hash Generator - `Ctrl/Cmd + 9`: Switch to URL Inspector - `Ctrl/Cmd + 0`: Switch to Image Beautifier +- Color Utility: Accessible from the sidebar (no keyboard shortcut since Ctrl/Cmd + 0-9 are already assigned) - `Ctrl + Enter` (Git Diff): Compare texts - `Esc` (Git Diff): Return to edit mode @@ -193,6 +199,8 @@ quickdevtools/ │ │ │ └── UrlInspector.tsx │ │ ├── ImageBeautifier/ # Screenshot beautification tool │ │ │ └── ImageBeautifier.tsx +│ │ ├── ColorUtility/ # Color converter and picker tool +│ │ │ └── ColorUtility.tsx │ │ └── Sidebar/ # Navigation sidebar component │ │ └── Sidebar.tsx │ ├── context/ # React context providers diff --git a/src/App.tsx b/src/App.tsx index 51b4c04..a62ef19 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { TimestampConverter } from './components/TimestampConverter/TimestampCon import { UuidHashGenerator } from './components/UuidHashGenerator/UuidHashGenerator'; import { TextCaseConverter } from './components/TextCaseConverter/TextCaseConverter'; import { UrlInspector } from './components/UrlInspector/UrlInspector'; +import { ColorUtility } from './components/ColorUtility/ColorUtility'; import type { ToolType } from './types'; function AppContent() { @@ -61,6 +62,8 @@ function AppContent() { return ; case 'url': return ; + case 'color': + return ; default: return ; } diff --git a/src/components/ColorUtility/ColorUtility.tsx b/src/components/ColorUtility/ColorUtility.tsx new file mode 100644 index 0000000..bb36a50 --- /dev/null +++ b/src/components/ColorUtility/ColorUtility.tsx @@ -0,0 +1,462 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Check, Copy, Palette } from 'lucide-react'; +import clsx from 'clsx'; + +type Mode = 'convert' | 'picker'; + +interface ColorValues { + r: number; + g: number; + b: number; + a: number; + format: string; +} + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const toHex = (value: number) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0').toUpperCase(); + +const formatAlpha = (value: number) => Number(clamp(value, 0, 1).toFixed(2)).toString(); + +const normalizeAlpha = (value: number) => { + if (Number.isNaN(value)) return null; + if (value > 1 && value <= 100) return value / 100; + if (value < 0 || value > 1) return null; + return value; +}; + +const parseHex = (value: string): ColorValues | null => { + const match = value.match(/^#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i); + if (!match) return null; + + const hex = match[1]; + let r = 0; + let g = 0; + let b = 0; + let a = 1; + + if (hex.length === 3 || hex.length === 4) { + r = parseInt(hex[0] + hex[0], 16); + g = parseInt(hex[1] + hex[1], 16); + b = parseInt(hex[2] + hex[2], 16); + if (hex.length === 4) { + a = parseInt(hex[3] + hex[3], 16) / 255; + } + } else { + r = parseInt(hex.slice(0, 2), 16); + g = parseInt(hex.slice(2, 4), 16); + b = parseInt(hex.slice(4, 6), 16); + if (hex.length === 8) { + a = parseInt(hex.slice(6, 8), 16) / 255; + } + } + + return { r, g, b, a, format: hex.length === 4 || hex.length === 8 ? 'HEXA' : 'HEX' }; +}; + +const hslToRgb = (h: number, s: number, l: number) => { + const hue = ((h % 360) + 360) % 360; + const saturation = clamp(s, 0, 1); + const lightness = clamp(l, 0, 1); + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const hueSegment = hue / 60; + const x = chroma * (1 - Math.abs((hueSegment % 2) - 1)); + let r1 = 0; + let g1 = 0; + let b1 = 0; + + if (hueSegment >= 0 && hueSegment < 1) { + r1 = chroma; + g1 = x; + } else if (hueSegment >= 1 && hueSegment < 2) { + r1 = x; + g1 = chroma; + } else if (hueSegment >= 2 && hueSegment < 3) { + g1 = chroma; + b1 = x; + } else if (hueSegment >= 3 && hueSegment < 4) { + g1 = x; + b1 = chroma; + } else if (hueSegment >= 4 && hueSegment < 5) { + r1 = x; + b1 = chroma; + } else { + r1 = chroma; + b1 = x; + } + + const m = lightness - chroma / 2; + return { + r: Math.round((r1 + m) * 255), + g: Math.round((g1 + m) * 255), + b: Math.round((b1 + m) * 255), + }; +}; + +const rgbToHsl = (r: number, g: number, b: number) => { + const red = clamp(r / 255, 0, 1); + const green = clamp(g / 255, 0, 1); + const blue = clamp(b / 255, 0, 1); + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (delta !== 0) { + s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min); + switch (max) { + case red: + h = (green - blue) / delta + (green < blue ? 6 : 0); + break; + case green: + h = (blue - red) / delta + 2; + break; + default: + h = (red - green) / delta + 4; + break; + } + h *= 60; + } + + return { + h: Math.round(h), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; +}; + +const parseColorInput = (value: string): ColorValues | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + const hexValue = parseHex(trimmed); + if (hexValue) return hexValue; + + const rgbMatch = trimmed.match( + /^rgba?\(\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*(?:,\s*([+-]?\d*\.?\d+)\s*)?\)$/i + ); + + if (rgbMatch) { + const r = Number(rgbMatch[1]); + const g = Number(rgbMatch[2]); + const b = Number(rgbMatch[3]); + const alphaRaw = rgbMatch[4]; + const a = alphaRaw !== undefined ? normalizeAlpha(Number(alphaRaw)) : 1; + if (a === null || [r, g, b].some((channel) => channel < 0 || channel > 255)) { + return null; + } + return { + r: Math.round(r), + g: Math.round(g), + b: Math.round(b), + a, + format: alphaRaw !== undefined ? 'RGBA' : 'RGB', + }; + } + + const hslMatch = trimmed.match( + /^hsla?\(\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)%\s*,\s*([+-]?\d*\.?\d+)%\s*(?:,\s*([+-]?\d*\.?\d+)\s*)?\)$/i + ); + + if (hslMatch) { + const h = Number(hslMatch[1]); + const s = Number(hslMatch[2]); + const l = Number(hslMatch[3]); + const alphaRaw = hslMatch[4]; + const a = alphaRaw !== undefined ? normalizeAlpha(Number(alphaRaw)) : 1; + if (a === null || s < 0 || s > 100 || l < 0 || l > 100) { + return null; + } + const rgb = hslToRgb(h, s / 100, l / 100); + return { + ...rgb, + a, + format: alphaRaw !== undefined ? 'HSLA' : 'HSL', + }; + } + + return null; +}; + +const formatHex = (color: ColorValues) => { + const base = `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`; + if (color.a < 1) { + return `${base}${toHex(color.a * 255)}`; + } + return base; +}; + +const formatRgb = (color: ColorValues) => `rgb(${color.r}, ${color.g}, ${color.b})`; + +const formatRgba = (color: ColorValues) => `rgba(${color.r}, ${color.g}, ${color.b}, ${formatAlpha(color.a)})`; + +const formatHsl = (color: ColorValues) => { + const hsl = rgbToHsl(color.r, color.g, color.b); + return `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`; +}; + +const formatHsla = (color: ColorValues) => { + const hsl = rgbToHsl(color.r, color.g, color.b); + return `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${formatAlpha(color.a)})`; +}; + +export const ColorUtility: React.FC = () => { + const savedState = useMemo(() => { + const saved = localStorage.getItem('colorUtility'); + if (!saved) return null; + try { + return JSON.parse(saved) as Partial<{ + mode: Mode; + input: string; + pickerColor: string; + pickerAlpha: number; + }>; + } catch (error) { + console.error('Failed to parse saved color utility state from localStorage:', error); + return null; + } + }, []); + + const [mode, setMode] = useState(() => { + if (savedState && (savedState.mode === 'picker' || savedState.mode === 'convert')) { + return savedState.mode; + } + return 'convert'; + }); + const [input, setInput] = useState(() => + typeof savedState?.input === 'string' ? savedState.input : '' + ); + const [pickerColor, setPickerColor] = useState(() => { + if (typeof savedState?.pickerColor === 'string') { + const hex = parseHex(savedState.pickerColor); + if (hex) { + return formatHex(hex); + } + } + return '#6366F1'; + }); + const pickerAlpha = 1; + const [copied, setCopied] = useState(null); + + const parsedColor = useMemo(() => parseColorInput(input), [input]); + const pickerValues = useMemo(() => { + const parsed = parseHex(pickerColor) ?? { r: 99, g: 102, b: 241, a: 1, format: 'HEX' }; + return { ...parsed, a: pickerAlpha }; + }, [pickerColor, pickerAlpha]); + + const conversionFormats = useMemo(() => { + if (!parsedColor) return []; + return [ + { name: 'HEX', value: formatHex(parsedColor) }, + { name: 'RGB', value: formatRgb(parsedColor) }, + { name: 'RGBA', value: formatRgba(parsedColor) }, + { name: 'HSL', value: formatHsl(parsedColor) }, + { name: 'HSLA', value: formatHsla(parsedColor) }, + ]; + }, [parsedColor]); + + const pickerFormats = useMemo( + () => [ + { name: 'HEX', value: formatHex(pickerValues) }, + { name: 'RGB', value: formatRgb(pickerValues) }, + { name: 'RGBA', value: formatRgba(pickerValues) }, + { name: 'HSL', value: formatHsl(pickerValues) }, + { name: 'HSLA', value: formatHsla(pickerValues) }, + ], + [pickerValues] + ); + + const conversionError = input.trim() && !parsedColor + ? 'Unsupported color format. Try HEX, RGB(A), or HSL(A).' + : null; + + useEffect(() => { + localStorage.setItem( + 'colorUtility', + JSON.stringify({ + mode, + input, + pickerColor, + pickerAlpha, + }) + ); + }, [mode, input, pickerColor, pickerAlpha]); + + const handleCopy = async (text: string, key: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(key); + setTimeout(() => setCopied(null), 2000); + } catch (error) { + console.error('Failed to copy color value:', error); + } + }; + + const renderFormats = (formats: { name: string; value: string }[]) => { + if (!formats.length) { + return ( +
+ No color detected yet. +
+ ); + } + + return ( +
+ {formats.map((format) => ( +
+
+
+ + + {format.name} + +
+ +
+
+ + {format.value} + +
+
+ ))} +
+ ); + }; + + return ( +
+
+
+
+ + Color Utility + +
+ + +
+
+ +
+ {mode === 'convert' ? ( + <> +
+ + setInput(event.target.value)} + placeholder="e.g. #6366f1, rgb(99, 102, 241), hsl(230, 86%, 60%)" + className="w-full px-3 py-2 text-sm font-mono bg-white dark:bg-[#0d1117] border border-gray-300 dark:border-[#30363d] rounded focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-[#c9d1d9]" + /> +
+
+
+ + {parsedColor ? `Detected: ${parsedColor.format}` : 'Paste a color to detect its format.'} + +
+ {conversionError && ( +
+ {conversionError} +
+ )} + + ) : ( + <> +
+ +
+ setPickerColor(event.target.value)} + className="h-10 w-14 rounded border border-gray-200 dark:border-[#30363d] bg-white dark:bg-[#0d1117] cursor-pointer" + /> +
+
+ Selected HEX +
+
+ {pickerColor.toUpperCase()} +
+
+
+
+ + )} +
+
+ +
+
+ + {mode === 'convert' ? 'Converted Formats' : 'Picked Formats'} + +
+
+
+ {renderFormats(mode === 'convert' ? conversionFormats : pickerFormats)} +
+
+
+ +
+
+ + Instant tools. No sign-up. No data sent to any server. +
+
+
+ ); +}; diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index 02c8bb7..ff75f42 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -17,7 +17,8 @@ import { Clock, Key, Type, - Link + Link, + Palette } from 'lucide-react'; import clsx from 'clsx'; @@ -37,6 +38,7 @@ const tools = [ { id: 'generator' as ToolType, name: 'UUID/Hash', icon: Key }, { id: 'case' as ToolType, name: 'Case Converter', icon: Type }, { id: 'url' as ToolType, name: 'URL Inspector', icon: Link }, + { id: 'color' as ToolType, name: 'Color Utility', icon: Palette }, ]; export const Sidebar: React.FC = ({ activeTool, onToolChange }) => { @@ -80,7 +82,7 @@ export const Sidebar: React.FC = ({ activeTool, onToolChange }) => {tools.map((tool, index) => { const Icon = tool.icon; const isActive = activeTool === tool.id; - const shortcutKey = index === 9 ? '0' : (index + 1).toString(); + const shortcutKey = index < 9 ? (index + 1).toString() : index === 9 ? '0' : null; return ( diff --git a/src/types/index.ts b/src/types/index.ts index 7ecd184..60ecb84 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -export type ToolType = 'config' | 'markdown' | 'diff' | 'image' | 'regex' | 'decoder' | 'timestamp' | 'generator' | 'case' | 'url'; +export type ToolType = 'config' | 'markdown' | 'diff' | 'image' | 'regex' | 'decoder' | 'timestamp' | 'generator' | 'case' | 'url' | 'color'; export type Theme = 'light' | 'dark'; @@ -79,3 +79,10 @@ export interface TextCaseConverterState { export interface UrlInspectorState { input: string; } + +export interface ColorUtilityState { + mode: 'convert' | 'picker'; + input: string; + pickerColor: string; + pickerAlpha: number; +}