refactor: extract ColorManipulation component and pass icon/summary to AppPage
- Rename ColorPage → ColorManipulation (no AppPage wrapper inside) - Move AppPage + title/description/icon to color/page.tsx, consistent with other tools - AppPage now accepts icon prop directly; removes internal usePathname lookup and 'use client' - All tool pages pass tool.summary as description and tool.icon as icon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,8 @@ export default function ASCIIPage() {
|
|||||||
return (
|
return (
|
||||||
<AppPage
|
<AppPage
|
||||||
title={tool.title}
|
title={tool.title}
|
||||||
description={tool.description}
|
description={tool.summary}
|
||||||
|
icon={tool.icon}
|
||||||
>
|
>
|
||||||
<ASCIIConverter />
|
<ASCIIConverter />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import { ColorManipulation } from '@/components/color/ColorManipulation';
|
||||||
|
import { AppPage } from '@/components/layout/AppPage';
|
||||||
import { getToolByHref } from '@/lib/tools';
|
import { getToolByHref } from '@/lib/tools';
|
||||||
import { ColorPage } from '@/components/color/ColorPage';
|
|
||||||
|
|
||||||
const tool = getToolByHref('/color')!;
|
const tool = getToolByHref('/color')!;
|
||||||
|
|
||||||
export const metadata: Metadata = { title: tool.title };
|
export const metadata: Metadata = { title: tool.title };
|
||||||
|
|
||||||
export default function Page() {
|
export default function ColorPage() {
|
||||||
return <ColorPage />;
|
return (
|
||||||
|
<AppPage
|
||||||
|
title={tool.title}
|
||||||
|
description={tool.summary}
|
||||||
|
icon={tool.icon}
|
||||||
|
>
|
||||||
|
<ColorManipulation />
|
||||||
|
</AppPage>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export default function FaviconPage() {
|
|||||||
return (
|
return (
|
||||||
<AppPage
|
<AppPage
|
||||||
title={tool.title}
|
title={tool.title}
|
||||||
description={tool.description}
|
description={tool.summary}
|
||||||
|
icon={tool.icon}
|
||||||
>
|
>
|
||||||
<FaviconGenerator />
|
<FaviconGenerator />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export default function MediaPage() {
|
|||||||
return (
|
return (
|
||||||
<AppPage
|
<AppPage
|
||||||
title={tool.title}
|
title={tool.title}
|
||||||
description={tool.description}
|
description={tool.summary}
|
||||||
|
icon={tool.icon}
|
||||||
>
|
>
|
||||||
<FileConverter />
|
<FileConverter />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export default function QRCodePage() {
|
|||||||
return (
|
return (
|
||||||
<AppPage
|
<AppPage
|
||||||
title={tool.title}
|
title={tool.title}
|
||||||
description={tool.description}
|
description={tool.summary}
|
||||||
|
icon={tool.icon}
|
||||||
>
|
>
|
||||||
<QRCodeGenerator />
|
<QRCodeGenerator />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export default function UnitsPage() {
|
|||||||
return (
|
return (
|
||||||
<AppPage
|
<AppPage
|
||||||
title={tool.title}
|
title={tool.title}
|
||||||
description={tool.description}
|
description={tool.summary}
|
||||||
|
icon={tool.icon}
|
||||||
>
|
>
|
||||||
<MainConverter />
|
<MainConverter />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
|
|||||||
378
components/color/ColorManipulation.tsx
Normal file
378
components/color/ColorManipulation.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { ColorPicker } from '@/components/color/ColorPicker';
|
||||||
|
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 { toast } from 'sonner';
|
||||||
|
|
||||||
|
type HarmonyType =
|
||||||
|
| 'monochromatic'
|
||||||
|
| 'analogous'
|
||||||
|
| 'complementary'
|
||||||
|
| 'triadic'
|
||||||
|
| 'tetradic';
|
||||||
|
|
||||||
|
function ColorManipulationContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [color, setColor] = useState(() => {
|
||||||
|
const urlColor = searchParams.get('color');
|
||||||
|
return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Harmony state
|
||||||
|
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
|
||||||
|
const [palette, setPalette] = useState<string[]>([]);
|
||||||
|
const paletteMutation = useGeneratePalette();
|
||||||
|
|
||||||
|
// Gradient state
|
||||||
|
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
|
||||||
|
const [gradientCount, setGradientCount] = useState(10);
|
||||||
|
const [gradientResult, setGradientResult] = useState<string[]>([]);
|
||||||
|
const gradientMutation = useGenerateGradient();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = 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) {
|
||||||
|
router.push(`/color?color=${hex}`, { scroll: false });
|
||||||
|
}
|
||||||
|
}, [color, router]);
|
||||||
|
|
||||||
|
// Sync first gradient stop with active color
|
||||||
|
useEffect(() => {
|
||||||
|
const newStops = [...stops];
|
||||||
|
newStops[0] = color;
|
||||||
|
setStops(newStops);
|
||||||
|
}, [color]);
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
const url = `${window.location.origin}/color?color=${color.replace('#', '')}`;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
toast.success('Link copied to clipboard!');
|
||||||
|
};
|
||||||
|
|
||||||
|
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 generateGradient = async () => {
|
||||||
|
try {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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<HarmonyType, string> = {
|
||||||
|
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°)',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Row 1: Workspace */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
||||||
|
{/* Main Workspace: Color Picker and Information */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle>Color Picker</CardTitle>
|
||||||
|
<Button onClick={handleShare} variant="outline" size="xs">
|
||||||
|
<Share2 className="h-3 w-3 mr-1" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col md:flex-row gap-8">
|
||||||
|
<div className="flex-shrink-0 mx-auto md:mx-0">
|
||||||
|
<ColorPicker color={color} onChange={setColor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
|
||||||
|
<p className="font-medium">Error loading color information</p>
|
||||||
|
<p className="mt-1">{error?.message || 'Unknown error'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{colorInfo && <ColorInfo info={colorInfo} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar: Color Manipulation */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Adjustments</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ManipulationPanel color={color} onColorChange={setColor} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Harmony Generator */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
||||||
|
{/* Harmony Controls */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Harmony</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Select
|
||||||
|
value={harmonyType}
|
||||||
|
onValueChange={(value) => setHarmonyType(value as HarmonyType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select harmony" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="monochromatic">Monochromatic</SelectItem>
|
||||||
|
<SelectItem value="analogous">Analogous</SelectItem>
|
||||||
|
<SelectItem value="complementary">Complementary</SelectItem>
|
||||||
|
<SelectItem value="triadic">Triadic</SelectItem>
|
||||||
|
<SelectItem value="tetradic">Tetradic (Square)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{harmonyDescriptions[harmonyType]}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={generateHarmony}
|
||||||
|
disabled={paletteMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{paletteMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Generate'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Harmony Results */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Palette {palette.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({palette.length})</span>}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{palette.length > 0 ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<PaletteGrid colors={palette} onColorClick={setColor} />
|
||||||
|
<div className="pt-3 border-t">
|
||||||
|
<ExportMenu colors={palette} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-xs">
|
||||||
|
<Palette className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||||
|
<p>Generate a harmony palette from the current color</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Gradient Generator */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
||||||
|
{/* Gradient Controls */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Gradient</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Color Stops</Label>
|
||||||
|
{stops.map((stop, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={stop}
|
||||||
|
onChange={(e) => updateStop(index, e.target.value)}
|
||||||
|
className="w-9 h-9 p-1 shrink-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={stop}
|
||||||
|
onChange={(e) => updateStop(index, e.target.value)}
|
||||||
|
className="font-mono text-xs flex-1"
|
||||||
|
/>
|
||||||
|
{index !== 0 && stops.length > 2 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => removeStop(index)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button onClick={addStop} variant="outline" className="w-full">
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Add Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Steps</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={100}
|
||||||
|
value={gradientCount}
|
||||||
|
onChange={(e) => setGradientCount(parseInt(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={generateGradient}
|
||||||
|
disabled={gradientMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{gradientMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Generate'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gradient Results */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Gradient {gradientResult.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({gradientResult.length})</span>}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{gradientResult.length > 0 ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div
|
||||||
|
className="h-16 w-full rounded-lg border"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${gradientResult.join(', ')})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PaletteGrid colors={gradientResult} onColorClick={setColor} />
|
||||||
|
<div className="pt-3 border-t">
|
||||||
|
<ExportMenu colors={gradientResult} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-xs">
|
||||||
|
<Layers className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||||
|
<p>Add color stops and generate a smooth gradient</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorManipulation() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<ColorManipulationContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
|
||||||
import { ColorPicker } from '@/components/color/ColorPicker';
|
|
||||||
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 { AppPage } from '@/components/layout/AppPage';
|
|
||||||
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 { toast } from 'sonner';
|
|
||||||
import { getToolByHref } from '@/lib/tools';
|
|
||||||
|
|
||||||
const tool = getToolByHref('/color')!;
|
|
||||||
|
|
||||||
type HarmonyType =
|
|
||||||
| 'monochromatic'
|
|
||||||
| 'analogous'
|
|
||||||
| 'complementary'
|
|
||||||
| 'triadic'
|
|
||||||
| 'tetradic';
|
|
||||||
|
|
||||||
function PlaygroundContent() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const [color, setColor] = useState(() => {
|
|
||||||
// Initialize from URL if available
|
|
||||||
const urlColor = searchParams.get('color');
|
|
||||||
return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Harmony state
|
|
||||||
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
|
|
||||||
const [palette, setPalette] = useState<string[]>([]);
|
|
||||||
const paletteMutation = useGeneratePalette();
|
|
||||||
|
|
||||||
// Gradient state
|
|
||||||
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
|
|
||||||
const [gradientCount, setGradientCount] = useState(10);
|
|
||||||
const [gradientResult, setGradientResult] = useState<string[]>([]);
|
|
||||||
const gradientMutation = useGenerateGradient();
|
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = 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) {
|
|
||||||
router.push(`/color?color=${hex}`, { scroll: false });
|
|
||||||
}
|
|
||||||
}, [color, router]);
|
|
||||||
|
|
||||||
// Sync first gradient stop with active color
|
|
||||||
useEffect(() => {
|
|
||||||
const newStops = [...stops];
|
|
||||||
newStops[0] = color;
|
|
||||||
setStops(newStops);
|
|
||||||
}, [color]);
|
|
||||||
|
|
||||||
// Share color via URL
|
|
||||||
const handleShare = () => {
|
|
||||||
const url = `${window.location.origin}/color?color=${color.replace('#', '')}`;
|
|
||||||
navigator.clipboard.writeText(url);
|
|
||||||
toast.success('Link copied to clipboard!');
|
|
||||||
};
|
|
||||||
|
|
||||||
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 generateGradient = async () => {
|
|
||||||
try {
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addStop = () => {
|
|
||||||
setStops([...stops, '#000000']);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeStop = (index: number) => {
|
|
||||||
if (index === 0) return; // Prevent deleting the first stop (synchronized with picker)
|
|
||||||
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<HarmonyType, string> = {
|
|
||||||
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°)',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppPage
|
|
||||||
title={tool.title}
|
|
||||||
description={tool.description}
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Row 1: Workspace */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
|
||||||
{/* Main Workspace: Color Picker and Information */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
||||||
<CardTitle>Color Picker</CardTitle>
|
|
||||||
<Button onClick={handleShare} variant="outline" size="xs">
|
|
||||||
<Share2 className="h-3 w-3 mr-1" />
|
|
||||||
Share
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col md:flex-row gap-8">
|
|
||||||
<div className="flex-shrink-0 mx-auto md:mx-0">
|
|
||||||
<ColorPicker color={color} onChange={setColor} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isError && (
|
|
||||||
<div className="p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
|
|
||||||
<p className="font-medium">Error loading color information</p>
|
|
||||||
<p className="mt-1">{error?.message || 'Unknown error'}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{colorInfo && <ColorInfo info={colorInfo} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar: Color Manipulation */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Adjustments</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ManipulationPanel color={color} onColorChange={setColor} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Harmony Generator */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
|
||||||
{/* Harmony Controls */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Harmony</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<Select
|
|
||||||
value={harmonyType}
|
|
||||||
onValueChange={(value) => setHarmonyType(value as HarmonyType)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Select harmony" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="monochromatic">Monochromatic</SelectItem>
|
|
||||||
<SelectItem value="analogous">Analogous</SelectItem>
|
|
||||||
<SelectItem value="complementary">Complementary</SelectItem>
|
|
||||||
<SelectItem value="triadic">Triadic</SelectItem>
|
|
||||||
<SelectItem value="tetradic">Tetradic (Square)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{harmonyDescriptions[harmonyType]}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={generateHarmony}
|
|
||||||
disabled={paletteMutation.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{paletteMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Generate'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Harmony Results */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
Palette {palette.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({palette.length})</span>}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{palette.length > 0 ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<PaletteGrid colors={palette} onColorClick={setColor} />
|
|
||||||
<div className="pt-3 border-t">
|
|
||||||
<ExportMenu colors={palette} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-8 text-center text-muted-foreground text-xs">
|
|
||||||
<Palette className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
|
||||||
<p>Generate a harmony palette from the current color</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 3: Gradient Generator */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
|
||||||
{/* Gradient Controls */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Gradient</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">Color Stops</Label>
|
|
||||||
{stops.map((stop, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="color"
|
|
||||||
value={stop}
|
|
||||||
onChange={(e) => updateStop(index, e.target.value)}
|
|
||||||
className="w-9 h-9 p-1 shrink-0 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={stop}
|
|
||||||
onChange={(e) => updateStop(index, e.target.value)}
|
|
||||||
className="font-mono text-xs flex-1"
|
|
||||||
/>
|
|
||||||
{index !== 0 && stops.length > 2 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
onClick={() => removeStop(index)}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button onClick={addStop} variant="outline" className="w-full">
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Add Stop
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">Steps</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={2}
|
|
||||||
max={100}
|
|
||||||
value={gradientCount}
|
|
||||||
onChange={(e) => setGradientCount(parseInt(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={generateGradient}
|
|
||||||
disabled={gradientMutation.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{gradientMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Generate'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gradient Results */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
Gradient {gradientResult.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({gradientResult.length})</span>}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{gradientResult.length > 0 ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div
|
|
||||||
className="h-16 w-full rounded-lg border"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, ${gradientResult.join(', ')})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PaletteGrid colors={gradientResult} onColorClick={setColor} />
|
|
||||||
<div className="pt-3 border-t">
|
|
||||||
<ExportMenu colors={gradientResult} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-8 text-center text-muted-foreground text-xs">
|
|
||||||
<Layers className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
|
||||||
<p>Add color stops and generate a smooth gradient</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AppPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ColorPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 flex items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<PlaygroundContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,15 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getToolByHref } from '@/lib/tools';
|
|
||||||
|
|
||||||
interface AppPageProps {
|
interface AppPageProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
icon?: React.ElementType;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppPage({ title, description, children, className }: AppPageProps) {
|
export function AppPage({ title, description, icon: Icon, children, className }: AppPageProps) {
|
||||||
const pathname = usePathname();
|
|
||||||
const firstSegment = pathname.split('/').filter(Boolean)[0];
|
|
||||||
const tool = getToolByHref(`/${firstSegment ?? ''}`);
|
|
||||||
const Icon = tool?.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-h-screen py-8", className)}>
|
<div className={cn("min-h-screen py-8", className)}>
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-6 animate-fade-in">
|
<div className="max-w-7xl mx-auto px-8 space-y-6 animate-fade-in">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const tools: Tool[] = [
|
|||||||
title: 'Color Manipulation',
|
title: 'Color Manipulation',
|
||||||
navTitle: 'Color Manipulation',
|
navTitle: 'Color Manipulation',
|
||||||
href: '/color',
|
href: '/color',
|
||||||
description: 'Interactive color manipulation and analysis tool',
|
description: 'Interactive color manipulation and analysis tool.',
|
||||||
summary:
|
summary:
|
||||||
'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.',
|
'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.',
|
||||||
icon: ColorIcon,
|
icon: ColorIcon,
|
||||||
@@ -36,7 +36,7 @@ export const tools: Tool[] = [
|
|||||||
title: 'Units Converter',
|
title: 'Units Converter',
|
||||||
navTitle: 'Units Converter',
|
navTitle: 'Units Converter',
|
||||||
href: '/units',
|
href: '/units',
|
||||||
description: 'Smart unit converter with 187 units across 23 categories',
|
description: 'Smart unit converter with 187 units across 23 categories.',
|
||||||
summary:
|
summary:
|
||||||
'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search.',
|
'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search.',
|
||||||
icon: UnitsIcon,
|
icon: UnitsIcon,
|
||||||
@@ -47,7 +47,7 @@ export const tools: Tool[] = [
|
|||||||
title: 'ASCII Art Generator',
|
title: 'ASCII Art Generator',
|
||||||
navTitle: 'ASCII Art',
|
navTitle: 'ASCII Art',
|
||||||
href: '/ascii',
|
href: '/ascii',
|
||||||
description: 'ASCII Art Text Generator with 373 Fonts',
|
description: 'ASCII Art Text Generator with 373 Fonts.',
|
||||||
summary:
|
summary:
|
||||||
'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.',
|
'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.',
|
||||||
icon: ASCIIIcon,
|
icon: ASCIIIcon,
|
||||||
@@ -58,7 +58,7 @@ export const tools: Tool[] = [
|
|||||||
title: 'Media Converter',
|
title: 'Media Converter',
|
||||||
navTitle: 'Media Converter',
|
navTitle: 'Media Converter',
|
||||||
href: '/media',
|
href: '/media',
|
||||||
description: 'Professional browser-based media conversion for video, audio, and images',
|
description: 'Browser-based media conversion for video, audio, and images.',
|
||||||
summary:
|
summary:
|
||||||
'Modern browser-based file converter powered by WebAssembly. Convert videos, images, and audio locally without server uploads. Privacy-first with no file size limits.',
|
'Modern browser-based file converter powered by WebAssembly. Convert videos, images, and audio locally without server uploads. Privacy-first with no file size limits.',
|
||||||
icon: MediaIcon,
|
icon: MediaIcon,
|
||||||
@@ -69,7 +69,7 @@ export const tools: Tool[] = [
|
|||||||
title: 'Favicon Generator',
|
title: 'Favicon Generator',
|
||||||
navTitle: 'Favicon Generator',
|
navTitle: 'Favicon Generator',
|
||||||
href: '/favicon',
|
href: '/favicon',
|
||||||
description: 'Create a complete set of icons for your website including PWA manifest and HTML code.',
|
description: 'Create a complete set of icons for your website.',
|
||||||
summary:
|
summary:
|
||||||
'Generate a complete set of favicons for your website. Includes PWA manifest and HTML embed code. All processing happens locally in your browser.',
|
'Generate a complete set of favicons for your website. Includes PWA manifest and HTML embed code. All processing happens locally in your browser.',
|
||||||
icon: FaviconIcon,
|
icon: FaviconIcon,
|
||||||
@@ -80,7 +80,7 @@ export const tools: Tool[] = [
|
|||||||
title: 'QR Code Generator',
|
title: 'QR Code Generator',
|
||||||
navTitle: 'QR Code Generator',
|
navTitle: 'QR Code Generator',
|
||||||
href: '/qrcode',
|
href: '/qrcode',
|
||||||
description: 'Generate QR codes with custom colors, error correction, and multi-format export.',
|
description: 'Generate QR codes with custom colors and error correction.',
|
||||||
summary:
|
summary:
|
||||||
'Generate QR codes with live preview, customizable colors, error correction levels, and export as PNG or SVG. All processing happens locally in your browser.',
|
'Generate QR codes with live preview, customizable colors, error correction levels, and export as PNG or SVG. All processing happens locally in your browser.',
|
||||||
icon: QRCodeIcon,
|
icon: QRCodeIcon,
|
||||||
|
|||||||
Reference in New Issue
Block a user