feat: implement Figlet, Pastel, and Unit tools with a unified layout
- Add Figlet text converter with font selection and history - Add Pastel color palette generator and manipulation suite - Add comprehensive Units converter with category-based logic - Introduce AppShell with Sidebar and Header for navigation - Modernize theme system with CSS variables and new animations - Update project configuration and dependencies
This commit is contained in:
38
components/pastel/color/ColorDisplay.tsx
Normal file
38
components/pastel/color/ColorDisplay.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg transition-all',
|
||||
showBorder && 'ring-2 ring-border',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
role="img"
|
||||
aria-label={`Color swatch: ${color}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
92
components/pastel/color/ColorInfo.tsx
Normal file
92
components/pastel/color/ColorInfo.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { ColorInfo as ColorInfoType } from '@/lib/pastel/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 (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{formats.map((format) => (
|
||||
<div
|
||||
key={format.label}
|
||||
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-muted-foreground mb-1">{format.label}</div>
|
||||
<div className="font-mono text-sm">{format.value}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(format.value, format.label)}
|
||||
aria-label={`Copy ${format.label} value`}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Brightness</div>
|
||||
<div className="text-sm font-medium">{(info.brightness * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Luminance</div>
|
||||
<div className="text-sm font-medium">{(info.luminance * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Type</div>
|
||||
<div className="text-sm font-medium">{info.is_light ? 'Light' : 'Dark'}</div>
|
||||
</div>
|
||||
{info.name && typeof info.name === 'string' && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Named</div>
|
||||
<div className="text-sm font-medium">{info.name}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
components/pastel/color/ColorPicker.tsx
Normal file
38
components/pastel/color/ColorPicker.tsx
Normal file
@@ -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<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
// Allow partial input while typing
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<HexColorPicker color={color} onChange={onChange} className="w-full" />
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="color-input" className="text-sm font-medium">
|
||||
Color Value
|
||||
</label>
|
||||
<Input
|
||||
id="color-input"
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={handleInputChange}
|
||||
placeholder="#ff0099 or rgb(255, 0, 153)"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
components/pastel/color/ColorSwatch.tsx
Normal file
66
components/pastel/color/ColorSwatch.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ColorSwatchProps {
|
||||
color: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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();
|
||||
navigator.clipboard.writeText(color);
|
||||
setCopied(true);
|
||||
toast.success(`Copied ${color}`);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center gap-2', className)}>
|
||||
<button
|
||||
className={cn(
|
||||
'relative rounded-lg ring-2 ring-border transition-all duration-200',
|
||||
'hover:scale-110 hover:ring-primary hover:shadow-lg',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'group active:scale-95',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={onClick || handleCopy}
|
||||
aria-label={`Color ${color}`}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 bg-black/30 rounded-lg backdrop-blur-sm">
|
||||
{copied ? (
|
||||
<Check className="h-5 w-5 text-white animate-scale-in" />
|
||||
) : (
|
||||
<Copy className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{showLabel && (
|
||||
<span className="text-xs font-mono text-muted-foreground">{color}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
components/pastel/color/PaletteGrid.tsx
Normal file
37
components/pastel/color/PaletteGrid.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { ColorSwatch } from './ColorSwatch';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface PaletteGridProps {
|
||||
colors: string[];
|
||||
onColorClick?: (color: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PaletteGrid({ colors, onColorClick, className }: PaletteGridProps) {
|
||||
if (colors.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No colors in palette yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{colors.map((color, index) => (
|
||||
<ColorSwatch
|
||||
key={`${color}-${index}`}
|
||||
color={color}
|
||||
onClick={onColorClick ? () => onColorClick(color) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
components/pastel/layout/Footer.tsx
Normal file
203
components/pastel/layout/Footer.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Github, Heart, ExternalLink } from 'lucide-react';
|
||||
|
||||
export function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="border-t bg-card/50 backdrop-blur-sm">
|
||||
<div className="max-w-7xl mx-auto px-8 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* About */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">Pastel UI</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A modern, interactive web application for color manipulation, palette generation,
|
||||
and accessibility analysis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Resources</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link href="/pastel/playground" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Color Playground
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/pastel/palettes" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Palette Generator
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/pastel/accessibility" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Accessibility Tools
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/pastel/batch" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Batch Operations
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Documentation */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Documentation</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-ui#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Getting Started
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-api#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
API Documentation
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/sharkdp/pastel"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Pastel CLI
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Community */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Community</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-ui"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
Pastel UI
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
Pastel API
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-ui/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Report an Issue
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-ui/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Discussions
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="mt-12 pt-8 border-t">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1">
|
||||
© {currentYear} Pastel UI. Built with
|
||||
<Heart className="h-4 w-4 text-red-500 fill-red-500" />
|
||||
using
|
||||
<a
|
||||
href="https://nextjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors underline"
|
||||
>
|
||||
Next.js
|
||||
</a>
|
||||
and
|
||||
<a
|
||||
href="https://tailwindcss.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors underline"
|
||||
>
|
||||
Tailwind CSS
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Based on{' '}
|
||||
<a
|
||||
href="https://github.com/sharkdp/pastel"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors underline"
|
||||
>
|
||||
Pastel
|
||||
</a>
|
||||
{' '}by{' '}
|
||||
<a
|
||||
href="https://github.com/sharkdp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors underline"
|
||||
>
|
||||
David Peter
|
||||
</a>
|
||||
</p>
|
||||
<span>•</span>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-ui/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
MIT License
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
77
components/pastel/layout/Navbar.tsx
Normal file
77
components/pastel/layout/Navbar.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Palette } from 'lucide-react';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Home', href: '/pastel' },
|
||||
{ name: 'Playground', href: '/pastel/playground' },
|
||||
{ name: 'Palettes', href: '/pastel/palettes' },
|
||||
{ name: 'Accessibility', href: '/pastel/accessibility' },
|
||||
{ name: 'Named Colors', href: '/pastel/names' },
|
||||
{ name: 'Batch', href: '/pastel/batch' },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center space-x-2 font-bold text-xl">
|
||||
<Palette className="h-6 w-6 text-primary" />
|
||||
<span className="bg-gradient-to-r from-pink-500 via-purple-500 to-blue-500 bg-clip-text text-transparent">
|
||||
Pastel UI
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-1">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
pathname === item.href
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<div className="md:hidden pb-3 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'block px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
pathname === item.href
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
28
components/pastel/layout/ThemeToggle.tsx
Normal file
28
components/pastel/layout/ThemeToggle.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
29
components/pastel/providers/Providers.tsx
Normal file
29
components/pastel/providers/Providers.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<Toaster position="top-right" richColors />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
78
components/pastel/providers/ThemeProvider.tsx
Normal file
78
components/pastel/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('system');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
// Load theme from localStorage
|
||||
const stored = localStorage.getItem('theme') as Theme | null;
|
||||
if (stored) {
|
||||
setTheme(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
// Remove previous theme classes
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
root.classList.add(systemTheme);
|
||||
setResolvedTheme(systemTheme);
|
||||
} else {
|
||||
root.classList.add(theme);
|
||||
setResolvedTheme(theme);
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = () => {
|
||||
if (theme === 'system') {
|
||||
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
|
||||
setResolvedTheme(systemTheme);
|
||||
window.document.documentElement.classList.remove('light', 'dark');
|
||||
window.document.documentElement.classList.add(systemTheme);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
125
components/pastel/tools/ExportMenu.tsx
Normal file
125
components/pastel/tools/ExportMenu.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { useState } from 'react';
|
||||
import { Download, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
exportAsCSS,
|
||||
exportAsSCSS,
|
||||
exportAsTailwind,
|
||||
exportAsJSON,
|
||||
exportAsJavaScript,
|
||||
downloadAsFile,
|
||||
type ExportColor,
|
||||
} from '@/lib/pastel/utils/export';
|
||||
|
||||
interface ExportMenuProps {
|
||||
colors: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript';
|
||||
|
||||
export function ExportMenu({ colors, className }: ExportMenuProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>('css');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const exportColors: ExportColor[] = colors.map((hex) => ({ hex }));
|
||||
|
||||
const getExportContent = (): 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);
|
||||
}
|
||||
};
|
||||
|
||||
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 handleCopy = () => {
|
||||
const content = getExportContent();
|
||||
navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
toast.success('Copied to clipboard!');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const content = getExportContent();
|
||||
const extension = getFileExtension();
|
||||
downloadAsFile(content, `palette.${extension}`, 'text/plain');
|
||||
toast.success('Downloaded!');
|
||||
};
|
||||
|
||||
if (colors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Export Palette</h3>
|
||||
<Select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as ExportFormat)}
|
||||
>
|
||||
<option value="css">CSS Variables</option>
|
||||
<option value="scss">SCSS Variables</option>
|
||||
<option value="tailwind">Tailwind Config</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="javascript">JavaScript Array</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<pre className="text-xs overflow-x-auto">
|
||||
<code>{getExportContent()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCopy} variant="outline" className="flex-1">
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleDownload} variant="default" className="flex-1">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
components/pastel/tools/ManipulationPanel.tsx
Normal file
231
components/pastel/tools/ManipulationPanel.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Slider } from '@/components/ui/Slider';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
useLighten,
|
||||
useDarken,
|
||||
useSaturate,
|
||||
useDesaturate,
|
||||
useRotate,
|
||||
useComplement
|
||||
} from '@/lib/pastel/api/queries';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ManipulationPanelProps {
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export function ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) {
|
||||
const [lightenAmount, setLightenAmount] = useState(0.2);
|
||||
const [darkenAmount, setDarkenAmount] = useState(0.2);
|
||||
const [saturateAmount, setSaturateAmount] = useState(0.2);
|
||||
const [desaturateAmount, setDesaturateAmount] = useState(0.2);
|
||||
const [rotateAmount, setRotateAmount] = useState(30);
|
||||
|
||||
const lightenMutation = useLighten();
|
||||
const darkenMutation = useDarken();
|
||||
const saturateMutation = useSaturate();
|
||||
const desaturateMutation = useDesaturate();
|
||||
const rotateMutation = useRotate();
|
||||
const complementMutation = useComplement();
|
||||
|
||||
const handleLighten = async () => {
|
||||
try {
|
||||
const result = await lightenMutation.mutateAsync({
|
||||
colors: [color],
|
||||
amount: lightenAmount,
|
||||
});
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(`Lightened by ${(lightenAmount * 100).toFixed(0)}%`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to lighten color');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDarken = async () => {
|
||||
try {
|
||||
const result = await darkenMutation.mutateAsync({
|
||||
colors: [color],
|
||||
amount: darkenAmount,
|
||||
});
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(`Darkened by ${(darkenAmount * 100).toFixed(0)}%`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to darken color');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaturate = async () => {
|
||||
try {
|
||||
const result = await saturateMutation.mutateAsync({
|
||||
colors: [color],
|
||||
amount: saturateAmount,
|
||||
});
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(`Saturated by ${(saturateAmount * 100).toFixed(0)}%`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to saturate color');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDesaturate = async () => {
|
||||
try {
|
||||
const result = await desaturateMutation.mutateAsync({
|
||||
colors: [color],
|
||||
amount: desaturateAmount,
|
||||
});
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(`Desaturated by ${(desaturateAmount * 100).toFixed(0)}%`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to desaturate color');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotate = async () => {
|
||||
try {
|
||||
const result = await rotateMutation.mutateAsync({
|
||||
colors: [color],
|
||||
amount: rotateAmount,
|
||||
});
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(`Rotated hue by ${rotateAmount}°`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to rotate hue');
|
||||
}
|
||||
};
|
||||
|
||||
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 (error) {
|
||||
toast.error('Failed to generate complement');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
lightenMutation.isPending ||
|
||||
darkenMutation.isPending ||
|
||||
saturateMutation.isPending ||
|
||||
desaturateMutation.isPending ||
|
||||
rotateMutation.isPending ||
|
||||
complementMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Lighten */}
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
label="Lighten"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={lightenAmount}
|
||||
onChange={(e) => setLightenAmount(parseFloat(e.target.value))}
|
||||
suffix="%"
|
||||
showValue
|
||||
/>
|
||||
<Button onClick={handleLighten} disabled={isLoading} className="w-full">
|
||||
Apply Lighten
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Darken */}
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
label="Darken"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={darkenAmount}
|
||||
onChange={(e) => setDarkenAmount(parseFloat(e.target.value))}
|
||||
suffix="%"
|
||||
showValue
|
||||
/>
|
||||
<Button onClick={handleDarken} disabled={isLoading} className="w-full">
|
||||
Apply Darken
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Saturate */}
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
label="Saturate"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={saturateAmount}
|
||||
onChange={(e) => setSaturateAmount(parseFloat(e.target.value))}
|
||||
suffix="%"
|
||||
showValue
|
||||
/>
|
||||
<Button onClick={handleSaturate} disabled={isLoading} className="w-full">
|
||||
Apply Saturate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Desaturate */}
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
label="Desaturate"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={desaturateAmount}
|
||||
onChange={(e) => setDesaturateAmount(parseFloat(e.target.value))}
|
||||
suffix="%"
|
||||
showValue
|
||||
/>
|
||||
<Button onClick={handleDesaturate} disabled={isLoading} className="w-full">
|
||||
Apply Desaturate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Rotate Hue */}
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
label="Rotate Hue"
|
||||
min={-180}
|
||||
max={180}
|
||||
step={5}
|
||||
value={rotateAmount}
|
||||
onChange={(e) => setRotateAmount(parseInt(e.target.value))}
|
||||
suffix="°"
|
||||
showValue
|
||||
/>
|
||||
<Button onClick={handleRotate} disabled={isLoading} className="w-full">
|
||||
Apply Rotation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="pt-4 border-t space-y-2">
|
||||
<h3 className="text-sm font-medium mb-3">Quick Actions</h3>
|
||||
<Button
|
||||
onClick={handleComplement}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Get Complementary Color
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user