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:
2026-02-22 21:35:53 +01:00
parent ff6bb873eb
commit 2000623c67
540 changed files with 338653 additions and 809 deletions

View 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>
);
}

View 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>
);
}