Files
kit-ui/app/(app)/pastel/page.tsx

397 lines
14 KiB
TypeScript

'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { ColorPicker } from '@/components/pastel/ColorPicker';
import { ColorInfo } from '@/components/pastel/ColorInfo';
import { ManipulationPanel } from '@/components/pastel/ManipulationPanel';
import { PaletteGrid } from '@/components/pastel/PaletteGrid';
import { ExportMenu } from '@/components/pastel/ExportMenu';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AppPage } from '@/components/layout/AppPage';
import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/pastel/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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
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(`/pastel?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}/pastel?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="Pastel"
description="Interactive color manipulation and analysis tool"
>
<div className="space-y-8">
{/* Row 1: Workspace */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 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="sm">
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row gap-12">
<div className="flex-shrink-0 mx-auto md:mx-0">
<ColorPicker color={color} onChange={setColor} />
</div>
<div className="flex-1">
<h3 className="text-sm font-medium mb-4 text-muted-foreground uppercase tracking-wider">Color Information</h3>
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{isError && (
<div className="p-4 bg-destructive/10 text-destructive rounded-lg">
<p className="font-medium">Error loading color information</p>
<p className="text-sm 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>Color Manipulation</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-8 items-stretch">
{/* Harmony Controls */}
<div className="lg:col-span-1">
<Card className="h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Harmony Type
</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-sm text-muted-foreground">
{harmonyDescriptions[harmonyType]}
</p>
<Button
onClick={generateHarmony}
disabled={paletteMutation.isPending}
className="w-full"
>
{paletteMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating..
</>
) : (
<>
<Palette className="mr-2 h-4 w-4" />
Generate Harmony
</>
)}
</Button>
</CardContent>
</Card>
</div>
{/* Harmony Results */}
<div className="lg:col-span-2">
<Card className="h-full">
<CardHeader>
<CardTitle>
Generated Palette {palette.length > 0 && `(${palette.length} colors)`}
</CardTitle>
</CardHeader>
<CardContent>
{palette.length > 0 ? (
<div className="space-y-6">
<PaletteGrid colors={palette} onColorClick={setColor} />
<div className="pt-4 border-t">
<ExportMenu colors={palette} />
</div>
</div>
) : (
<div className="p-12 text-center text-muted-foreground">
<Palette className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Select a harmony type and click Generate to create your palette based on the current color</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Row 3: Gradient Generator */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-stretch">
{/* Gradient Controls */}
<div className="lg:col-span-1">
<Card className="h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="h-5 w-5" />
Gradient Controls
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Color Stops</h4>
{stops.map((stop, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<Input
type="color"
value={stop}
onChange={(e) => updateStop(index, e.target.value)}
className="h-10 w-full cursor-pointer p-1"
/>
</div>
{index !== 0 && stops.length > 2 && (
<Button
variant="ghost"
size="icon"
onClick={() => removeStop(index)}
className="shrink-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button onClick={addStop} variant="outline" size="sm" className="w-full">
<Plus className="h-4 w-4 mr-2" />
Add Stop
</Button>
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Steps</h4>
<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="mr-2 h-4 w-4 animate-spin" />
Generating..
</>
) : (
'Generate Gradient'
)}
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Gradient Results */}
<div className="lg:col-span-2">
<Card className="h-full">
<CardHeader>
<CardTitle>
Generated Gradient {gradientResult.length > 0 && `(${gradientResult.length} colors)`}
</CardTitle>
</CardHeader>
<CardContent>
{gradientResult.length > 0 ? (
<div className="space-y-6">
<div
className="h-24 w-full rounded-lg border shadow-inner"
style={{
background: `linear-gradient(to right, ${gradientResult.join(', ')})`,
}}
/>
<PaletteGrid colors={gradientResult} onColorClick={setColor} />
<div className="pt-4 border-t">
<ExportMenu colors={gradientResult} />
</div>
</div>
) : (
<div className="p-12 text-center text-muted-foreground">
<Layers className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Add color stops and click Generate to create your smooth gradient</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</AppPage>
);
}
export default function PlaygroundPage() {
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>
);
}