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

13
app/(app)/figlet/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { FigletConverter } from '@/components/figlet/FigletConverter';
export default function FigletPage() {
return (
<div className="p-4 sm:p-8 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Figlet UI</h1>
<p className="text-muted-foreground italic">ASCII Art Text Generator with 373 Fonts</p>
</div>
<FigletConverter />
</div>
);
}

16
app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { AppShell } from '@/components/layout/AppShell';
import { Providers } from '@/components/providers/Providers';
export default function AppLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<Providers>
<AppShell>
{children}
</AppShell>
</Providers>
);
}

View File

@@ -0,0 +1,214 @@
'use client';
import { useState } from 'react';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { ColorDisplay } from '@/components/pastel/color/ColorDisplay';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { useSimulateColorBlindness } from '@/lib/pastel/api/queries';
import { Loader2, Eye, Plus, X } from 'lucide-react';
import { toast } from 'sonner';
type ColorBlindnessType = 'protanopia' | 'deuteranopia' | 'tritanopia';
export default function ColorBlindPage() {
const [colors, setColors] = useState<string[]>(['#ff0099']);
const [blindnessType, setBlindnessType] = useState<ColorBlindnessType>('protanopia');
const [simulations, setSimulations] = useState<
Array<{ input: string; output: string; difference_percentage: number }>
>([]);
const simulateMutation = useSimulateColorBlindness();
const handleSimulate = async () => {
try {
const result = await simulateMutation.mutateAsync({
colors,
type: blindnessType,
});
setSimulations(result.colors);
toast.success(`Simulated ${blindnessType}`);
} catch (error) {
toast.error('Failed to simulate color blindness');
console.error(error);
}
};
const addColor = () => {
if (colors.length < 10) {
setColors([...colors, '#000000']);
}
};
const removeColor = (index: number) => {
if (colors.length > 1) {
setColors(colors.filter((_, i) => i !== index));
}
};
const updateColor = (index: number, color: string) => {
const newColors = [...colors];
newColors[index] = color;
setColors(newColors);
};
const typeDescriptions: Record<ColorBlindnessType, string> = {
protanopia: 'Red-blind (affects ~1% of males)',
deuteranopia: 'Green-blind (affects ~1% of males)',
tritanopia: 'Blue-blind (rare, affects ~0.001%)',
};
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Color Blindness Simulator</h1>
<p className="text-muted-foreground">
Simulate how colors appear with different types of color blindness
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Controls */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Colors to Test</h2>
<Button
onClick={addColor}
variant="outline"
size="sm"
disabled={colors.length >= 10}
>
<Plus className="h-4 w-4 mr-2" />
Add Color
</Button>
</div>
<div className="space-y-4">
{colors.map((color, index) => (
<div key={index} className="flex items-start gap-3">
<div className="flex-1">
<ColorPicker
color={color}
onChange={(newColor) => updateColor(index, newColor)}
/>
</div>
{colors.length > 1 && (
<Button
variant="ghost"
size="icon"
onClick={() => removeColor(index)}
className="mt-8"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Blindness Type</h2>
<div className="space-y-4">
<Select
label="Type"
value={blindnessType}
onChange={(e) => setBlindnessType(e.target.value as ColorBlindnessType)}
>
<option value="protanopia">Protanopia (Red-blind)</option>
<option value="deuteranopia">Deuteranopia (Green-blind)</option>
<option value="tritanopia">Tritanopia (Blue-blind)</option>
</Select>
<p className="text-sm text-muted-foreground">
{typeDescriptions[blindnessType]}
</p>
<Button
onClick={handleSimulate}
disabled={simulateMutation.isPending || colors.length === 0}
className="w-full"
>
{simulateMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Simulating..
</>
) : (
<>
<Eye className="mr-2 h-4 w-4" />
Simulate
</>
)}
</Button>
</div>
</div>
</div>
{/* Results */}
<div className="space-y-6">
{simulations.length > 0 ? (
<>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Simulation Results</h2>
<p className="text-sm text-muted-foreground mb-6">
Compare original colors (left) with how they appear to people with{' '}
{blindnessType} (right)
</p>
<div className="space-y-4">
{simulations.map((sim, index) => (
<div
key={index}
className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg"
>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Original
</p>
<div className="flex items-center gap-3">
<ColorDisplay color={sim.input} size="md" />
<code className="text-sm font-mono">{sim.input}</code>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
As Seen ({sim.difference_percentage.toFixed(1)}% difference)
</p>
<div className="flex items-center gap-3">
<ColorDisplay color={sim.output} size="md" />
<code className="text-sm font-mono">{sim.output}</code>
</div>
</div>
</div>
))}
</div>
</div>
<div className="p-6 border rounded-lg bg-card bg-blue-50 dark:bg-blue-950/20">
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Eye className="h-5 w-5" />
Accessibility Tip
</h3>
<p className="text-sm text-muted-foreground">
Ensure important information isn't conveyed by color alone. Use text
labels, patterns, or icons to make your design accessible to everyone
</p>
</div>
</>
) : (
<div className="p-12 border rounded-lg bg-card text-center text-muted-foreground">
<Eye className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Add colors and click Simulate to see how they appear</p>
<p className="text-sm mt-2">with different types of color blindness</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useState, useEffect } from 'react';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { getContrastRatio, hexToRgb, checkWCAGCompliance } from '@/lib/pastel/utils/color';
import { ArrowLeftRight, Check, X } from 'lucide-react';
export default function ContrastPage() {
const [foreground, setForeground] = useState('#000000');
const [background, setBackground] = useState('#ffffff');
const [ratio, setRatio] = useState<number | null>(null);
const [compliance, setCompliance] = useState<any>(null);
useEffect(() => {
const fgRgb = hexToRgb(foreground);
const bgRgb = hexToRgb(background);
if (fgRgb && bgRgb) {
const contrastRatio = getContrastRatio(fgRgb, bgRgb);
setRatio(contrastRatio);
setCompliance(checkWCAGCompliance(contrastRatio));
}
}, [foreground, background]);
const swapColors = () => {
const temp = foreground;
setForeground(background);
setBackground(temp);
};
const ComplianceItem = ({
label,
passed,
}: {
label: string;
passed: boolean;
}) => (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<span className="text-sm">{label}</span>
<Badge variant={passed ? 'success' : 'destructive'}>
{passed ? (
<>
<Check className="h-3 w-3 mr-1" />
Pass
</>
) : (
<>
<X className="h-3 w-3 mr-1" />
Fail
</>
)}
</Badge>
</div>
);
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Contrast Checker</h1>
<p className="text-muted-foreground">
Test color combinations for WCAG 2.1 compliance
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Color Pickers */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Foreground Color</h2>
</div>
<ColorPicker color={foreground} onChange={setForeground} />
</div>
<div className="flex justify-center">
<Button
onClick={swapColors}
variant="outline"
size="icon"
className="rounded-full"
>
<ArrowLeftRight className="h-4 w-4" />
</Button>
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Background Color</h2>
<ColorPicker color={background} onChange={setBackground} />
</div>
</div>
{/* Results */}
<div className="space-y-6">
{/* Preview */}
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Preview</h2>
<div
className="rounded-lg p-8 text-center"
style={{ backgroundColor: background, color: foreground }}
>
<p className="text-xl font-bold mb-2">Normal Text (16px)</p>
<p className="text-3xl font-bold">Large Text (24px)</p>
</div>
</div>
{/* Contrast Ratio */}
{ratio !== null && (
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Contrast Ratio</h2>
<div className="text-center mb-6">
<div className="text-5xl font-bold">{ratio.toFixed(2)}:1</div>
<p className="text-sm text-muted-foreground mt-2">
{ratio >= 7
? 'Excellent contrast'
: ratio >= 4.5
? 'Good contrast'
: ratio >= 3
? 'Minimum contrast'
: 'Poor contrast'}
</p>
</div>
</div>
)}
{/* WCAG Compliance */}
{compliance && (
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">WCAG 2.1 Compliance</h2>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2">Level AA</h3>
<div className="space-y-2">
<ComplianceItem
label="Normal Text (4.5:1)"
passed={compliance.aa.normalText}
/>
<ComplianceItem
label="Large Text (3:1)"
passed={compliance.aa.largeText}
/>
<ComplianceItem
label="UI Components (3:1)"
passed={compliance.aa.ui}
/>
</div>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Level AAA</h3>
<div className="space-y-2">
<ComplianceItem
label="Normal Text (7:1)"
passed={compliance.aaa.normalText}
/>
<ComplianceItem
label="Large Text (4.5:1)"
passed={compliance.aaa.largeText}
/>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import Link from 'next/link';
import { Contrast, Eye, Palette } from 'lucide-react';
export default function AccessibilityPage() {
const tools = [
{
title: 'Contrast Checker',
description: 'Test color combinations for WCAG 2.1 AA and AAA compliance',
href: '/pastel/accessibility/contrast',
icon: Contrast,
features: ['WCAG 2.1 standards', 'AA/AAA ratings', 'Live preview'],
},
{
title: 'Color Blindness Simulator',
description: 'Simulate how colors appear with different types of color blindness',
href: '/pastel/accessibility/colorblind',
icon: Eye,
features: ['Protanopia', 'Deuteranopia', 'Tritanopia'],
},
{
title: 'Text Color Optimizer',
description: 'Find the best text color for any background automatically',
href: '/pastel/accessibility/textcolor',
icon: Palette,
features: ['Automatic optimization', 'WCAG guaranteed', 'Light/dark options'],
},
];
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Accessibility Tools</h1>
<p className="text-muted-foreground">
Ensure your colors are accessible to everyone
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tools.map((tool) => {
const Icon = tool.icon;
return (
<Link
key={tool.href}
href={tool.href}
className="p-6 border rounded-lg bg-card hover:border-primary transition-colors"
>
<div className="flex items-start justify-between mb-4">
<Icon className="h-8 w-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-2">{tool.title}</h2>
<p className="text-sm text-muted-foreground mb-4">{tool.description}</p>
<ul className="space-y-1">
{tool.features.map((feature) => (
<li key={feature} className="text-sm text-muted-foreground flex items-center">
<span className="mr-2"></span>
{feature}
</li>
))}
</ul>
</Link>
);
})}
</div>
{/* Educational Content */}
<div className="p-6 border rounded-lg bg-card mt-8">
<h2 className="text-xl font-semibold mb-4">About WCAG 2.1</h2>
<div className="space-y-4 text-sm text-muted-foreground">
<p>
The Web Content Accessibility Guidelines (WCAG) 2.1 provide standards for making web
content more accessible to people with disabilities
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="font-semibold text-foreground mb-2">Level AA (Minimum)</h3>
<ul className="space-y-1">
<li> Normal text: 4.5:1 contrast ratio</li>
<li> Large text: 3:1 contrast ratio</li>
<li> UI components: 3:1 contrast ratio</li>
</ul>
</div>
<div>
<h3 className="font-semibold text-foreground mb-2">Level AAA (Enhanced)</h3>
<ul className="space-y-1">
<li> Normal text: 7:1 contrast ratio</li>
<li> Large text: 4.5:1 contrast ratio</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,221 @@
'use client';
import { useState } from 'react';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { ColorDisplay } from '@/components/pastel/color/ColorDisplay';
import { Button } from '@/components/ui/Button';
import { useTextColor } from '@/lib/pastel/api/queries';
import { Loader2, Palette, Plus, X, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
export default function TextColorPage() {
const [backgrounds, setBackgrounds] = useState<string[]>(['#ff0099']);
const [results, setResults] = useState<
Array<{
background: string;
textcolor: string;
contrast_ratio: number;
wcag_aa: boolean;
wcag_aaa: boolean;
}>
>([]);
const textColorMutation = useTextColor();
const handleOptimize = async () => {
try {
const result = await textColorMutation.mutateAsync({
backgrounds,
});
setResults(result.colors);
toast.success(`Optimized text colors for ${result.colors.length} background(s)`);
} catch (error) {
toast.error('Failed to optimize text colors');
console.error(error);
}
};
const addBackground = () => {
if (backgrounds.length < 10) {
setBackgrounds([...backgrounds, '#000000']);
}
};
const removeBackground = (index: number) => {
if (backgrounds.length > 1) {
setBackgrounds(backgrounds.filter((_, i) => i !== index));
}
};
const updateBackground = (index: number, color: string) => {
const newBackgrounds = [...backgrounds];
newBackgrounds[index] = color;
setBackgrounds(newBackgrounds);
};
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Text Color Optimizer</h1>
<p className="text-muted-foreground">
Automatically find the best text color (black or white) for any background color
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Input */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Background Colors</h2>
<Button
onClick={addBackground}
disabled={backgrounds.length >= 10}
variant="outline"
size="sm"
>
<Plus className="h-4 w-4 mr-2" />
Add
</Button>
</div>
<div className="space-y-4">
{backgrounds.map((color, index) => (
<div key={index} className="flex items-center gap-3">
<div className="flex-1">
<ColorPicker
color={color}
onChange={(newColor) => updateBackground(index, newColor)}
/>
</div>
{backgrounds.length > 1 && (
<Button
onClick={() => removeBackground(index)}
variant="ghost"
size="icon"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
<Button
onClick={handleOptimize}
disabled={textColorMutation.isPending || backgrounds.length === 0}
className="w-full mt-4"
>
{textColorMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Optimizing..
</>
) : (
<>
<Palette className="mr-2 h-4 w-4" />
Optimize Text Colors
</>
)}
</Button>
</div>
<div className="p-6 border rounded-lg bg-card bg-blue-50 dark:bg-blue-950/20">
<h3 className="font-semibold mb-2">How it works</h3>
<p className="text-sm text-muted-foreground">
This tool analyzes each background color and automatically selects either black
or white text to ensure maximum readability. The algorithm guarantees WCAG AA
compliance (4.5:1 contrast ratio) for normal text
</p>
</div>
</div>
{/* Results */}
<div className="space-y-6">
{results.length > 0 ? (
<>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Optimized Results</h2>
<div className="space-y-4">
{results.map((result, index) => (
<div
key={index}
className="p-4 border rounded-lg"
style={{ backgroundColor: result.background }}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<ColorDisplay color={result.background} size="sm" />
<code className="text-sm font-mono text-inherit">
{result.background}
</code>
</div>
</div>
<div
className="p-4 rounded border-2"
style={{
backgroundColor: result.background,
color: result.textcolor,
borderColor: result.textcolor,
}}
>
<p className="font-semibold mb-2" style={{ color: result.textcolor }}>
Sample Text Preview
</p>
<p className="text-sm" style={{ color: result.textcolor }}>
The quick brown fox jumps over the lazy dog. This is how your text
will look on this background color
</p>
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Text Color: </span>
<code className="font-mono">{result.textcolor}</code>
</div>
<div>
<span className="text-muted-foreground">Contrast: </span>
<span className="font-medium">
{result.contrast_ratio.toFixed(2)}:1
</span>
</div>
<div className="flex items-center gap-2">
{result.wcag_aa ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<span className={result.wcag_aa ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
WCAG AA
</span>
</div>
<div className="flex items-center gap-2">
{result.wcag_aaa ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-yellow-500" />
)}
<span className={result.wcag_aaa ? 'text-green-600 dark:text-green-400' : 'text-yellow-600 dark:text-yellow-400'}>
WCAG AAA
</span>
</div>
</div>
</div>
))}
</div>
</div>
</>
) : (
<div className="p-12 border rounded-lg bg-card text-center text-muted-foreground">
<Palette className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Add background colors and click Optimize to see results</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { Input } from '@/components/ui/Input';
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
import { useLighten, useDarken, useSaturate, useDesaturate, useRotate } from '@/lib/pastel/api/queries';
import { Loader2, Upload, Download } from 'lucide-react';
import { toast } from 'sonner';
type Operation = 'lighten' | 'darken' | 'saturate' | 'desaturate' | 'rotate';
export default function BatchPage() {
const [inputColors, setInputColors] = useState('');
const [operation, setOperation] = useState<Operation>('lighten');
const [amount, setAmount] = useState(0.2);
const [outputColors, setOutputColors] = useState<string[]>([]);
const lightenMutation = useLighten();
const darkenMutation = useDarken();
const saturateMutation = useSaturate();
const desaturateMutation = useDesaturate();
const rotateMutation = useRotate();
const parseColors = (text: string): string[] => {
// Parse colors from text (one per line, or comma-separated)
return text
.split(/[\n,]/)
.map((c) => c.trim())
.filter((c) => c.length > 0 && c.match(/^#?[0-9a-fA-F]{3,8}$/));
};
const handleProcess = async () => {
const colors = parseColors(inputColors);
if (colors.length === 0) {
toast.error('No valid colors found');
return;
}
if (colors.length > 100) {
toast.error('Maximum 100 colors allowed');
return;
}
try {
let result;
switch (operation) {
case 'lighten':
result = await lightenMutation.mutateAsync({ colors, amount });
break;
case 'darken':
result = await darkenMutation.mutateAsync({ colors, amount });
break;
case 'saturate':
result = await saturateMutation.mutateAsync({ colors, amount });
break;
case 'desaturate':
result = await desaturateMutation.mutateAsync({ colors, amount });
break;
case 'rotate':
result = await rotateMutation.mutateAsync({ colors, amount: amount * 360 });
break;
}
// Extract output colors from the result
const processed = result.colors.map((c) => c.output);
setOutputColors(processed);
toast.success(`Processed ${processed.length} colors`);
} catch (error) {
toast.error('Failed to process colors');
console.error(error);
}
};
const isPending =
lightenMutation.isPending ||
darkenMutation.isPending ||
saturateMutation.isPending ||
desaturateMutation.isPending ||
rotateMutation.isPending;
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Batch Operations</h1>
<p className="text-muted-foreground">
Process multiple colors at once with manipulation operations
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Input */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Input Colors</h2>
<p className="text-sm text-muted-foreground mb-4">
Enter colors (one per line or comma-separated). Supports hex format
</p>
<textarea
value={inputColors}
onChange={(e) => setInputColors(e.target.value)}
placeholder="#ff0099, #00ff99, #9900ff&#10;#ff5533&#10;#3355ff"
className="w-full h-48 p-3 border rounded-lg bg-background font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-2">
{parseColors(inputColors).length} valid colors found
</p>
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Operation</h2>
<div className="space-y-4">
<Select
label="Operation"
value={operation}
onChange={(e) => setOperation(e.target.value as Operation)}
>
<option value="lighten">Lighten</option>
<option value="darken">Darken</option>
<option value="saturate">Saturate</option>
<option value="desaturate">Desaturate</option>
<option value="rotate">Rotate Hue</option>
</Select>
<div>
<label className="text-sm font-medium mb-2 block">
Amount: {operation === 'rotate' ? (amount * 360).toFixed(0) + '°' : (amount * 100).toFixed(0) + '%'}
</label>
<Input
type="range"
min="0"
max="1"
step="0.01"
value={amount}
onChange={(e) => setAmount(parseFloat(e.target.value))}
/>
</div>
<Button
onClick={handleProcess}
disabled={isPending || parseColors(inputColors).length === 0}
className="w-full"
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing..
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Process Colors
</>
)}
</Button>
</div>
</div>
</div>
{/* Output */}
<div className="space-y-6">
{outputColors.length > 0 ? (
<>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">
Output Colors ({outputColors.length})
</h2>
<PaletteGrid colors={outputColors} />
</div>
<div className="p-6 border rounded-lg bg-card">
<ExportMenu colors={outputColors} />
</div>
</>
) : (
<div className="p-12 border rounded-lg bg-card text-center text-muted-foreground">
<Download className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Enter colors and click Process to see results</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "../components/color/*.{js,ts,jsx,tsx}";
@source "../components/layout/*.{js,ts,jsx,tsx}";
@source "../components/providers/*.{js,ts,jsx,tsx}";
@source "../components/tools/*.{js,ts,jsx,tsx}";
@source "../components/ui/*.{js,ts,jsx,tsx}";
@source "./playground/*.{js,ts,jsx,tsx}";
@source "./palettes/*.{js,ts,jsx,tsx}";
@source "./palettes/distinct/*.{js,ts,jsx,tsx}";
@source "./palettes/gradient/*.{js,ts,jsx,tsx}";
@source "./palettes/harmony/*.{js,ts,jsx,tsx}";
@source "./names/*.{js,ts,jsx,tsx}";
@source "./batch/*.{js,ts,jsx,tsx}";
@source "./accessibility/*.{js,ts,jsx,tsx}";
@source "./accessibility/colorblind/*.{js,ts,jsx,tsx}";
@source "./accessibility/contrast/*.{js,ts,jsx,tsx}";
@source "*.{js,ts,jsx,tsx}";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.5rem;
/* Light Mode Colors - Using OKLCH for better color precision */
--background: oklch(100% 0 0);
--foreground: oklch(9.8% 0.038 285.8);
--card: oklch(100% 0 0);
--card-foreground: oklch(9.8% 0.038 285.8);
--popover: oklch(100% 0 0);
--popover-foreground: oklch(9.8% 0.038 285.8);
--primary: oklch(22.4% 0.053 285.8);
--primary-foreground: oklch(98% 0.016 240);
--secondary: oklch(96.1% 0.016 240);
--secondary-foreground: oklch(22.4% 0.053 285.8);
--muted: oklch(96.1% 0.016 240);
--muted-foreground: oklch(46.9% 0.025 244.1);
--accent: oklch(96.1% 0.016 240);
--accent-foreground: oklch(22.4% 0.053 285.8);
--destructive: oklch(60.2% 0.168 29.2);
--destructive-foreground: oklch(98% 0.016 240);
--border: oklch(91.4% 0.026 243.1);
--input: oklch(91.4% 0.026 243.1);
--ring: oklch(9.8% 0.038 285.8);
}
@theme inline {
/* Tailwind v4 theme color definitions */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Custom Animations */
--animate-fade-in: fadeIn 0.3s ease-in-out;
--animate-slide-up: slideUp 0.4s ease-out;
--animate-slide-down: slideDown 0.4s ease-out;
--animate-slide-in-right: slideInRight 0.3s ease-out;
--animate-slide-in-left: slideInLeft 0.3s ease-out;
--animate-scale-in: scaleIn 0.2s ease-out;
--animate-bounce-gentle: bounceGentle 0.5s ease-in-out;
--animate-shimmer: shimmer 2s infinite;
}
.dark {
--background: oklch(9.8% 0.038 285.8);
--foreground: oklch(98% 0.016 240);
--card: oklch(9.8% 0.038 285.8);
--card-foreground: oklch(98% 0.016 240);
--popover: oklch(9.8% 0.038 285.8);
--popover-foreground: oklch(98% 0.016 240);
--primary: oklch(98% 0.016 240);
--primary-foreground: oklch(22.4% 0.053 285.8);
--secondary: oklch(17.5% 0.036 242.3);
--secondary-foreground: oklch(98% 0.016 240);
--muted: oklch(17.5% 0.036 242.3);
--muted-foreground: oklch(65.1% 0.031 244);
--accent: oklch(17.5% 0.036 242.3);
--accent-foreground: oklch(98% 0.016 240);
--destructive: oklch(30.6% 0.125 29.2);
--destructive-foreground: oklch(98% 0.016 240);
--border: oklch(17.5% 0.036 242.3);
--input: oklch(17.5% 0.036 242.3);
--ring: oklch(83.9% 0.031 243.7);
}
@layer base {
* {
@apply border-border outline-ring/50;
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
body {
@apply bg-background text-foreground;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
html {
scroll-behavior: smooth;
}
/* Disable transitions during theme switch to prevent flash */
.theme-transitioning * {
transition: none !important;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
@apply bg-background;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/20 rounded-lg hover:bg-muted-foreground/30;
}
/* Screen reader only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
}
/* Animation Keyframes */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideInRight {
from { transform: translateX(-20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideInLeft {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes bounceGentle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@keyframes shimmer {
from { background-position: -1000px 0; }
to { background-position: 1000px 0; }
}

View File

@@ -0,0 +1,11 @@
export default function PastelLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="p-4 sm:p-8 max-w-7xl mx-auto">
{children}
</div>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import { useState, useMemo } from 'react';
import { ColorSwatch } from '@/components/pastel/color/ColorSwatch';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { useNamedColors } from '@/lib/pastel/api/queries';
import { Loader2 } from 'lucide-react';
export default function NamedColorsPage() {
const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState<'name' | 'hue'>('name');
const { data, isLoading, isError } = useNamedColors();
const filteredColors = useMemo(() => {
if (!data) return [];
let colors = data.colors.filter(
(color) =>
color.name.toLowerCase().includes(search.toLowerCase()) ||
color.hex.toLowerCase().includes(search.toLowerCase())
);
if (sortBy === 'name') {
colors.sort((a, b) => a.name.localeCompare(b.name));
}
// For hue sorting, we'd need to convert to HSL which requires the API
// For now, just keep alphabetical as default
return colors;
}, [data, search, sortBy]);
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Named Colors</h1>
<p className="text-muted-foreground">
Explore 148 CSS/X11 named colors
</p>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
type="text"
placeholder="Search by name or hex..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="w-full sm:w-48">
<Select value={sortBy} onChange={(e) => setSortBy(e.target.value as 'name' | 'hue')}>
<option value="name">Sort by Name</option>
<option value="hue">Sort by Hue</option>
</Select>
</div>
</div>
{/* Colors Grid */}
<div className="p-6 border rounded-lg bg-card">
{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="text-center py-12 text-destructive">
Failed to load named colors
</div>
)}
{filteredColors.length > 0 && (
<>
<div className="mb-4 text-sm text-muted-foreground">
Showing {filteredColors.length} colors
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
{filteredColors.map((color) => (
<div key={color.name} className="flex flex-col items-center gap-2">
<ColorSwatch color={color.hex} showLabel={false} />
<div className="text-center">
<div className="text-sm font-medium">{color.name}</div>
<div className="text-xs font-mono text-muted-foreground">
{color.hex}
</div>
</div>
</div>
))}
</div>
</>
)}
{filteredColors.length === 0 && !isLoading && !isError && (
<div className="text-center py-12 text-muted-foreground">
No colors match your search
</div>
)}
</div>
</div>
</div>
);
}

218
app/(app)/pastel/page.tsx Normal file
View File

@@ -0,0 +1,218 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { ColorDisplay } from '@/components/pastel/color/ColorDisplay';
import { ColorInfo } from '@/components/pastel/color/ColorInfo';
import { ManipulationPanel } from '@/components/pastel/tools/ManipulationPanel';
import { useColorInfo } from '@/lib/pastel/api/queries';
import { useKeyboard } from '@/lib/pastel/hooks/useKeyboard';
import { useColorHistory } from '@/lib/pastel/stores/historyStore';
import { Loader2, Share2, History, X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { toast } from 'sonner';
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';
});
const { data, isLoading, isError, error } = useColorInfo({
colors: [color],
});
const colorInfo = data?.colors[0];
// Color history
const { history, addColor, removeColor, clearHistory, getRecent } = useColorHistory();
const recentColors = getRecent(10);
// Update URL and history when color changes
useEffect(() => {
const hex = color.replace('#', '');
router.push(`/pastel?color=${hex}`, { scroll: false });
addColor(color);
}, [color, router, addColor]);
// 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!');
};
// Copy color to clipboard
const handleCopyColor = () => {
navigator.clipboard.writeText(color);
toast.success('Color copied to clipboard!');
};
// Random color generation
const handleRandomColor = () => {
const randomHex = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
setColor(randomHex);
};
// Keyboard shortcuts
useKeyboard([
{
key: 'c',
meta: true,
handler: handleCopyColor,
description: 'Copy color',
},
{
key: 's',
meta: true,
handler: handleShare,
description: 'Share color',
},
{
key: 'r',
meta: true,
handler: handleRandomColor,
description: 'Random color',
},
]);
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Color Playground</h1>
<p className="text-muted-foreground">
Interactive color manipulation and analysis tool
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<kbd className="px-2 py-1 bg-muted rounded border">C</kbd>
<span>Copy</span>
<kbd className="px-2 py-1 bg-muted rounded border ml-3">S</kbd>
<span>Share</span>
<kbd className="px-2 py-1 bg-muted rounded border ml-3">R</kbd>
<span>Random</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column: Color Picker and Display */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Color Picker</h2>
<ColorPicker color={color} onChange={setColor} />
</div>
<div className="p-6 border rounded-lg bg-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Preview</h2>
<Button onClick={handleShare} variant="outline" size="sm">
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
</div>
<div className="flex justify-center">
<ColorDisplay color={color} size="xl" />
</div>
</div>
{recentColors.length > 0 && (
<div className="p-6 border rounded-lg bg-card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<History className="h-5 w-5" />
<h2 className="text-xl font-semibold">Recent Colors</h2>
</div>
<Button
onClick={clearHistory}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
>
Clear
</Button>
</div>
<div className="grid grid-cols-5 gap-2">
{recentColors.map((entry) => (
<div
key={entry.timestamp}
className="group relative aspect-square rounded-lg border-2 border-border hover:border-primary transition-all hover:scale-110 cursor-pointer"
style={{ backgroundColor: entry.color }}
onClick={() => setColor(entry.color)}
title={entry.color}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
setColor(entry.color);
}
}}
>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/30 rounded-lg">
<button
onClick={(e) => {
e.stopPropagation();
removeColor(entry.color);
toast.success('Color removed from history');
}}
className="p-1 bg-destructive rounded-full hover:bg-destructive/80"
aria-label="Remove color"
>
<X className="h-3 w-3 text-destructive-foreground" />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Right Column: Color Information */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Color Information</h2>
{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 className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Color Manipulation</h2>
<ManipulationPanel color={color} onColorChange={setColor} />
</div>
</div>
</div>
</div>
</div>
);
}
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>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { useGenerateDistinct } from '@/lib/pastel/api/queries';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function DistinctPage() {
const [count, setCount] = useState(8);
const [metric, setMetric] = useState<'cie76' | 'ciede2000'>('ciede2000');
const [colors, setColors] = useState<string[]>([]);
const [stats, setStats] = useState<{
min_distance: number;
avg_distance: number;
generation_time_ms: number;
} | null>(null);
const generateMutation = useGenerateDistinct();
const handleGenerate = async () => {
try {
const result = await generateMutation.mutateAsync({
count,
metric,
});
setColors(result.colors);
setStats(result.stats);
toast.success(`Generated ${result.colors.length} distinct colors`);
} catch (error) {
toast.error('Failed to generate distinct colors');
}
};
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Distinct Colors Generator</h1>
<p className="text-muted-foreground">
Generate visually distinct colors using simulated annealing
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Controls */}
<div className="lg:col-span-1">
<div className="p-6 border rounded-lg bg-card space-y-6">
<div>
<h2 className="text-xl font-semibold mb-4">Settings</h2>
</div>
<div>
<label htmlFor="count" className="text-sm font-medium mb-2 block">
Number of Colors
</label>
<Input
id="count"
type="number"
min={2}
max={100}
value={count}
onChange={(e) => setCount(parseInt(e.target.value))}
/>
<p className="text-xs text-muted-foreground mt-1">
Higher counts take longer to generate
</p>
</div>
<Select
label="Distance Metric"
value={metric}
onChange={(e) => setMetric(e.target.value as 'cie76' | 'ciede2000')}
>
<option value="cie76">CIE76 (Faster)</option>
<option value="ciede2000">CIEDE2000 (More Accurate)</option>
</Select>
<Button
onClick={handleGenerate}
disabled={generateMutation.isPending}
className="w-full"
>
{generateMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating..
</>
) : (
'Generate Colors'
)}
</Button>
{generateMutation.isPending && (
<div className="text-sm text-muted-foreground text-center">
This may take a few moments..
</div>
)}
{stats && (
<div className="pt-4 border-t space-y-2">
<h3 className="font-semibold text-sm">Statistics</h3>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Min Distance:</span>
<span className="font-mono">{stats.min_distance.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Avg Distance:</span>
<span className="font-mono">{stats.avg_distance.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Generation Time:</span>
<span className="font-mono">
{(stats.generation_time_ms / 1000).toFixed(2)}s
</span>
</div>
</div>
</div>
)}
</div>
</div>
{/* Results */}
<div className="lg:col-span-2 space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">
Generated Colors {colors.length > 0 && `(${colors.length})`}
</h2>
<PaletteGrid colors={colors} />
</div>
{colors.length > 0 && (
<div className="p-6 border rounded-lg bg-card">
<ExportMenu colors={colors} />
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useState } from 'react';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { useGenerateGradient } from '@/lib/pastel/api/queries';
import { Loader2, Plus, X } from 'lucide-react';
import { toast } from 'sonner';
export default function GradientPage() {
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
const [count, setCount] = useState(10);
const [colorspace, setColorspace] = useState<
'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch'
>('lch');
const [gradient, setGradient] = useState<string[]>([]);
const generateMutation = useGenerateGradient();
const handleGenerate = async () => {
try {
const result = await generateMutation.mutateAsync({
stops,
count,
colorspace,
});
setGradient(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 (stops.length > 2) {
setStops(stops.filter((_, i) => i !== index));
}
};
const updateStop = (index: number, color: string) => {
const newStops = [...stops];
newStops[index] = color;
setStops(newStops);
};
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Gradient Creator</h1>
<p className="text-muted-foreground">
Create smooth color gradients with multiple stops
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Controls */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Color Stops</h2>
<div className="space-y-4">
{stops.map((stop, index) => (
<div key={index} className="flex items-start gap-3">
<div className="flex-1">
<ColorPicker
color={stop}
onChange={(color) => updateStop(index, color)}
/>
</div>
{stops.length > 2 && (
<Button
variant="ghost"
size="icon"
onClick={() => removeStop(index)}
className="mt-8"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button onClick={addStop} variant="outline" className="w-full">
<Plus className="h-4 w-4 mr-2" />
Add Stop
</Button>
</div>
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Settings</h2>
<div className="space-y-4">
<div>
<label htmlFor="count" className="text-sm font-medium mb-2 block">
Number of Colors
</label>
<Input
id="count"
type="number"
min={2}
max={1000}
value={count}
onChange={(e) => setCount(parseInt(e.target.value))}
/>
</div>
<Select
label="Color Space"
value={colorspace}
onChange={(e) =>
setColorspace(
e.target.value as
| 'rgb'
| 'hsl'
| 'hsv'
| 'lab'
| 'oklab'
| 'lch'
| 'oklch'
)
}
>
<option value="rgb">RGB</option>
<option value="hsl">HSL</option>
<option value="hsv">HSV</option>
<option value="lab">Lab</option>
<option value="oklab">OkLab</option>
<option value="lch">LCH</option>
<option value="oklch">OkLCH (Recommended)</option>
</Select>
<Button
onClick={handleGenerate}
disabled={generateMutation.isPending || stops.length < 2}
className="w-full"
>
{generateMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating..
</>
) : (
'Generate Gradient'
)}
</Button>
</div>
</div>
</div>
{/* Preview */}
<div className="space-y-6">
{gradient && gradient.length > 0 && (
<>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Gradient Preview</h2>
<div
className="h-32 rounded-lg"
style={{
background: `linear-gradient(to right, ${gradient.join(', ')})`,
}}
/>
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">
Colors ({gradient.length})
</h2>
<PaletteGrid colors={gradient} />
</div>
<div className="p-6 border rounded-lg bg-card">
<ExportMenu colors={gradient} />
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import { useState } from 'react';
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { useGeneratePalette } from '@/lib/pastel/api/queries';
import { Loader2, Palette } from 'lucide-react';
import { toast } from 'sonner';
type HarmonyType =
| 'monochromatic'
| 'analogous'
| 'complementary'
| 'split-complementary'
| 'triadic'
| 'tetradic';
export default function HarmonyPage() {
const [baseColor, setBaseColor] = useState('#ff0099');
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
const [palette, setPalette] = useState<string[]>([]);
const paletteMutation = useGeneratePalette();
const generateHarmony = async () => {
try {
const result = await paletteMutation.mutateAsync({
base: baseColor,
scheme: harmonyType,
});
// Combine primary and secondary colors into flat array
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 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°)',
'split-complementary': 'Base color + two colors flanking its complement',
triadic: 'Three colors evenly spaced on the color wheel (120°)',
tetradic: 'Four colors evenly spaced on the color wheel (90°)',
};
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Harmony Palette Generator</h1>
<p className="text-muted-foreground">
Create color harmonies based on color theory principles
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Controls */}
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Base Color</h2>
<ColorPicker color={baseColor} onChange={setBaseColor} />
</div>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">Harmony Type</h2>
<div className="space-y-4">
<Select
label="Harmony"
value={harmonyType}
onChange={(e) => setHarmonyType(e.target.value as HarmonyType)}
>
<option value="monochromatic">Monochromatic</option>
<option value="analogous">Analogous</option>
<option value="complementary">Complementary</option>
<option value="split-complementary">Split-Complementary</option>
<option value="triadic">Triadic</option>
<option value="tetradic">Tetradic (Square)</option>
</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>
</div>
</div>
</div>
{/* Results */}
<div className="space-y-6">
{palette.length > 0 && (
<>
<div className="p-6 border rounded-lg bg-card">
<h2 className="text-xl font-semibold mb-4">
Generated Palette ({palette.length} colors)
</h2>
<PaletteGrid colors={palette} />
</div>
<div className="p-6 border rounded-lg bg-card">
<ExportMenu colors={palette} />
</div>
</>
)}
{palette.length === 0 && (
<div className="p-12 border rounded-lg bg-card 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</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import Link from 'next/link';
import { Palette, Sparkles, GraduationCap } from 'lucide-react';
export default function PalettesPage() {
const paletteTypes = [
{
title: 'Gradient Creator',
description: 'Create smooth color gradients with multiple stops and color spaces',
href: '/pastel/palettes/gradient',
icon: GraduationCap,
features: ['Multiple color stops', 'Various color spaces', 'Live preview'],
},
{
title: 'Distinct Colors',
description: 'Generate visually distinct colors using simulated annealing algorithm',
href: '/pastel/palettes/distinct',
icon: Sparkles,
features: ['Perceptual distance', 'Configurable count', 'Quality metrics'],
},
{
title: 'Harmony Palettes',
description: 'Create color palettes based on color theory and harmony rules',
href: '/pastel/palettes/harmony',
icon: Palette,
features: ['Color theory', 'Multiple schemes', 'Instant generation'],
},
];
return (
<div className="min-h-screen py-12">
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div>
<h1 className="text-4xl font-bold mb-2">Palette Generation</h1>
<p className="text-muted-foreground">
Create beautiful color palettes using various generation methods
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{paletteTypes.map((type) => {
const Icon = type.icon;
return (
<Link
key={type.href}
href={type.href}
className="p-6 border rounded-lg bg-card hover:border-primary transition-colors"
>
<div className="flex items-start justify-between mb-4">
<Icon className="h-8 w-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-2">{type.title}</h2>
<p className="text-sm text-muted-foreground mb-4">{type.description}</p>
<ul className="space-y-1">
{type.features.map((feature) => (
<li key={feature} className="text-sm text-muted-foreground flex items-center">
<span className="mr-2"></span>
{feature}
</li>
))}
</ul>
</Link>
);
})}
</div>
</div>
</div>
);
}

13
app/(app)/units/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import MainConverter from '@/components/units/converter/MainConverter';
export default function UnitsPage() {
return (
<div className="p-4 sm:p-8 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Units Converter</h1>
<p className="text-muted-foreground italic">Smart unit converter with 187 units across 23 categories</p>
</div>
<MainConverter />
</div>
);
}

29
app/api/fonts/route.ts Normal file
View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export const dynamic = 'force-static';
export async function GET() {
try {
const fontsDir = path.join(process.cwd(), 'public/fonts/figlet-fonts');
const files = fs.readdirSync(fontsDir);
const fonts = files
.filter(file => file.endsWith('.flf'))
.map(file => {
const name = file.replace('.flf', '');
return {
name,
fileName: file,
path: `/fonts/figlet-fonts/${file}`,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json(fonts);
} catch (error) {
console.error('Error reading fonts directory:', error);
return NextResponse.json({ error: 'Failed to load fonts' }, { status: 500 });
}
}

View File

@@ -5,16 +5,63 @@
@source "*.{js,ts,jsx,tsx}"; @source "*.{js,ts,jsx,tsx}";
@theme { @theme {
--color-background: #0a0a0f; --color-background: var(--background);
--color-foreground: #ffffff; --color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
/* Category colors for 23 unit types */
--color-category-angle: oklch(69.2% 0.154 237.7);
--color-category-apparent-power: oklch(64.8% 0.190 293.6);
--color-category-area: oklch(75.8% 0.159 70.5);
--color-category-current: oklch(75.8% 0.159 70.5);
--color-category-digital: oklch(72.3% 0.134 216.8);
--color-category-each: oklch(52.5% 0.033 257.5);
--color-category-energy: oklch(80.3% 0.162 97.3);
--color-category-frequency: oklch(66.8% 0.238 301.6);
--color-category-illuminance: oklch(78.3% 0.184 128.6);
--color-category-length: oklch(62.1% 0.214 255.5);
--color-category-mass: oklch(72.4% 0.159 165.1);
--color-category-pace: oklch(71.5% 0.145 192.2);
--color-category-parts-per: oklch(69.4% 0.224 350.3);
--color-category-power: oklch(62.8% 0.230 16.6);
--color-category-pressure: oklch(61.3% 0.218 281.3);
--color-category-reactive-energy: oklch(67.5% 0.276 320.6);
--color-category-reactive-power: oklch(74.5% 0.233 316.8);
--color-category-speed: oklch(72.4% 0.159 165.1);
--color-category-temperature: oklch(62.8% 0.257 29.2);
--color-category-tempo: oklch(70% 0.18 30);
--color-category-time: oklch(58.5% 0.238 293.1);
--color-category-voltage: oklch(75.5% 0.159 55.3);
--color-category-volume: oklch(64.8% 0.190 293.6);
--color-category-volume-flow-rate: oklch(77.9% 0.162 208.8);
/* Custom animations */ /* Custom animations */
--animate-gradient: gradient 8s linear infinite; --animate-gradient: gradient 8s linear infinite;
--animate-float: float 3s ease-in-out infinite; --animate-float: float 3s ease-in-out infinite;
--animate-glow: glow 2s ease-in-out infinite alternate; --animate-glow: glow 2s ease-in-out infinite alternate;
--animate-fade-in: fadeIn 0.3s ease-in-out;
--animate-slide-up: slideUp 0.4s ease-out;
--animate-slide-down: slideDown 0.4s ease-out;
--animate-scale-in: scaleIn 0.2s ease-out;
@keyframes gradient { @keyframes gradient {
0%, 100% { 0%, 100% {
@@ -40,20 +87,91 @@
box-shadow: 0 0 30px rgba(139, 92, 246, 0.6), 0 0 40px rgba(139, 92, 246, 0.3); box-shadow: 0 0 30px rgba(139, 92, 246, 0.6), 0 0 40px rgba(139, 92, 246, 0.3);
} }
} }
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
}
:root {
/* CORPORATE DARK THEME (The Standard) */
--background: #0a0a0f;
--foreground: #ffffff;
--card: rgba(255, 255, 255, 0.03);
--card-foreground: #ffffff;
--popover: #0f0f15;
--popover-foreground: #ffffff;
--primary: #8b5cf6;
--primary-foreground: #ffffff;
--secondary: rgba(255, 255, 255, 0.05);
--secondary-foreground: #ffffff;
--muted: rgba(255, 255, 255, 0.05);
--muted-foreground: #a1a1aa;
--accent: rgba(255, 255, 255, 0.08);
--accent-foreground: #ffffff;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: rgba(255, 255, 255, 0.08);
--input: rgba(255, 255, 255, 0.05);
--ring: rgba(139, 92, 246, 0.5);
--radius: 1rem;
}
.light {
/* LIGHT ADAPTATION (Keeping the "Glass" look) */
--background: oklch(98% 0.005 255);
--foreground: oklch(20% 0.04 255);
--card: rgba(255, 255, 255, 0.4);
--card-foreground: oklch(20% 0.04 255);
--popover: oklch(100% 0 0);
--popover-foreground: oklch(20% 0.04 255);
--primary: oklch(55% 0.22 270);
--primary-foreground: oklch(100% 0 0);
--secondary: rgba(0, 0, 0, 0.02);
--secondary-foreground: oklch(20% 0.04 255);
--muted: rgba(0, 0, 0, 0.02);
--muted-foreground: oklch(45% 0.04 255);
--accent: rgba(0, 0, 0, 0.03);
--accent-foreground: oklch(15% 0.05 255);
--destructive: oklch(60% 0.2 25);
--destructive-foreground: oklch(100% 0 0);
--border: rgba(0, 0, 0, 0.06);
--input: rgba(0, 0, 0, 0.01);
--ring: rgba(139, 92, 246, 0.2);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
} }
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
body {
color: var(--color-foreground);
background: var(--color-background);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
html { html {
scroll-behavior: auto; scroll-behavior: auto;
@@ -73,10 +191,10 @@ body {
} }
@utility glass { @utility glass {
background: rgba(255, 255, 255, 0.05); background: var(--card);
backdrop-filter: blur(10px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--border);
} }
@utility gradient-purple-blue { @utility gradient-purple-blue {
@@ -94,3 +212,7 @@ body {
@utility gradient-yellow-amber { @utility gradient-yellow-amber {
background: linear-gradient(135deg, #eab308 0%, #f59e0b 100%); background: linear-gradient(135deg, #eab308 0%, #f59e0b 100%);
} }
@utility gradient-brand {
background: linear-gradient(to right, #a78bfa, #f472b6, #22d3ee);
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import './globals.css'; import './globals.css';
import { Providers } from '@/components/providers/Providers';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Kit - Your Creative Toolkit', title: 'Kit - Your Creative Toolkit',
@@ -50,7 +51,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" className="dark">
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://kit.pivoine.art" /> <link rel="preconnect" href="https://kit.pivoine.art" />

View File

@@ -2,10 +2,10 @@
export default function AnimatedBackground() { export default function AnimatedBackground() {
return ( return (
<div className="fixed inset-0 -z-10 overflow-hidden"> <div className="fixed inset-0 -z-10 overflow-hidden bg-background transition-colors duration-500">
{/* Animated gradient background */} {/* Animated gradient background */}
<div <div
className="absolute inset-0 opacity-50" className="absolute inset-0 opacity-[0.08] dark:opacity-50"
style={{ style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #667eea 100%)', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #667eea 100%)',
backgroundSize: '400% 400%', backgroundSize: '400% 400%',
@@ -13,9 +13,9 @@ export default function AnimatedBackground() {
}} }}
/> />
{/* Grid pattern overlay */} {/* Signature Grid pattern overlay - Original landing page specification */}
<div <div
className="absolute inset-0 opacity-10" className="absolute inset-0 opacity-[0.05] dark:opacity-10"
style={{ style={{
backgroundImage: ` backgroundImage: `
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px), linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
@@ -26,9 +26,9 @@ export default function AnimatedBackground() {
/> />
{/* Floating orbs */} {/* Floating orbs */}
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-float" /> <div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark:opacity-20 animate-float" />
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-float" style={{ animationDelay: '2s' }} /> <div className="absolute top-1/3 right-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark:opacity-20 animate-float" style={{ animationDelay: '2s' }} />
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-float" style={{ animationDelay: '4s' }} /> <div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark:opacity-20 animate-float" style={{ animationDelay: '4s' }} />
</div> </div>
); );
} }

View File

@@ -7,7 +7,7 @@ export default function Footer() {
return ( return (
<footer className="relative py-12 px-4"> <footer className="relative py-12 px-4">
<div className="max-w-6xl mx-auto border-t border-gray-600 pt-12"> <div className="max-w-6xl mx-auto border-t border-border pt-12">
<motion.div <motion.div
className="flex flex-col md:flex-row items-center justify-between gap-6" className="flex flex-col md:flex-row items-center justify-between gap-6"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -16,16 +16,16 @@ export default function Footer() {
transition={{ duration: 0.6 }} transition={{ duration: 0.6 }}
> >
{/* Brand Section */} {/* Brand Section */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-purple-400"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-primary/50 bg-primary/5">
<span className="text-base font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">Kit</span> <span className="text-base font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">Kit</span>
<span className="text-base text-gray-600"></span> <span className="text-base text-muted-foreground/30"></span>
<span className="text-base text-purple-400">Open Source</span> <span className="text-base text-primary font-medium">Open Source</span>
</div> </div>
{/* Copyright - centered */} {/* Copyright - centered */}
<div className="text-center"> <div className="text-center">
<p className="text-base text-gray-500"> <p className="text-sm text-muted-foreground">
© {currentYear} Kit. Built with Next.js 16 & Tailwind CSS 4. © {currentYear} Kit. Built with Next.js 16 & Tailwind CSS 4
</p> </p>
</div> </div>
@@ -34,15 +34,15 @@ export default function Footer() {
href="https://dev.pivoine.art/valknar/kit-ui" href="https://dev.pivoine.art/valknar/kit-ui"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="group flex items-center gap-3 px-4 py-2 rounded-full border border-gray-700 hover:border-purple-400 transition-all duration-300" className="group flex items-center gap-3 px-4 py-2 rounded-full border border-border hover:border-primary transition-all duration-300 bg-card/50"
> >
<svg className="w-5 h-5 text-gray-400 group-hover:text-purple-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}> <svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<line x1="6" y1="3" x2="6" y2="15" strokeLinecap="round" /> <line x1="6" y1="3" x2="6" y2="15" strokeLinecap="round" />
<circle cx="18" cy="6" r="3" /> <circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" /> <circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 01-9 9" strokeLinecap="round" /> <path d="M18 9a9 9 0 01-9 9" strokeLinecap="round" />
</svg> </svg>
<span className="text-base text-gray-300 group-hover:text-purple-400 transition-colors font-medium"> <span className="text-sm text-muted-foreground group-hover:text-primary transition-colors font-medium">
View on Dev View on Dev
</span> </span>
</a> </a>

View File

@@ -2,6 +2,9 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Logo from './Logo'; import Logo from './Logo';
import Link from 'next/link';
const MotionLink = motion.create(Link);
export default function Hero() { export default function Hero() {
return ( return (
@@ -29,7 +32,7 @@ export default function Hero() {
{/* Subtitle */} {/* Subtitle */}
<motion.p <motion.p
className="text-xl md:text-2xl text-gray-300 mb-4 max-w-2xl mx-auto" className="text-xl md:text-2xl text-muted-foreground mb-4 max-w-2xl mx-auto"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }} transition={{ duration: 0.8, delay: 0.4 }}
@@ -39,13 +42,13 @@ export default function Hero() {
{/* Description */} {/* Description */}
<motion.p <motion.p
className="text-base md:text-lg text-gray-400 mb-12 max-w-xl mx-auto" className="text-base md:text-lg text-muted-foreground/80 mb-12 max-w-xl mx-auto"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.6 }} transition={{ duration: 0.8, delay: 0.6 }}
> >
A curated collection of creative and utility tools for developers and creators. A curated collection of creative and utility tools for developers and creators
Simple, powerful, and always at your fingertips. Simple, powerful, and always at your fingertips
</motion.p> </motion.p>
{/* CTA Buttons */} {/* CTA Buttons */}
@@ -55,7 +58,7 @@ export default function Hero() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.8 }} transition={{ duration: 0.8, delay: 0.8 }}
> >
<motion.a <MotionLink
href="#tools" href="#tools"
className="group relative px-8 py-4 rounded-full bg-gradient-to-r from-purple-500 to-cyan-500 text-white font-semibold shadow-lg overflow-hidden" className="group relative px-8 py-4 rounded-full bg-gradient-to-r from-purple-500 to-cyan-500 text-white font-semibold shadow-lg overflow-hidden"
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
@@ -68,7 +71,7 @@ export default function Hero() {
whileHover={{ x: 0 }} whileHover={{ x: 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
/> />
</motion.a> </MotionLink>
<motion.a <motion.a
href="https://dev.pivoine.art/valknar/kit-ui" href="https://dev.pivoine.art/valknar/kit-ui"
@@ -89,7 +92,7 @@ export default function Hero() {
</motion.div> </motion.div>
{/* Scroll indicator */} {/* Scroll indicator */}
<motion.a <MotionLink
href="#tools" href="#tools"
className="flex flex-col items-center gap-2 cursor-pointer group" className="flex flex-col items-center gap-2 cursor-pointer group"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -104,7 +107,7 @@ export default function Hero() {
> >
<div className="w-1 h-2 bg-gradient-to-b from-purple-400 to-cyan-400 rounded-full mx-auto" /> <div className="w-1 h-2 bg-gradient-to-b from-purple-400 to-cyan-400 rounded-full mx-auto" />
</motion.div> </motion.div>
</motion.a> </MotionLink>
</div> </div>
</section> </section>
); );

View File

@@ -48,7 +48,7 @@ export default function Stats() {
whileHover={{ y: -5 }} whileHover={{ y: -5 }}
> >
<motion.div <motion.div
className="inline-flex items-center justify-center w-12 h-12 mb-4 rounded-xl bg-gradient-to-br from-purple-500/20 to-cyan-500/20 text-purple-400" className="inline-flex items-center justify-center w-12 h-12 mb-4 rounded-xl bg-primary/10 text-primary"
whileHover={{ scale: 1.1, rotate: 5 }} whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ type: 'spring', stiffness: 300 }} transition={{ type: 'spring', stiffness: 300 }}
> >
@@ -57,7 +57,7 @@ export default function Stats() {
<div className="text-4xl font-bold mb-2 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400"> <div className="text-4xl font-bold mb-2 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
{stat.number} {stat.number}
</div> </div>
<div className="text-gray-400 text-base font-medium"> <div className="text-muted-foreground text-base font-medium">
{stat.label} {stat.label}
</div> </div>
</motion.div> </motion.div>

View File

@@ -2,6 +2,9 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import Link from 'next/link';
const MotionLink = motion.create(Link);
interface ToolCardProps { interface ToolCardProps {
title: string; title: string;
@@ -16,10 +19,8 @@ interface ToolCardProps {
export default function ToolCard({ title, description, icon, url, gradient, accentColor, index, badges }: ToolCardProps) { export default function ToolCard({ title, description, icon, url, gradient, accentColor, index, badges }: ToolCardProps) {
return ( return (
<motion.a <MotionLink
href={url} href={url}
target="_blank"
rel="noopener noreferrer"
className="group relative block" className="group relative block"
initial={{ opacity: 0, y: 50 }} initial={{ opacity: 0, y: 50 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@@ -27,15 +28,15 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -10 }} whileHover={{ y: -10 }}
> >
<div className="glass relative overflow-hidden rounded-2xl p-8 h-full transition-all duration-300 group-hover:shadow-2xl"> <div className="glass relative overflow-hidden rounded-2xl p-8 h-full transition-all duration-300 group-hover:shadow-2xl group-hover:bg-card/80">
{/* Gradient overlay on hover */} {/* Gradient overlay on hover */}
<div <div
className={`absolute inset-0 opacity-0 group-hover:opacity-15 transition-opacity duration-300 ${gradient}`} className={`absolute inset-0 opacity-0 group-hover:opacity-10 dark:group-hover:opacity-15 transition-opacity duration-300 ${gradient}`}
/> />
{/* Glow effect */} {/* Glow effect */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl -z-10"> <div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl -z-10">
<div className={`w-full h-full ${gradient} opacity-30`} /> <div className={`w-full h-full ${gradient} opacity-20 dark:opacity-30`} />
</div> </div>
{/* Icon */} {/* Icon */}
@@ -44,23 +45,14 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
whileHover={{ scale: 1.1, rotate: 5 }} whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ type: 'spring', stiffness: 300 }} transition={{ type: 'spring', stiffness: 300 }}
> >
<div className={`p-4 rounded-xl ${gradient}`}> <div className={`p-4 rounded-xl ${gradient} shadow-lg shadow-black/10`}>
{icon} {icon}
</div> </div>
</motion.div> </motion.div>
{/* Title */} {/* Title */}
<h3 <h3
className="text-2xl font-bold mb-3 text-white transition-all duration-300" className="text-2xl font-bold mb-3 text-foreground transition-all duration-300 group-hover:text-primary"
style={{
color: 'white',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = accentColor;
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'white';
}}
> >
{title} {title}
</h3> </h3>
@@ -71,7 +63,7 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
{badges.map((badge) => ( {badges.map((badge) => (
<span <span
key={badge} key={badge}
className="text-xs px-2 py-1 rounded-full bg-white/5 border border-white/10 text-gray-400" className="text-xs px-2 py-1 rounded-full bg-primary/5 border border-primary/10 text-muted-foreground font-medium"
> >
{badge} {badge}
</span> </span>
@@ -80,13 +72,13 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
)} )}
{/* Description */} {/* Description */}
<p className="text-gray-400 group-hover:text-gray-300 transition-colors duration-300"> <p className="text-muted-foreground group-hover:text-foreground/80 transition-colors duration-300">
{description} {description}
</p> </p>
{/* Arrow icon */} {/* Arrow icon */}
<motion.div <motion.div
className="absolute bottom-8 right-8 text-gray-400 group-hover:text-gray-200 transition-colors duration-300" className="absolute bottom-8 right-8 text-muted-foreground group-hover:text-primary transition-colors duration-300"
initial={{ x: 0 }} initial={{ x: 0 }}
whileHover={{ x: 5 }} whileHover={{ x: 5 }}
> >
@@ -105,6 +97,6 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
</svg> </svg>
</motion.div> </motion.div>
</div> </div>
</motion.a> </MotionLink>
); );
} }

View File

@@ -7,7 +7,7 @@ const tools = [
{ {
title: 'Pastel', title: 'Pastel',
description: 'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.', description: 'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.',
url: 'https://pastel.kit.pivoine.art', url: '/pastel',
gradient: 'gradient-indigo-purple', gradient: 'gradient-indigo-purple',
accentColor: '#a855f7', accentColor: '#a855f7',
badges: ['Open Source', 'WCAG', 'Free'], badges: ['Open Source', 'WCAG', 'Free'],
@@ -24,7 +24,7 @@ const tools = [
{ {
title: 'Units', title: 'Units',
description: 'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search and conversion history.', description: 'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search and conversion history.',
url: 'https://units.kit.pivoine.art', url: '/units',
gradient: 'gradient-cyan-purple', gradient: 'gradient-cyan-purple',
accentColor: '#2dd4bf', accentColor: '#2dd4bf',
badges: ['Open Source', 'Real-time', 'Free'], badges: ['Open Source', 'Real-time', 'Free'],
@@ -37,7 +37,7 @@ const tools = [
{ {
title: 'Figlet', title: 'Figlet',
description: 'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.', description: 'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.',
url: 'https://figlet.kit.pivoine.art', url: '/figlet',
gradient: 'gradient-yellow-amber', gradient: 'gradient-yellow-amber',
accentColor: '#eab308', accentColor: '#eab308',
badges: ['Open Source', 'ASCII Art', 'Free'], badges: ['Open Source', 'ASCII Art', 'Free'],
@@ -67,8 +67,8 @@ export default function ToolsGrid() {
<h2 className="text-4xl md:text-5xl font-bold mb-4 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400"> <h2 className="text-4xl md:text-5xl font-bold mb-4 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
Available Tools Available Tools
</h2> </h2>
<p className="text-gray-400 text-lg max-w-2xl mx-auto"> <p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Explore our collection of carefully crafted tools designed to boost your productivity and creativity. Explore our collection of carefully crafted tools designed to boost your productivity and creativity
</p> </p>
</motion.div> </motion.div>

View File

@@ -0,0 +1,124 @@
'use client';
import * as React from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Copy, X, Download, GitCompare } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { FigletFont } from '@/types/figlet';
export interface ComparisonModeProps {
text: string;
selectedFonts: string[];
fontResults: Record<string, string>;
onRemoveFont: (fontName: string) => void;
onCopyFont: (fontName: string, result: string) => void;
onDownloadFont: (fontName: string, result: string) => void;
className?: string;
}
export function ComparisonMode({
text,
selectedFonts,
fontResults,
onRemoveFont,
onCopyFont,
onDownloadFont,
className,
}: ComparisonModeProps) {
return (
<div className={cn('space-y-4', className)}>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Font Comparison</h2>
<span className="text-sm text-muted-foreground">
{selectedFonts.length} font{selectedFonts.length !== 1 ? 's' : ''} selected
</span>
</div>
{selectedFonts.length === 0 ? (
<Card>
<EmptyState
icon={GitCompare}
title="No fonts selected for comparison"
description="Click the + icon next to any font in the font selector to add it to the comparison"
className="py-12"
/>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{selectedFonts.map((fontName, index) => (
<Card
key={fontName}
className="relative scale-in"
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="p-4 space-y-3">
{/* Font Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-mono font-semibold px-2 py-1 bg-primary/10 text-primary rounded">
{fontName}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onCopyFont(fontName, fontResults[fontName] || '')}
className="h-8 w-8 p-0"
title="Copy to clipboard"
>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDownloadFont(fontName, fontResults[fontName] || '')}
className="h-8 w-8 p-0"
title="Download"
>
<Download className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onRemoveFont(fontName)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
title="Remove from comparison"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* ASCII Art Preview */}
<div className="relative">
<pre className="p-4 bg-muted rounded-md overflow-x-auto">
<code className="text-xs font-mono whitespace-pre">
{fontResults[fontName] || 'Loading...'}
</code>
</pre>
</div>
{/* Stats */}
{fontResults[fontName] && (
<div className="flex gap-4 text-xs text-muted-foreground">
<span>
{fontResults[fontName].split('\n').length} lines
</span>
<span>
{Math.max(
...fontResults[fontName].split('\n').map((line) => line.length)
)} chars wide
</span>
</div>
)}
</div>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,288 @@
'use client';
import * as React from 'react';
import { TextInput } from './TextInput';
import { FontPreview } from './FontPreview';
import { FontSelector } from './FontSelector';
import { TextTemplates } from './TextTemplates';
import { HistoryPanel } from './HistoryPanel';
import { ComparisonMode } from './ComparisonMode';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { GitCompare } from 'lucide-react';
import { textToAscii } from '@/lib/figlet/figletService';
import { getFontList } from '@/lib/figlet/fontLoader';
import { debounce } from '@/lib/utils/debounce';
import { addRecentFont } from '@/lib/storage/favorites';
import { addToHistory, type HistoryItem } from '@/lib/storage/history';
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
import { useToast } from '@/components/ui/Toast';
import { cn } from '@/lib/utils/cn';
import type { FigletFont } from '@/types/figlet';
export function FigletConverter() {
const [text, setText] = React.useState('Figlet UI');
const [selectedFont, setSelectedFont] = React.useState('Standard');
const [asciiArt, setAsciiArt] = React.useState('');
const [fonts, setFonts] = React.useState<FigletFont[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isComparisonMode, setIsComparisonMode] = React.useState(false);
const [comparisonFonts, setComparisonFonts] = React.useState<string[]>([]);
const [comparisonResults, setComparisonResults] = React.useState<Record<string, string>>({});
const { addToast } = useToast();
// Load fonts and check URL params on mount
React.useEffect(() => {
getFontList().then(setFonts);
// Check for URL parameters
const urlState = decodeFromUrl();
if (urlState) {
if (urlState.text) setText(urlState.text);
if (urlState.font) setSelectedFont(urlState.font);
}
}, []);
// Generate ASCII art
const generateAsciiArt = React.useCallback(
debounce(async (inputText: string, fontName: string) => {
if (!inputText) {
setAsciiArt('');
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const result = await textToAscii(inputText, fontName);
setAsciiArt(result);
} catch (error) {
console.error('Error generating ASCII art:', error);
setAsciiArt('Error generating ASCII art. Please try a different font.');
} finally {
setIsLoading(false);
}
}, 300),
[]
);
// Trigger generation when text or font changes
React.useEffect(() => {
generateAsciiArt(text, selectedFont);
// Track recent fonts
if (selectedFont) {
addRecentFont(selectedFont);
}
// Update URL
updateUrl(text, selectedFont);
}, [text, selectedFont, generateAsciiArt]);
// Copy to clipboard
const handleCopy = async () => {
if (!asciiArt) return;
try {
await navigator.clipboard.writeText(asciiArt);
addToHistory(text, selectedFont, asciiArt);
addToast('Copied to clipboard!', 'success');
} catch (error) {
console.error('Failed to copy:', error);
addToast('Failed to copy', 'error');
}
};
// Download as text file
const handleDownload = () => {
if (!asciiArt) return;
const blob = new Blob([asciiArt], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `figlet-${selectedFont}-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Share (copy URL to clipboard)
const handleShare = async () => {
const shareUrl = getShareableUrl(text, selectedFont);
try {
await navigator.clipboard.writeText(shareUrl);
addToast('Shareable URL copied!', 'success');
} catch (error) {
console.error('Failed to copy URL:', error);
addToast('Failed to copy URL', 'error');
}
};
// Random font
const handleRandomFont = () => {
if (fonts.length === 0) return;
const randomIndex = Math.floor(Math.random() * fonts.length);
setSelectedFont(fonts[randomIndex].name);
addToast(`Random font: ${fonts[randomIndex].name}`, 'info');
};
const handleSelectTemplate = (templateText: string) => {
setText(templateText);
addToast(`Template applied: ${templateText}`, 'info');
};
const handleSelectHistory = (item: HistoryItem) => {
setText(item.text);
setSelectedFont(item.font);
addToast(`Restored from history`, 'info');
};
// Comparison mode handlers
const handleToggleComparisonMode = () => {
const newMode = !isComparisonMode;
setIsComparisonMode(newMode);
if (newMode && comparisonFonts.length === 0) {
// Initialize with current font
setComparisonFonts([selectedFont]);
}
addToast(newMode ? 'Comparison mode enabled' : 'Comparison mode disabled', 'info');
};
const handleAddToComparison = (fontName: string) => {
if (comparisonFonts.includes(fontName)) {
addToast('Font already in comparison', 'info');
return;
}
if (comparisonFonts.length >= 6) {
addToast('Maximum 6 fonts for comparison', 'info');
return;
}
setComparisonFonts([...comparisonFonts, fontName]);
addToast(`Added ${fontName} to comparison`, 'success');
};
const handleRemoveFromComparison = (fontName: string) => {
setComparisonFonts(comparisonFonts.filter((f) => f !== fontName));
addToast(`Removed ${fontName} from comparison`, 'info');
};
const handleCopyComparisonFont = async (fontName: string, result: string) => {
try {
await navigator.clipboard.writeText(result);
addToHistory(text, fontName, result);
addToast(`Copied ${fontName} to clipboard!`, 'success');
} catch (error) {
console.error('Failed to copy:', error);
addToast('Failed to copy', 'error');
}
};
const handleDownloadComparisonFont = (fontName: string, result: string) => {
const blob = new Blob([result], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `figlet-${fontName}-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Generate comparison results
React.useEffect(() => {
if (!isComparisonMode || comparisonFonts.length === 0 || !text) return;
const generateComparisons = async () => {
const results: Record<string, string> = {};
for (const fontName of comparisonFonts) {
try {
results[fontName] = await textToAscii(text, fontName);
} catch (error) {
console.error(`Error generating ASCII art for ${fontName}:`, error);
results[fontName] = 'Error generating ASCII art';
}
}
setComparisonResults(results);
};
generateComparisons();
}, [isComparisonMode, comparisonFonts, text]);
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Input and Preview */}
<div className="lg:col-span-2 space-y-6">
{/* Comparison Mode Toggle */}
<Card className="scale-in">
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<GitCompare className={cn(
"h-4 w-4",
isComparisonMode ? "text-primary" : "text-muted-foreground"
)} />
<span className="text-sm font-medium">Comparison Mode</span>
{isComparisonMode && comparisonFonts.length > 0 && (
<span className="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium slide-down">
{comparisonFonts.length} {comparisonFonts.length === 1 ? 'font' : 'fonts'}
</span>
)}
</div>
<Button
variant={isComparisonMode ? 'default' : 'outline'}
size="sm"
onClick={handleToggleComparisonMode}
className={cn(isComparisonMode && "shadow-lg")}
>
{isComparisonMode ? 'Disable' : 'Enable'}
</Button>
</div>
</Card>
<TextTemplates onSelectTemplate={handleSelectTemplate} />
<HistoryPanel onSelectHistory={handleSelectHistory} />
<TextInput
value={text}
onChange={setText}
placeholder="Type your text here..."
/>
{isComparisonMode ? (
<ComparisonMode
text={text}
selectedFonts={comparisonFonts}
fontResults={comparisonResults}
onRemoveFont={handleRemoveFromComparison}
onCopyFont={handleCopyComparisonFont}
onDownloadFont={handleDownloadComparisonFont}
/>
) : (
<FontPreview
text={asciiArt}
font={selectedFont}
isLoading={isLoading}
onCopy={handleCopy}
onDownload={handleDownload}
onShare={handleShare}
/>
)}
</div>
{/* Right Column - Font Selector */}
<div className="lg:col-span-1">
<FontSelector
fonts={fonts}
selectedFont={selectedFont}
onSelectFont={setSelectedFont}
onRandomFont={handleRandomFont}
isComparisonMode={isComparisonMode}
comparisonFonts={comparisonFonts}
onAddToComparison={handleAddToComparison}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
'use client';
import * as React from 'react';
import { toPng } from 'html-to-image';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
import { EmptyState } from '@/components/ui/EmptyState';
import { Copy, Download, Share2, Image as ImageIcon, AlignLeft, AlignCenter, AlignRight, Type } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { useToast } from '@/components/ui/Toast';
export interface FontPreviewProps {
text: string;
font?: string;
isLoading?: boolean;
onCopy?: () => void;
onDownload?: () => void;
onShare?: () => void;
className?: string;
}
type TextAlign = 'left' | 'center' | 'right';
export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare, className }: FontPreviewProps) {
const lineCount = text ? text.split('\n').length : 0;
const charCount = text ? text.length : 0;
const previewRef = React.useRef<HTMLDivElement>(null);
const [textAlign, setTextAlign] = React.useState<TextAlign>('left');
const [fontSize, setFontSize] = React.useState<'xs' | 'sm' | 'base'>('sm');
const { addToast } = useToast();
const handleExportPNG = async () => {
if (!previewRef.current || !text) return;
try {
const dataUrl = await toPng(previewRef.current, {
backgroundColor: getComputedStyle(previewRef.current).backgroundColor,
pixelRatio: 2,
});
const link = document.createElement('a');
link.download = `figlet-${font || 'export'}-${Date.now()}.png`;
link.href = dataUrl;
link.click();
addToast('Exported as PNG!', 'success');
} catch (error) {
console.error('Failed to export PNG:', error);
addToast('Failed to export PNG', 'error');
}
};
return (
<Card className={cn('relative', className)}>
<div className="p-6">
<div className="space-y-3 mb-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">Preview</h3>
{font && (
<span className="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-md font-mono">
{font}
</span>
)}
</div>
<div className="flex gap-2 flex-wrap">
{onCopy && (
<Button variant="outline" size="sm" onClick={onCopy}>
<Copy className="h-4 w-4" />
Copy
</Button>
)}
{onShare && (
<Button variant="outline" size="sm" onClick={onShare} title="Copy shareable URL">
<Share2 className="h-4 w-4" />
Share
</Button>
)}
<Button variant="outline" size="sm" onClick={handleExportPNG} title="Export as PNG">
<ImageIcon className="h-4 w-4" />
PNG
</Button>
{onDownload && (
<Button variant="outline" size="sm" onClick={onDownload}>
<Download className="h-4 w-4" />
TXT
</Button>
)}
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 border rounded-md p-1">
<button
onClick={() => setTextAlign('left')}
className={cn(
'p-1.5 rounded transition-colors',
textAlign === 'left' ? 'bg-accent' : 'hover:bg-accent/50'
)}
title="Align left"
>
<AlignLeft className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setTextAlign('center')}
className={cn(
'p-1.5 rounded transition-colors',
textAlign === 'center' ? 'bg-accent' : 'hover:bg-accent/50'
)}
title="Align center"
>
<AlignCenter className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setTextAlign('right')}
className={cn(
'p-1.5 rounded transition-colors',
textAlign === 'right' ? 'bg-accent' : 'hover:bg-accent/50'
)}
title="Align right"
>
<AlignRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-1 border rounded-md p-1">
<button
onClick={() => setFontSize('xs')}
className={cn(
'px-2 py-1 text-xs rounded transition-colors',
fontSize === 'xs' ? 'bg-accent' : 'hover:bg-accent/50'
)}
>
XS
</button>
<button
onClick={() => setFontSize('sm')}
className={cn(
'px-2 py-1 text-xs rounded transition-colors',
fontSize === 'sm' ? 'bg-accent' : 'hover:bg-accent/50'
)}
>
SM
</button>
<button
onClick={() => setFontSize('base')}
className={cn(
'px-2 py-1 text-xs rounded transition-colors',
fontSize === 'base' ? 'bg-accent' : 'hover:bg-accent/50'
)}
>
MD
</button>
</div>
</div>
</div>
{!isLoading && text && (
<div className="flex gap-4 mb-2 text-xs text-muted-foreground">
<span>{lineCount} lines</span>
<span></span>
<span>{charCount} chars</span>
</div>
)}
<div
ref={previewRef}
className={cn(
'relative min-h-[200px] bg-muted/50 rounded-lg p-4 overflow-x-auto',
textAlign === 'center' && 'text-center',
textAlign === 'right' && 'text-right'
)}
>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-5/6" />
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-4/5" />
</div>
) : text ? (
<pre className={cn(
'font-mono whitespace-pre overflow-x-auto animate-in',
fontSize === 'xs' && 'text-[10px]',
fontSize === 'sm' && 'text-xs sm:text-sm',
fontSize === 'base' && 'text-sm sm:text-base'
)}>
{text}
</pre>
) : (
<EmptyState
icon={Type}
title="Start typing to see your ASCII art"
description="Enter text in the input field above to generate ASCII art with the selected font"
className="py-8"
/>
)}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import * as React from 'react';
import Fuse from 'fuse.js';
import { Input } from '@/components/ui/Input';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { Search, X, Heart, Clock, List, Shuffle, Plus, Check } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/Button';
import type { FigletFont } from '@/types/figlet';
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
export interface FontSelectorProps {
fonts: FigletFont[];
selectedFont: string;
onSelectFont: (fontName: string) => void;
onRandomFont?: () => void;
isComparisonMode?: boolean;
comparisonFonts?: string[];
onAddToComparison?: (fontName: string) => void;
className?: string;
}
type FilterType = 'all' | 'favorites' | 'recent';
export function FontSelector({
fonts,
selectedFont,
onSelectFont,
onRandomFont,
isComparisonMode = false,
comparisonFonts = [],
onAddToComparison,
className
}: FontSelectorProps) {
const [searchQuery, setSearchQuery] = React.useState('');
const [filter, setFilter] = React.useState<FilterType>('all');
const [favorites, setFavorites] = React.useState<string[]>([]);
const [recentFonts, setRecentFonts] = React.useState<string[]>([]);
const searchInputRef = React.useRef<HTMLInputElement>(null);
// Load favorites and recent fonts
React.useEffect(() => {
setFavorites(getFavorites());
setRecentFonts(getRecentFonts());
}, []);
// Keyboard shortcuts
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// "/" to focus search
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
searchInputRef.current?.focus();
}
// "Esc" to clear search
if (e.key === 'Escape' && searchQuery) {
e.preventDefault();
setSearchQuery('');
searchInputRef.current?.blur();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [searchQuery]);
// Initialize Fuse.js for fuzzy search
const fuse = React.useMemo(() => {
return new Fuse(fonts, {
keys: ['name', 'fileName'],
threshold: 0.3,
includeScore: true,
});
}, [fonts]);
const filteredFonts = React.useMemo(() => {
let fontsToFilter = fonts;
// Apply category filter
if (filter === 'favorites') {
fontsToFilter = fonts.filter(f => favorites.includes(f.name));
} else if (filter === 'recent') {
fontsToFilter = fonts.filter(f => recentFonts.includes(f.name));
// Sort by recent order
fontsToFilter.sort((a, b) => {
return recentFonts.indexOf(a.name) - recentFonts.indexOf(b.name);
});
}
// Apply search query
if (!searchQuery) return fontsToFilter;
const results = fuse.search(searchQuery);
const searchResults = results.map(result => result.item);
// Filter search results by category
if (filter === 'favorites') {
return searchResults.filter(f => favorites.includes(f.name));
} else if (filter === 'recent') {
return searchResults.filter(f => recentFonts.includes(f.name));
}
return searchResults;
}, [fonts, searchQuery, fuse, filter, favorites, recentFonts]);
const handleToggleFavorite = (fontName: string, e: React.MouseEvent) => {
e.stopPropagation();
toggleFavorite(fontName);
setFavorites(getFavorites());
};
return (
<Card className={className}>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">Select Font</h3>
{onRandomFont && (
<Button
variant="outline"
size="sm"
onClick={onRandomFont}
title="Random font"
>
<Shuffle className="h-4 w-4" />
Random
</Button>
)}
</div>
{/* Filter Tabs */}
<div className="flex gap-1 mb-4 p-1 bg-muted rounded-lg">
<button
onClick={() => setFilter('all')}
className={cn(
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
filter === 'all' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
)}
>
<List className="inline-block h-3 w-3 mr-1" />
All
</button>
<button
onClick={() => setFilter('favorites')}
className={cn(
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
filter === 'favorites' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
)}
>
<Heart className="inline-block h-3 w-3 mr-1" />
Favorites
</button>
<button
onClick={() => setFilter('recent')}
className={cn(
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
filter === 'recent' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
)}
>
<Clock className="inline-block h-3 w-3 mr-1" />
Recent
</button>
</div>
{/* Search Input */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search fonts... (Press / to focus)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Font List */}
<div className="max-h-[400px] overflow-y-auto space-y-1 pr-2">
{filteredFonts.length === 0 ? (
<EmptyState
icon={filter === 'favorites' ? Heart : (filter === 'recent' ? Clock : Search)}
title={
filter === 'favorites'
? 'No favorite fonts yet'
: filter === 'recent'
? 'No recent fonts'
: 'No fonts found'
}
description={
filter === 'favorites'
? 'Click the heart icon on any font to add it to your favorites'
: filter === 'recent'
? 'Fonts you use will appear here'
: searchQuery
? 'Try a different search term'
: 'Loading fonts...'
}
className="py-8"
/>
) : (
filteredFonts.map((font) => {
const isInComparison = comparisonFonts.includes(font.name);
return (
<div
key={font.name}
className={cn(
'group flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors',
'hover:bg-accent hover:text-accent-foreground',
selectedFont === font.name && 'bg-accent text-accent-foreground font-medium'
)}
>
<button
onClick={() => onSelectFont(font.name)}
className="flex-1 text-left"
>
{font.name}
</button>
{isComparisonMode && onAddToComparison && (
<button
onClick={(e) => {
e.stopPropagation();
onAddToComparison(font.name);
}}
className={cn(
'opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded',
isInComparison && 'opacity-100 bg-primary/10'
)}
aria-label={isInComparison ? 'In comparison' : 'Add to comparison'}
disabled={isInComparison}
>
{isInComparison ? (
<Check className="h-4 w-4 text-primary" />
) : (
<Plus className="h-4 w-4 text-muted-foreground hover:text-primary" />
)}
</button>
)}
<button
onClick={(e) => handleToggleFavorite(font.name, e)}
className={cn(
'opacity-0 group-hover:opacity-100 transition-opacity',
isFavorite(font.name) && 'opacity-100'
)}
aria-label={isFavorite(font.name) ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={cn(
'h-4 w-4 transition-colors',
isFavorite(font.name) ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-red-500'
)}
/>
</button>
</div>
);
})
)}
</div>
{/* Stats */}
<div className="mt-4 pt-4 border-t text-xs text-muted-foreground">
{filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''}
{filter === 'favorites' && `${favorites.length} total favorites`}
{filter === 'recent' && `${recentFonts.length} recent`}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,133 @@
'use client';
import * as React from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { History, X, Trash2, ChevronDown, ChevronUp, Clock } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { getHistory, clearHistory, removeHistoryItem, type HistoryItem } from '@/lib/storage/history';
export interface HistoryPanelProps {
onSelectHistory: (item: HistoryItem) => void;
className?: string;
}
export function HistoryPanel({ onSelectHistory, className }: HistoryPanelProps) {
const [isExpanded, setIsExpanded] = React.useState(false);
const [history, setHistory] = React.useState<HistoryItem[]>([]);
const loadHistory = React.useCallback(() => {
setHistory(getHistory());
}, []);
React.useEffect(() => {
loadHistory();
// Refresh history every 2 seconds when expanded
if (isExpanded) {
const interval = setInterval(loadHistory, 2000);
return () => clearInterval(interval);
}
}, [isExpanded, loadHistory]);
const handleClearAll = () => {
clearHistory();
loadHistory();
};
const handleRemove = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
removeHistoryItem(id);
loadHistory();
};
const formatTime = (timestamp: number) => {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return new Date(timestamp).toLocaleDateString();
};
return (
<Card className={className}>
<div className="p-4">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between text-sm font-medium hover:text-primary transition-colors"
>
<div className="flex items-center gap-2">
<History className="h-4 w-4" />
<span>Copy History</span>
<span className="text-xs text-muted-foreground">({history.length})</span>
</div>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{isExpanded && (
<div className="mt-4 space-y-2 slide-down">
{history.length === 0 ? (
<EmptyState
icon={Clock}
title="No copy history yet"
description="Your recently copied ASCII art will appear here"
/>
) : (
<>
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3 mr-1" />
Clear All
</Button>
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{history.map((item) => (
<div
key={item.id}
onClick={() => onSelectHistory(item)}
className="group relative p-3 bg-muted/50 hover:bg-accent hover:scale-[1.02] rounded-md cursor-pointer transition-all"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{item.font}
</span>
<span className="text-xs text-muted-foreground">
{formatTime(item.timestamp)}
</span>
</div>
<p className="text-xs truncate">{item.text}</p>
</div>
<button
onClick={(e) => handleRemove(item.id, e)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-destructive/10 rounded"
>
<X className="h-3 w-3 text-destructive" />
</button>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface TextInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function TextInput({ value, onChange, placeholder, className }: TextInputProps) {
return (
<div className={cn('relative', className)}>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || 'Type something...'}
className="w-full h-32 px-4 py-3 text-base border border-input rounded-lg bg-background resize-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 placeholder:text-muted-foreground"
maxLength={100}
/>
<div className="absolute bottom-2 right-2 text-xs text-muted-foreground">
{value.length}/100
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import * as React from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { TEXT_TEMPLATES, TEMPLATE_CATEGORIES } from '@/lib/figlet/constants/templates';
export interface TextTemplatesProps {
onSelectTemplate: (text: string) => void;
className?: string;
}
export function TextTemplates({ onSelectTemplate, className }: TextTemplatesProps) {
const [isExpanded, setIsExpanded] = React.useState(false);
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
const filteredTemplates = React.useMemo(() => {
if (selectedCategory === 'all') return TEXT_TEMPLATES;
return TEXT_TEMPLATES.filter(t => t.category === selectedCategory);
}, [selectedCategory]);
return (
<Card className={className}>
<div className="p-4">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between text-sm font-medium hover:text-primary transition-colors"
>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
<span>Text Templates</span>
<span className="text-xs text-muted-foreground">({TEXT_TEMPLATES.length})</span>
</div>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{isExpanded && (
<div className="mt-4 space-y-3 slide-down">
{/* Category Filter */}
<div className="flex gap-1 flex-wrap">
<button
onClick={() => setSelectedCategory('all')}
className={cn(
'px-2 py-1 text-xs rounded-md transition-colors',
selectedCategory === 'all'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'
)}
>
All
</button>
{TEMPLATE_CATEGORIES.map((cat) => (
<button
key={cat.id}
onClick={() => setSelectedCategory(cat.id)}
className={cn(
'px-2 py-1 text-xs rounded-md transition-colors',
selectedCategory === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'
)}
>
{cat.icon} {cat.label}
</button>
))}
</div>
{/* Templates Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{filteredTemplates.map((template) => (
<button
key={template.id}
onClick={() => onSelectTemplate(template.text)}
className="px-3 py-2 text-xs bg-muted hover:bg-accent hover:scale-105 rounded-md transition-all text-left truncate"
title={template.text}
>
{template.label}
</button>
))}
</div>
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Menu, Search, Bell, ChevronRight, Moon, Sun, X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { useTheme } from '@/components/providers/ThemeProvider';
import { cn } from '@/lib/utils/cn';
import { useSidebar } from './SidebarProvider';
export function AppHeader() {
const pathname = usePathname();
const { toggle, isOpen } = useSidebar();
// Custom breadcrumb logic
const pathSegments = pathname.split('/').filter(Boolean);
return (
<header className="h-16 border-b border-white/5 bg-background/10 backdrop-blur-xl sticky top-0 z-40 flex items-center justify-between px-4 lg:px-8">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
className="lg:hidden text-muted-foreground hover:text-foreground"
onClick={toggle}
>
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
<nav className="hidden sm:flex items-center text-sm font-medium text-muted-foreground">
<Link href="/" className="hover:text-foreground transition-colors flex items-center gap-2">
<span>Kit</span>
</Link>
{pathSegments.map((segment, index) => {
const href = `/${pathSegments.slice(0, index + 1).join('/')}`;
const isLast = index === pathSegments.length - 1;
return (
<React.Fragment key={href}>
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground/30" />
<Link
href={href}
className={cn(
"capitalize transition-colors",
isLast ? "text-foreground font-semibold" : "hover:text-foreground"
)}
>
{segment.replace(/-/g, ' ')}
</Link>
</React.Fragment>
);
})}
</nav>
</div>
<div className="flex items-center gap-2 sm:gap-4">
<ThemeToggleComponent />
</div>
</header>
);
}
function ThemeToggleComponent() {
const { resolvedTheme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
className="text-muted-foreground hover:text-foreground hover:bg-white/5"
title={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
>
{resolvedTheme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import { AppSidebar } from './AppSidebar';
import { AppHeader } from './AppHeader';
import AnimatedBackground from '@/components/AnimatedBackground';
import { SidebarProvider } from './SidebarProvider';
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<SidebarProvider>
<div className="flex min-h-screen bg-background text-foreground relative">
<AnimatedBackground />
<AppSidebar />
<div className="flex-1 flex flex-col min-w-0 relative z-10">
<AppHeader />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</div>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
ChevronRight,
MousePointer2,
Palette,
Eye,
Languages,
Layers,
ChevronLeft,
X
} from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import Logo from '@/components/Logo';
import { useSidebar } from './SidebarProvider';
import { Button } from '@/components/ui/Button';
interface NavItem {
title: string;
href: string;
icon: React.ElementType | React.ReactNode;
items?: { title: string; href: string }[];
}
interface NavGroup {
label: string;
items: NavItem[];
}
const PastelIcon = (props: any) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
<circle cx="6.5" cy="11.5" r="1" fill="currentColor" />
<circle cx="9.5" cy="7.5" r="1" fill="currentColor" />
<circle cx="14.5" cy="7.5" r="1" fill="currentColor" />
<circle cx="17.5" cy="11.5" r="1" fill="currentColor" />
</svg>
);
const UnitsIcon = (props: any) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
const FigletIcon = (props: any) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.5 13h6" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m2 16 4.5-9 4.5 9" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 16V7" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m14 11 4-4 4 4" />
</svg>
);
const navigation: NavGroup[] = [
{
label: 'Toolkit',
items: [
{
title: 'Units Converter',
href: '/units',
icon: <UnitsIcon className="h-4 w-4" />
},
{
title: 'Figlet ASCII',
href: '/figlet',
icon: <FigletIcon className="h-4 w-4" />
},
{
title: 'Pastel (Color)',
href: '/pastel',
icon: <PastelIcon className="h-4 w-4" />,
items: [
{ title: 'Harmony Palettes', href: '/pastel/palettes/harmony' },
{ title: 'Distinct Colors', href: '/pastel/palettes/distinct' },
{ title: 'Gradients', href: '/pastel/palettes/gradient' },
{ title: 'Contrast Checker', href: '/pastel/accessibility/contrast' },
{ title: 'Color Blindness', href: '/pastel/accessibility/colorblind' },
{ title: 'Text Color', href: '/pastel/accessibility/textcolor' },
{ title: 'Named Colors', href: '/pastel/names' },
{ title: 'Batch Operations', href: '/pastel/batch' },
]
},
]
}
];
export function AppSidebar() {
const pathname = usePathname();
const { isOpen, isCollapsed, close, toggleCollapse } = useSidebar();
return (
<>
{/* Mobile Overlay Backdrop */}
{isOpen && (
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40 lg:hidden"
onClick={close}
/>
)}
<aside className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col border-r border-white/5 bg-background/40 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative",
isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
isCollapsed ? "lg:w-20" : "w-64"
)}>
{/* Sidebar Header */}
<div className="flex h-16 items-center justify-between px-6 shrink-0 border-b border-white/5">
<Link href="/" className="flex items-center gap-3 group overflow-hidden">
<div className="shrink-0">
<Logo size={isCollapsed ? 32 : 32} />
</div>
{!isCollapsed && (
<span className="font-bold text-xl bg-clip-text text-transparent bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400 group-hover:opacity-80 transition-opacity whitespace-nowrap">
Kit
</span>
)}
</Link>
<Button
variant="ghost"
size="icon"
className="lg:hidden text-muted-foreground"
onClick={close}
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-4 py-2 space-y-8 mt-4 scrollbar-hide">
{navigation.map((group) => (
<div key={group.label} className="space-y-2">
{!isCollapsed && (
<h4 className="px-3 text-xs font-semibold text-muted-foreground/50 uppercase tracking-wider">
{group.label}
</h4>
)}
<div className="space-y-1">
{group.items.map((item) => {
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
return (
<div key={item.href} className="space-y-1">
<Link
href={item.href}
onClick={() => { if (window.innerWidth < 1024) close(); }}
className={cn(
"flex items-center px-3 py-2 rounded-xl text-sm font-medium transition-all duration-300 relative group/item",
isActive
? "bg-primary/10 text-primary shadow-[0_0_15px_rgba(139,92,246,0.15)] ring-1 ring-primary/20"
: "text-muted-foreground hover:bg-white/5 hover:text-foreground",
isCollapsed ? "justify-center" : "justify-between"
)}
title={isCollapsed ? item.title : undefined}
>
<div className="flex items-center gap-3">
<span className={cn(
"transition-colors duration-300 shrink-0",
isActive ? "text-primary" : "text-muted-foreground group-hover/item:text-foreground"
)}>
{React.isValidElement(item.icon) ? item.icon : null}
</span>
{!isCollapsed && <span className="whitespace-nowrap">{item.title}</span>}
</div>
{!isCollapsed && item.items && (
<ChevronRight className={cn(
"h-3.5 w-3.5 transition-transform duration-300",
pathname.startsWith(item.href) && "rotate-90"
)} />
)}
{/* Collapsed Active Indicator */}
{isCollapsed && isActive && (
<div className="absolute left-0 w-1 h-6 bg-primary rounded-r-full" />
)}
</Link>
{item.items && pathname.startsWith(item.href) && !isCollapsed && (
<div className="ml-9 space-y-1 border-l border-white/5 pl-2 mt-1">
{item.items.map((subItem) => (
<Link
key={subItem.href}
href={subItem.href}
onClick={() => { if (window.innerWidth < 1024) close(); }}
className={cn(
"block px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
pathname === subItem.href
? "text-primary bg-primary/5 font-semibold"
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
)}
>
{subItem.title}
</Link>
))}
</div>
)}
</div>
);
})}
</div>
</div>
))}
</nav>
{/* Sidebar Footer / Desktop Toggle */}
<div className="p-4 border-t border-white/5 hidden lg:block">
<Button
variant="ghost"
size="sm"
className="w-full flex items-center justify-center gap-2 text-muted-foreground hover:text-foreground"
onClick={toggleCollapse}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<>
<ChevronLeft className="h-4 w-4" />
<span className="text-xs font-semibold uppercase tracking-wider">Collapse Sidebar</span>
</>
)}
</Button>
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import * as React from 'react';
interface SidebarContextType {
isOpen: boolean;
isCollapsed: boolean;
toggle: () => void;
toggleCollapse: () => void;
close: () => void;
}
const SidebarContext = React.createContext<SidebarContextType | undefined>(undefined);
export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = React.useState(false);
const [isCollapsed, setIsCollapsed] = React.useState(false);
const toggle = React.useCallback(() => setIsOpen((prev) => !prev), []);
const toggleCollapse = React.useCallback(() => setIsCollapsed((prev) => !prev), []);
const close = React.useCallback(() => setIsOpen(false), []);
return (
<SidebarContext.Provider value={{ isOpen, isCollapsed, toggle, toggleCollapse, close }}>
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider');
}
return context;
}

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,32 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner';
import { useState } from 'react';
import { ThemeProvider } from './ThemeProvider';
import { ToastProvider } from '@/components/ui/Toast';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
);
return (
<ThemeProvider>
<ToastProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" richColors />
</QueryClientProvider>
</ToastProvider>
</ThemeProvider>
);
}

View 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>('dark');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('dark');
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;
}

35
components/ui/Badge.tsx Normal file
View File

@@ -0,0 +1,35 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'success' | 'warning' | 'destructive' | 'outline';
}
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant = 'default', ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
'bg-primary text-primary-foreground': variant === 'default',
'bg-green-500 text-white': variant === 'success',
'bg-yellow-500 text-white': variant === 'warning',
'bg-destructive text-destructive-foreground': variant === 'destructive',
'border border-input': variant === 'outline',
},
className
)}
{...props}
/>
);
}
);
Badge.displayName = 'Badge';
export { Badge };

45
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,45 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
size?: 'default' | 'sm' | 'lg' | 'icon';
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
return (
<button
className={cn(
'inline-flex items-center justify-center rounded-xl font-semibold',
'transition-all duration-300',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background',
'disabled:pointer-events-none disabled:opacity-50',
'active:scale-[0.98]',
{
'bg-primary text-primary-foreground shadow-[0_0_20px_rgba(139,92,246,0.3)] hover:bg-primary/90 hover:shadow-[0_0_25px_rgba(139,92,246,0.5)] hover:-translate-y-0.5':
variant === 'default',
'glass bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-foreground':
variant === 'outline',
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-lg hover:shadow-destructive/20':
variant === 'destructive',
'bg-secondary text-secondary-foreground hover:bg-secondary/80':
variant === 'secondary',
'h-10 px-5 py-2 text-sm': size === 'default',
'h-9 px-4 text-xs': size === 'sm',
'h-12 px-8 text-base': size === 'lg',
'h-10 w-10': size === 'icon',
},
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button };

50
components/ui/Card.tsx Normal file
View File

@@ -0,0 +1,50 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('glass rounded-2xl text-card-foreground shadow-xl transition-all duration-300', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,231 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Command, Hash, Clock, Star, Moon, Sun } from 'lucide-react';
import { useTheme } from '@/components/providers/ThemeProvider';
import {
getAllMeasures,
formatMeasureName,
getCategoryColor,
getCategoryColorHex,
type Measure,
} from '@/lib/units/units';
import { getHistory, getFavorites } from '@/lib/units/storage';
import { cn } from '@/lib/units/utils';
interface CommandPaletteProps {
onSelectMeasure: (measure: Measure) => void;
onSelectUnit: (unit: string, measure: Measure) => void;
}
export default function CommandPalette({
onSelectMeasure,
onSelectUnit,
}: CommandPaletteProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const { theme, setTheme } = useTheme();
const inputRef = useRef<HTMLInputElement>(null);
// Commands
const commands: Array<{
id: string;
label: string;
icon: any;
action: () => void;
keywords: string[];
color?: string;
}> = [
{
id: 'theme-light',
label: 'Switch to Light Mode',
icon: Sun,
action: () => setTheme('light'),
keywords: ['theme', 'light', 'mode'],
},
{
id: 'theme-dark',
label: 'Switch to Dark Mode',
icon: Moon,
action: () => setTheme('dark'),
keywords: ['theme', 'dark', 'mode'],
},
{
id: 'theme-system',
label: 'Use System Theme',
icon: Command,
action: () => setTheme('system'),
keywords: ['theme', 'system', 'auto'],
},
];
// Add measure commands
const measures = getAllMeasures();
const measureCommands = measures.map(measure => ({
id: `measure-${measure}`,
label: `Convert ${formatMeasureName(measure)}`,
icon: Hash,
action: () => onSelectMeasure(measure),
keywords: ['convert', measure, formatMeasureName(measure).toLowerCase()],
color: getCategoryColorHex(measure),
}));
const allCommands = [...commands, ...measureCommands];
// Filter commands
const filteredCommands = query
? allCommands.filter(cmd =>
cmd.keywords.some(kw => kw.toLowerCase().includes(query.toLowerCase())) ||
cmd.label.toLowerCase().includes(query.toLowerCase())
)
: allCommands;
// Keyboard shortcut to open
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setIsOpen(prev => !prev);
}
if (e.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// Focus input when opened
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
setQuery('');
setSelectedIndex(0);
}
}, [isOpen]);
// Keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev =>
prev < filteredCommands.length - 1 ? prev + 1 : prev
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
} else if (e.key === 'Enter') {
e.preventDefault();
const command = filteredCommands[selectedIndex];
if (command) {
command.action();
setIsOpen(false);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, selectedIndex, filteredCommands]);
// Reset selected index when query changes
useEffect(() => {
setSelectedIndex(0);
}, [query]);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50"
onClick={() => setIsOpen(false)}
/>
{/* Command Palette */}
<div className="fixed left-1/2 top-1/4 -translate-x-1/2 w-full max-w-2xl z-50 animate-scale-in">
<div className="bg-popover border rounded-lg shadow-2xl overflow-hidden">
{/* Search Input */}
<div className="flex items-center border-b px-4">
<Command className="h-5 w-5 text-muted-foreground" />
<input
ref={inputRef}
type="text"
placeholder="Type a command or search..."
value={query}
onChange={e => setQuery(e.target.value)}
className="flex-1 bg-transparent py-4 px-4 outline-none placeholder:text-muted-foreground"
/>
<kbd className="hidden sm:inline-block px-2 py-1 text-xs bg-muted rounded">
ESC
</kbd>
</div>
{/* Commands List */}
<div className="max-h-96 overflow-y-auto p-2">
{filteredCommands.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No commands found
</div>
) : (
filteredCommands.map((command, index) => {
const Icon = command.icon;
return (
<button
key={command.id}
onClick={() => {
command.action();
setIsOpen(false);
}}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors text-left',
index === selectedIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50'
)}
>
{command.color ? (
<div
className="w-5 h-5 rounded flex-shrink-0"
style={{
backgroundColor: command.color,
}}
/>
) : (
<Icon className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
)}
<span className="flex-1">{command.label}</span>
</button>
);
})
)}
</div>
{/* Footer */}
<div className="border-t px-4 py-2 text-xs text-muted-foreground flex items-center gap-4">
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded"></kbd>
<kbd className="px-1.5 py-0.5 bg-muted rounded"></kbd>
<span>Navigate</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded">Enter</kbd>
<span>Select</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded">ESC</kbd>
<span>Close</span>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,36 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
import { LucideIcon } from 'lucide-react';
export interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
{Icon && (
<div className="mb-4 rounded-full bg-muted p-3">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
)}
<h3 className="mb-2 text-sm font-semibold">{title}</h3>
{description && (
<p className="mb-4 text-sm text-muted-foreground max-w-sm">
{description}
</p>
)}
{action}
</div>
);
}

28
components/ui/Input.tsx Normal file
View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2',
'text-sm ring-offset-background file:border-0 file:bg-transparent',
'file:text-sm file:font-medium placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary/50',
'disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,102 @@
'use client';
import * as React from 'react';
import { Card } from './Card';
import { Button } from './Button';
import { X, Keyboard } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
export interface Shortcut {
key: string;
description: string;
modifier?: 'ctrl' | 'shift';
}
const shortcuts: Shortcut[] = [
{ key: '/', description: 'Focus font search' },
{ key: 'Esc', description: 'Clear search / Close dialog' },
{ key: 'D', description: 'Toggle dark/light mode', modifier: 'ctrl' },
{ key: '?', description: 'Show this help dialog', modifier: 'shift' },
];
export function KeyboardShortcutsHelp() {
const [isOpen, setIsOpen] = React.useState(false);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === '?' && e.shiftKey) {
e.preventDefault();
setIsOpen(true);
}
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
if (!isOpen) {
return (
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(true)}
title="Keyboard shortcuts (Shift + ?)"
className="fixed bottom-4 right-4"
>
<Keyboard className="h-4 w-4" />
</Button>
);
}
return (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<Card className="max-w-md w-full">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Keyboard className="h-5 w-5" />
Keyboard Shortcuts
</h2>
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">Navigation</h3>
{shortcuts.map((shortcut, i) => (
<div key={i} className="flex items-center justify-between py-2 border-b last:border-0">
<span className="text-sm text-muted-foreground">{shortcut.description}</span>
<div className="flex gap-1">
{shortcut.modifier && (
<kbd className="px-2 py-1 text-xs font-semibold bg-muted rounded">
{shortcut.modifier === 'ctrl' ? '⌘/Ctrl' : 'Shift'}
</kbd>
)}
<kbd className="px-2 py-1 text-xs font-semibold bg-muted rounded">
{shortcut.key}
</kbd>
</div>
</div>
))}
</div>
<div>
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">Tips</h3>
<ul className="text-xs text-muted-foreground space-y-1">
<li> Click the Shuffle button for random fonts</li>
<li> Use the heart icon to favorite fonts</li>
<li> Filter by All, Favorites, or Recent</li>
<li> Text alignment and size controls in Preview</li>
</ul>
</div>
</div>
</div>
</Card>
</div>
);
}

39
components/ui/Select.tsx Normal file
View File

@@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, label, children, ...props }, ref) => {
return (
<div className="space-y-2">
{label && (
<label htmlFor={props.id} className="text-sm font-medium">
{label}
</label>
)}
<select
className={cn(
'flex h-10 w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2',
'text-sm ring-offset-background',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary/50',
'disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
className
)}
ref={ref}
{...props}
>
{children}
</select>
</div>
);
}
);
Select.displayName = 'Select';
export { Select };

View File

@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Separator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
orientation?: 'horizontal' | 'vertical';
decorative?: boolean;
}
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<div
ref={ref}
role={decorative ? undefined : 'separator'}
aria-orientation={decorative ? undefined : orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
);
Separator.displayName = 'Separator';
export { Separator };

View File

@@ -0,0 +1,14 @@
'use client';
import { cn } from '@/lib/utils/cn';
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {}
export function Skeleton({ className, ...props }: SkeletonProps) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}

65
components/ui/Slider.tsx Normal file
View File

@@ -0,0 +1,65 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: string;
showValue?: boolean;
suffix?: string;
}
const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
({ className, label, showValue = true, suffix = '', ...props }, ref) => {
const [value, setValue] = React.useState(props.value || props.defaultValue || 0);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
props.onChange?.(e);
};
return (
<div className="space-y-2">
{(label || showValue) && (
<div className="flex items-center justify-between">
{label && (
<label htmlFor={props.id} className="text-sm font-medium">
{label}
</label>
)}
{showValue && (
<span className="text-sm text-muted-foreground">
{value}
{suffix}
</span>
)}
</div>
)}
<input
type="range"
className={cn(
'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4',
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary',
'[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-all',
'[&::-webkit-slider-thumb]:hover:scale-110',
'[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full',
'[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0',
'[&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:transition-all',
'[&::-moz-range-thumb]:hover:scale-110',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
className
)}
ref={ref}
{...props}
value={value}
onChange={handleChange}
/>
</div>
);
}
);
Slider.displayName = 'Slider';
export { Slider };

90
components/ui/Toast.tsx Normal file
View File

@@ -0,0 +1,90 @@
'use client';
import * as React from 'react';
import { X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
export type ToastType = 'success' | 'error' | 'info';
export interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastContextType {
toasts: Toast[];
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: string) => void;
}
const ToastContext = React.createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<Toast[]>([]);
const addToast = React.useCallback((message: string, type: ToastType = 'success') => {
const id = Math.random().toString(36).substring(7);
setToasts((prev) => [...prev, { id, message, type }]);
// Auto remove after 3 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
const removeToast = React.useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
))}
</div>
</ToastContext.Provider>
);
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const Icon = toast.type === 'success' ? CheckCircle2 : toast.type === 'error' ? AlertCircle : Info;
return (
<div
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg pointer-events-auto',
'animate-in slide-in-from-right-full duration-300',
'min-w-[300px] max-w-[400px]',
{
'bg-green-50 text-green-900 border border-green-200 dark:bg-green-900/20 dark:text-green-100 dark:border-green-800':
toast.type === 'success',
'bg-red-50 text-red-900 border border-red-200 dark:bg-red-900/20 dark:text-red-100 dark:border-red-800':
toast.type === 'error',
'bg-blue-50 text-blue-900 border border-blue-200 dark:bg-blue-900/20 dark:text-blue-100 dark:border-blue-800':
toast.type === 'info',
}
)}
>
<Icon className="h-5 w-5 flex-shrink-0" />
<p className="text-sm font-medium flex-1">{toast.message}</p>
<button
onClick={onClose}
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
export function useToast() {
const context = React.useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}

View File

@@ -0,0 +1,122 @@
'use client';
import { useState, useEffect } from 'react';
import { History, Trash2, ArrowRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
getHistory,
clearHistory,
type ConversionRecord,
} from '@/lib/units/storage';
import { getRelativeTime, formatNumber } from '@/lib/units/utils';
import { formatMeasureName } from '@/lib/units/units';
interface ConversionHistoryProps {
onSelectConversion?: (record: ConversionRecord) => void;
}
export default function ConversionHistory({
onSelectConversion,
}: ConversionHistoryProps) {
const [history, setHistory] = useState<ConversionRecord[]>([]);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
loadHistory();
// Listen for storage changes
const handleStorageChange = () => {
loadHistory();
};
window.addEventListener('storage', handleStorageChange);
// Also listen for custom event from same window
window.addEventListener('historyUpdated', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('historyUpdated', handleStorageChange);
};
}, []);
const loadHistory = () => {
setHistory(getHistory());
};
const handleClearHistory = () => {
if (confirm('Clear all conversion history?')) {
clearHistory();
loadHistory();
}
};
if (history.length === 0) {
return null;
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Recent Conversions
</CardTitle>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? 'Hide' : `Show (${history.length})`}
</Button>
{history.length > 0 && (
<Button
variant="ghost"
size="icon"
onClick={handleClearHistory}
className="h-8 w-8"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
{isOpen && (
<CardContent>
<div className="space-y-2">
{history.map((record) => (
<button
key={record.id}
onClick={() => onSelectConversion?.(record)}
className="w-full p-3 rounded-lg border hover:bg-accent transition-colors text-left"
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="truncate">
{formatNumber(record.from.value)} {record.from.unit}
</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span className="truncate">
{formatNumber(record.to.value)} {record.to.unit}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
<span>{formatMeasureName(record.measure as any)}</span>
<span></span>
<span>{getRelativeTime(record.timestamp)}</span>
</div>
</div>
</div>
</button>
))}
</div>
</CardContent>
)}
</Card>
);
}

View File

@@ -0,0 +1,345 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Copy, Star, Check, ArrowLeftRight, BarChart3 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import SearchUnits from './SearchUnits';
import ConversionHistory from './ConversionHistory';
import VisualComparison from './VisualComparison';
import CommandPalette from '@/components/ui/CommandPalette';
import {
getAllMeasures,
getUnitsForMeasure,
convertToAll,
convertUnit,
formatMeasureName,
getCategoryColor,
getCategoryColorHex,
type Measure,
type ConversionResult,
} from '@/lib/units/units';
import { parseNumberInput, formatNumber, cn } from '@/lib/units/utils';
import { saveToHistory, getFavorites, toggleFavorite } from '@/lib/units/storage';
export default function MainConverter() {
const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length');
const [selectedUnit, setSelectedUnit] = useState<string>('m');
const [targetUnit, setTargetUnit] = useState<string>('ft');
const [inputValue, setInputValue] = useState<string>('1');
const [conversions, setConversions] = useState<ConversionResult[]>([]);
const [favorites, setFavorites] = useState<string[]>([]);
const [copiedUnit, setCopiedUnit] = useState<string | null>(null);
const [showVisualComparison, setShowVisualComparison] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const measures = getAllMeasures();
const units = getUnitsForMeasure(selectedMeasure);
// Load favorites
useEffect(() => {
setFavorites(getFavorites());
}, []);
// Update conversions when input changes
useEffect(() => {
const numValue = parseNumberInput(inputValue);
if (numValue !== null && selectedUnit) {
const results = convertToAll(numValue, selectedUnit);
setConversions(results);
} else {
setConversions([]);
}
}, [inputValue, selectedUnit]);
// Update selected unit when measure changes
useEffect(() => {
const availableUnits = getUnitsForMeasure(selectedMeasure);
if (availableUnits.length > 0) {
setSelectedUnit(availableUnits[0]);
setTargetUnit(availableUnits[1] || availableUnits[0]);
}
}, [selectedMeasure]);
// Swap units
const handleSwapUnits = useCallback(() => {
const temp = selectedUnit;
setSelectedUnit(targetUnit);
setTargetUnit(temp);
// Convert the value
const numValue = parseNumberInput(inputValue);
if (numValue !== null) {
const converted = convertUnit(numValue, selectedUnit, targetUnit);
setInputValue(converted.toString());
}
}, [selectedUnit, targetUnit, inputValue]);
// Copy to clipboard
const copyToClipboard = useCallback(async (value: number, unit: string) => {
try {
await navigator.clipboard.writeText(`${formatNumber(value)} ${unit}`);
setCopiedUnit(unit);
setTimeout(() => setCopiedUnit(null), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
}, []);
// Toggle favorite
const handleToggleFavorite = useCallback((unit: string) => {
const isFavorite = toggleFavorite(unit);
setFavorites(getFavorites());
}, []);
// Save to history when conversion happens (but not during dragging)
useEffect(() => {
if (isDragging) return; // Don't save to history while dragging
const numValue = parseNumberInput(inputValue);
if (numValue !== null && selectedUnit && conversions.length > 0) {
// Save first conversion to history
const firstConversion = conversions.find(c => c.unit !== selectedUnit);
if (firstConversion) {
saveToHistory({
from: { value: numValue, unit: selectedUnit },
to: { value: firstConversion.value, unit: firstConversion.unit },
measure: selectedMeasure,
});
// Dispatch custom event for same-window updates
window.dispatchEvent(new Event('historyUpdated'));
}
}
}, [inputValue, selectedUnit, conversions, selectedMeasure, isDragging]);
// Handle search selection
const handleSearchSelect = useCallback((unit: string, measure: Measure) => {
setSelectedMeasure(measure);
setSelectedUnit(unit);
}, []);
// Handle history selection
const handleHistorySelect = useCallback((record: any) => {
setInputValue(record.from.value.toString());
setSelectedMeasure(record.measure);
setSelectedUnit(record.from.unit);
}, []);
// Handle value change from draggable bars
const handleValueChange = useCallback((value: number, unit: string, dragging: boolean) => {
setIsDragging(dragging);
// Convert the dragged unit's value back to the currently selected unit
// This keeps the source unit stable while updating the value
const convertedValue = convertUnit(value, unit, selectedUnit);
setInputValue(convertedValue.toString());
// Keep selectedUnit unchanged
}, [selectedUnit]);
return (
<div className="w-full space-y-6">
{/* Command Palette */}
<CommandPalette
onSelectMeasure={setSelectedMeasure}
onSelectUnit={handleSearchSelect}
/>
{/* Search */}
<div className="flex justify-center">
<SearchUnits onSelectUnit={handleSearchSelect} />
</div>
{/* Category Selection */}
<Card>
<CardHeader>
<CardTitle>Select Category</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2">
{measures.map((measure) => (
<Button
key={measure}
variant={selectedMeasure === measure ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedMeasure(measure)}
className="justify-start"
style={{
backgroundColor:
selectedMeasure === measure
? getCategoryColorHex(measure)
: undefined,
borderColor: selectedMeasure !== measure
? getCategoryColorHex(measure)
: undefined,
color: selectedMeasure === measure ? 'white' : undefined,
}}
>
{formatMeasureName(measure)}
</Button>
))}
</div>
</CardContent>
</Card>
{/* Input Section */}
<Card>
<CardHeader>
<CardTitle>Convert {formatMeasureName(selectedMeasure)}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">Value</label>
<Input
type="text"
inputMode="decimal"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Enter value"
className="text-lg"
/>
</div>
<div className="w-40">
<label className="text-sm font-medium mb-2 block">From</label>
<select
value={selectedUnit}
onChange={(e) => setSelectedUnit(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{units.map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>
))}
</select>
</div>
<Button
variant="outline"
size="icon"
onClick={handleSwapUnits}
className="flex-shrink-0"
title="Swap units"
>
<ArrowLeftRight className="h-4 w-4" />
</Button>
<div className="w-40">
<label className="text-sm font-medium mb-2 block">To</label>
<select
value={targetUnit}
onChange={(e) => setTargetUnit(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{units.map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>
))}
</select>
</div>
</div>
{/* Quick result */}
{parseNumberInput(inputValue) !== null && (
<div className="p-4 rounded-lg bg-accent/50 border-l-4" style={{
borderLeftColor: getCategoryColorHex(selectedMeasure),
}}>
<div className="text-sm text-muted-foreground">Result</div>
<div className="text-3xl font-bold mt-1" style={{
color: getCategoryColorHex(selectedMeasure),
}}>
{formatNumber(convertUnit(parseNumberInput(inputValue)!, selectedUnit, targetUnit))} {targetUnit}
</div>
</div>
)}
</CardContent>
</Card>
{/* Results */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>All Conversions</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setShowVisualComparison(!showVisualComparison)}
>
<BarChart3 className="h-4 w-4 mr-2" />
{showVisualComparison ? 'Grid View' : 'Chart View'}
</Button>
</div>
</CardHeader>
<CardContent>
{showVisualComparison ? (
<VisualComparison
conversions={conversions}
color={getCategoryColorHex(selectedMeasure)}
onValueChange={handleValueChange}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{conversions.map((conversion) => {
const isFavorite = favorites.includes(conversion.unit);
const isCopied = copiedUnit === conversion.unit;
return (
<div
key={conversion.unit}
className="group relative p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
style={{
borderLeftWidth: '4px',
borderLeftColor: getCategoryColorHex(selectedMeasure),
}}
>
{/* Favorite & Copy buttons */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleToggleFavorite(conversion.unit)}
>
<Star
className={cn(
'h-4 w-4',
isFavorite && 'fill-yellow-400 text-yellow-400'
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => copyToClipboard(conversion.value, conversion.unit)}
>
{isCopied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<div className="text-sm text-muted-foreground mb-1">
{conversion.unitInfo.plural}
</div>
<div className="text-2xl font-bold">
{formatNumber(conversion.value)}
</div>
<div className="text-sm text-muted-foreground mt-1">
{conversion.unit}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Conversion History */}
<ConversionHistory onSelectConversion={handleHistorySelect} />
</div>
);
}

View File

@@ -0,0 +1,201 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Search, X } from 'lucide-react';
import Fuse from 'fuse.js';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import {
getAllMeasures,
getUnitsForMeasure,
getUnitInfo,
formatMeasureName,
getCategoryColor,
getCategoryColorHex,
type Measure,
type UnitInfo,
} from '@/lib/units/units';
import { cn } from '@/lib/units/utils';
interface SearchResult {
unitInfo: UnitInfo;
measure: Measure;
}
interface SearchUnitsProps {
onSelectUnit: (unit: string, measure: Measure) => void;
}
export default function SearchUnits({ onSelectUnit }: SearchUnitsProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Build search index
const searchIndex = useRef<Fuse<SearchResult> | null>(null);
useEffect(() => {
// Build comprehensive search data
const allData: SearchResult[] = [];
const measures = getAllMeasures();
for (const measure of measures) {
const units = getUnitsForMeasure(measure);
for (const unit of units) {
const unitInfo = getUnitInfo(unit);
if (unitInfo) {
allData.push({
unitInfo,
measure,
});
}
}
}
// Initialize Fuse.js for fuzzy search
searchIndex.current = new Fuse(allData, {
keys: [
{ name: 'unitInfo.abbr', weight: 2 },
{ name: 'unitInfo.singular', weight: 1.5 },
{ name: 'unitInfo.plural', weight: 1.5 },
{ name: 'measure', weight: 1 },
],
threshold: 0.3,
includeScore: true,
});
}, []);
// Perform search
useEffect(() => {
if (!query.trim() || !searchIndex.current) {
setResults([]);
setIsOpen(false);
return;
}
const searchResults = searchIndex.current.search(query);
setResults(searchResults.map(r => r.item).slice(0, 10));
setIsOpen(true);
}, [query]);
// Handle click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Keyboard shortcut: / to focus search
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
const activeElement = document.activeElement;
if (
activeElement?.tagName !== 'INPUT' &&
activeElement?.tagName !== 'TEXTAREA'
) {
e.preventDefault();
inputRef.current?.focus();
}
}
if (e.key === 'Escape') {
setIsOpen(false);
inputRef.current?.blur();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const handleSelectUnit = (unit: string, measure: Measure) => {
onSelectUnit(unit, measure);
setQuery('');
setIsOpen(false);
inputRef.current?.blur();
};
const clearSearch = () => {
setQuery('');
setIsOpen(false);
};
return (
<div ref={containerRef} className="relative w-full max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
type="text"
placeholder="Search units (press / to focus)"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => query && setIsOpen(true)}
className="pl-10 pr-10"
/>
{query && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={clearSearch}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Results dropdown */}
{isOpen && results.length > 0 && (
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-lg shadow-lg max-h-80 overflow-y-auto">
{results.map((result, index) => (
<button
key={`${result.measure}-${result.unitInfo.abbr}`}
onClick={() => handleSelectUnit(result.unitInfo.abbr, result.measure)}
className={cn(
'w-full px-4 py-3 text-left hover:bg-accent transition-colors',
'flex items-center justify-between gap-4',
index !== 0 && 'border-t'
)}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{result.unitInfo.plural}
</div>
<div className="text-sm text-muted-foreground flex items-center gap-2">
<span className="truncate">{result.unitInfo.abbr}</span>
<span></span>
<span className="truncate">{formatMeasureName(result.measure)}</span>
</div>
</div>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{
backgroundColor: getCategoryColorHex(result.measure),
}}
/>
</button>
))}
</div>
)}
{isOpen && query && results.length === 0 && (
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-lg shadow-lg p-4 text-center text-muted-foreground">
No units found for "{query}"
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,314 @@
'use client';
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
import { type ConversionResult } from '@/lib/units/units';
import { formatNumber, cn } from '@/lib/units/utils';
interface VisualComparisonProps {
conversions: ConversionResult[];
color: string;
onValueChange?: (value: number, unit: string, dragging: boolean) => void;
}
export default function VisualComparison({
conversions,
color,
onValueChange,
}: VisualComparisonProps) {
const [draggingUnit, setDraggingUnit] = useState<string | null>(null);
const [draggedPercentage, setDraggedPercentage] = useState<number | null>(null);
const dragStartX = useRef<number>(0);
const dragStartWidth = useRef<number>(0);
const activeBarRef = useRef<HTMLDivElement | null>(null);
const lastUpdateTime = useRef<number>(0);
const baseConversionsRef = useRef<ConversionResult[]>([]);
// Calculate percentages for visual bars using logarithmic scale
const withPercentages = useMemo(() => {
if (conversions.length === 0) return [];
// Use base conversions for scale if we're dragging (keeps scale stable)
const scaleSource = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
// Get all values from the SCALE SOURCE (not current conversions)
const values = scaleSource.map(c => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
if (maxValue === 0 || !isFinite(maxValue)) {
return conversions.map(c => ({ ...c, percentage: 0 }));
}
// Use logarithmic scale for better visualization
return conversions.map(c => {
const absValue = Math.abs(c.value);
if (absValue === 0 || !isFinite(absValue)) {
return { ...c, percentage: 2 }; // Show minimal bar
}
// Logarithmic scale
const logValue = Math.log10(absValue);
const logMax = Math.log10(maxValue);
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6; // 6 orders of magnitude range
const logRange = logMax - logMin;
let percentage: number;
if (logRange === 0) {
percentage = 100;
} else {
percentage = ((logValue - logMin) / logRange) * 100;
// Ensure bars are visible - minimum 3%, maximum 100%
percentage = Math.max(3, Math.min(100, percentage));
}
return {
...c,
percentage,
};
});
}, [conversions]);
// Calculate value from percentage (reverse logarithmic scale)
const calculateValueFromPercentage = useCallback((
percentage: number,
minValue: number,
maxValue: number
): number => {
const logMax = Math.log10(maxValue);
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
const logRange = logMax - logMin;
// Convert percentage back to log value
const logValue = logMin + (percentage / 100) * logRange;
// Convert log value back to actual value
return Math.pow(10, logValue);
}, []);
// Mouse drag handlers
const handleMouseDown = useCallback((e: React.MouseEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
if (!onValueChange) return;
e.preventDefault();
setDraggingUnit(unit);
setDraggedPercentage(currentPercentage);
dragStartX.current = e.clientX;
dragStartWidth.current = currentPercentage;
activeBarRef.current = barElement;
// Save the current conversions as reference
baseConversionsRef.current = [...conversions];
}, [onValueChange, conversions]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
// Throttle updates to every 16ms (~60fps)
const now = Date.now();
if (now - lastUpdateTime.current < 16) return;
lastUpdateTime.current = now;
const barWidth = activeBarRef.current.offsetWidth;
const deltaX = e.clientX - dragStartX.current;
const deltaPercentage = (deltaX / barWidth) * 100;
let newPercentage = dragStartWidth.current + deltaPercentage;
newPercentage = Math.max(3, Math.min(100, newPercentage));
// Update visual percentage immediately
setDraggedPercentage(newPercentage);
// Use the base conversions (from when drag started) for scale calculation
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
// Calculate min/max values for the scale from BASE conversions
const values = baseConversions.map(c => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
// Calculate new value from percentage
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
onValueChange(newValue, draggingUnit, true); // true = currently dragging
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
const handleMouseUp = useCallback(() => {
if (draggingUnit && onValueChange) {
// Find the current value for the dragged unit
const conversion = conversions.find(c => c.unit === draggingUnit);
if (conversion) {
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
}
}
setDraggingUnit(null);
// Don't clear draggedPercentage yet - let it clear when conversions update
activeBarRef.current = null;
// baseConversionsRef cleared after conversions update
}, [draggingUnit, conversions, onValueChange]);
// Touch drag handlers
const handleTouchStart = useCallback((e: React.TouchEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
if (!onValueChange) return;
const touch = e.touches[0];
setDraggingUnit(unit);
setDraggedPercentage(currentPercentage);
dragStartX.current = touch.clientX;
dragStartWidth.current = currentPercentage;
activeBarRef.current = barElement;
// Save the current conversions as reference
baseConversionsRef.current = [...conversions];
}, [onValueChange, conversions]);
const handleTouchMove = useCallback((e: TouchEvent) => {
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
// Throttle updates to every 16ms (~60fps)
const now = Date.now();
if (now - lastUpdateTime.current < 16) return;
lastUpdateTime.current = now;
e.preventDefault(); // Prevent scrolling while dragging
const touch = e.touches[0];
const barWidth = activeBarRef.current.offsetWidth;
const deltaX = touch.clientX - dragStartX.current;
const deltaPercentage = (deltaX / barWidth) * 100;
let newPercentage = dragStartWidth.current + deltaPercentage;
newPercentage = Math.max(3, Math.min(100, newPercentage));
// Update visual percentage immediately
setDraggedPercentage(newPercentage);
// Use the base conversions (from when drag started) for scale calculation
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
const values = baseConversions.map(c => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
onValueChange(newValue, draggingUnit, true); // true = currently dragging
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
const handleTouchEnd = useCallback(() => {
if (draggingUnit && onValueChange) {
// Find the current value for the dragged unit
const conversion = conversions.find(c => c.unit === draggingUnit);
if (conversion) {
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
}
}
setDraggingUnit(null);
// Don't clear draggedPercentage yet - let it clear when conversions update
activeBarRef.current = null;
// baseConversionsRef cleared after conversions update
}, [draggingUnit, conversions, onValueChange]);
// Add/remove global event listeners for drag
useEffect(() => {
if (draggingUnit) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
}, [draggingUnit, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
// Clear drag state when conversions update after drag ends
useEffect(() => {
if (!draggingUnit && draggedPercentage !== null) {
// Drag has ended, conversions have updated, now clear visual state
setDraggedPercentage(null);
baseConversionsRef.current = [];
}
}, [conversions, draggingUnit, draggedPercentage]);
if (conversions.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
Enter a value to see conversions
</div>
);
}
return (
<div className="space-y-3">
{withPercentages.map(item => {
const isDragging = draggingUnit === item.unit;
const isDraggable = !!onValueChange;
// Use draggedPercentage if this bar is being dragged
const displayPercentage = isDragging && draggedPercentage !== null ? draggedPercentage : item.percentage;
return (
<div key={item.unit} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-4">
<span className="text-sm font-medium text-foreground min-w-0 flex-shrink">
{item.unitInfo.plural}
</span>
<span className="text-lg font-bold tabular-nums flex-shrink-0">
{formatNumber(item.value)}
<span className="text-sm font-normal text-muted-foreground ml-1">
{item.unit}
</span>
</span>
</div>
{/* Progress bar */}
<div
className={cn(
"w-full h-8 bg-muted rounded-lg overflow-hidden border border-border relative",
"transition-all duration-200",
isDraggable && "cursor-grab active:cursor-grabbing",
isDragging && "ring-2 ring-ring ring-offset-2 ring-offset-background scale-105"
)}
onMouseDown={(e) => {
if (isDraggable && e.currentTarget instanceof HTMLDivElement) {
handleMouseDown(e, item.unit, item.percentage, e.currentTarget);
}
}}
onTouchStart={(e) => {
if (isDraggable && e.currentTarget instanceof HTMLDivElement) {
handleTouchStart(e, item.unit, item.percentage, e.currentTarget);
}
}}
>
{/* Colored fill */}
<div
className={cn(
"absolute inset-y-0 left-0",
draggingUnit ? "transition-none" : "transition-all duration-500 ease-out"
)}
style={{
width: `${displayPercentage}%`,
backgroundColor: color,
}}
/>
{/* Percentage label overlay */}
<div className="absolute inset-0 flex items-center px-3 text-xs font-bold pointer-events-none">
<span className="text-foreground drop-shadow-sm">
{Math.round(displayPercentage)}%
</span>
</div>
{/* Drag hint on hover */}
{isDraggable && !isDragging && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-background/10 backdrop-blur-[1px]">
<span className="text-xs font-semibold text-foreground drop-shadow-md">
Drag to adjust
</span>
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}
interface ThemeProviderState {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(
undefined
);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'units-ui-theme',
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(defaultTheme);
useEffect(() => {
// Load theme from localStorage
const stored = localStorage.getItem(storageKey) as Theme | null;
if (stored) {
setTheme(stored);
}
}, [storageKey]);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
return context;
};

View File

@@ -0,0 +1,103 @@
import { Heart, Github, Code2 } from 'lucide-react';
import Link from 'next/link';
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="border-t mt-16 bg-background">
<div className="w-full max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* About */}
<div>
<h3 className="font-semibold mb-3">Units UI</h3>
<p className="text-sm text-muted-foreground">
A spectacular unit conversion app supporting 23 measurement categories
with 187 units. Built with Next.js 16, TypeScript, and Tailwind CSS 4
</p>
</div>
{/* Features */}
<div>
<h3 className="font-semibold mb-3">Features</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li> Real-time bidirectional conversion</li>
<li> Fuzzy search across all units</li>
<li> Dark mode support</li>
<li> Conversion history</li>
<li> Keyboard shortcuts</li>
<li> Copy & favorite units</li>
</ul>
</div>
{/* Links */}
<div>
<h3 className="font-semibold mb-3">Quick Links</h3>
<ul className="space-y-2 text-sm">
<li>
<Link
href="/"
className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2"
>
Back to Kit Home
</Link>
</li>
<li>
<a
href="https://github.com/valknarness/units-ui"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2"
>
<Github className="h-4 w-4" />
GitHub Repository
</a>
</li>
<li>
<a
href="https://github.com/convert-units/convert-units"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-2"
>
<Code2 className="h-4 w-4" />
convert-units Library
</a>
</li>
</ul>
{/* Keyboard Shortcuts */}
<div className="mt-4">
<h4 className="font-semibold text-sm mb-2">Keyboard Shortcuts</h4>
<ul className="space-y-1 text-xs text-muted-foreground">
<li>
<kbd className="px-1.5 py-0.5 bg-muted rounded">/</kbd> Focus search
</li>
<li>
<kbd className="px-1.5 py-0.5 bg-muted rounded">Ctrl</kbd>
{' + '}
<kbd className="px-1.5 py-0.5 bg-muted rounded">K</kbd> Command palette
</li>
<li>
<kbd className="px-1.5 py-0.5 bg-muted rounded">ESC</kbd> Close dialogs
</li>
</ul>
</div>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-8 pt-8 border-t flex flex-col sm:flex-row justify-between items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
Made with{' '}
<Heart className="h-4 w-4 text-red-500 fill-red-500" />{' '}
using Next.js 16 & Tailwind CSS 4
</div>
<div>
© {currentYear} Units UI. All rights reserved
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,38 @@
export interface TextTemplate {
id: string;
label: string;
text: string;
category: 'greeting' | 'tech' | 'fun' | 'seasonal';
}
export const TEXT_TEMPLATES: TextTemplate[] = [
// Greetings
{ id: 'hello', label: 'Hello', text: 'Hello!', category: 'greeting' },
{ id: 'welcome', label: 'Welcome', text: 'Welcome', category: 'greeting' },
{ id: 'hello-world', label: 'Hello World', text: 'Hello World', category: 'greeting' },
// Tech
{ id: 'code', label: 'Code', text: 'CODE', category: 'tech' },
{ id: 'dev', label: 'Developer', text: 'DEV', category: 'tech' },
{ id: 'hack', label: 'Hack', text: 'HACK', category: 'tech' },
{ id: 'terminal', label: 'Terminal', text: 'Terminal', category: 'tech' },
{ id: 'git', label: 'Git', text: 'Git', category: 'tech' },
// Fun
{ id: 'awesome', label: 'Awesome', text: 'AWESOME', category: 'fun' },
{ id: 'cool', label: 'Cool', text: 'COOL', category: 'fun' },
{ id: 'epic', label: 'Epic', text: 'EPIC', category: 'fun' },
{ id: 'wow', label: 'Wow', text: 'WOW!', category: 'fun' },
// Seasonal
{ id: 'happy-birthday', label: 'Happy Birthday', text: 'Happy Birthday!', category: 'seasonal' },
{ id: 'congrats', label: 'Congrats', text: 'Congrats!', category: 'seasonal' },
{ id: 'thanks', label: 'Thanks', text: 'Thanks!', category: 'seasonal' },
];
export const TEMPLATE_CATEGORIES = [
{ id: 'greeting', label: 'Greetings', icon: '👋' },
{ id: 'tech', label: 'Tech', icon: '💻' },
{ id: 'fun', label: 'Fun', icon: '🎉' },
{ id: 'seasonal', label: 'Seasonal', icon: '🎊' },
] as const;

View File

@@ -0,0 +1,80 @@
'use client';
import figlet from 'figlet';
import type { FigletOptions } from '@/types/figlet';
import { loadFont } from './fontLoader';
/**
* Convert text to ASCII art using figlet
*/
export async function textToAscii(
text: string,
fontName: string = 'Standard',
options: FigletOptions = {}
): Promise<string> {
if (!text) {
return '';
}
try {
// Load the font
const fontData = await loadFont(fontName);
if (!fontData) {
throw new Error(`Font ${fontName} could not be loaded`);
}
// Parse and load the font into figlet
figlet.parseFont(fontName, fontData);
// Generate ASCII art
return new Promise((resolve, reject) => {
figlet.text(
text,
{
font: fontName,
horizontalLayout: options.horizontalLayout || 'default',
verticalLayout: options.verticalLayout || 'default',
width: options.width,
whitespaceBreak: options.whitespaceBreak ?? true,
},
(err, result) => {
if (err) {
reject(err);
} else {
resolve(result || '');
}
}
);
});
} catch (error) {
console.error('Error generating ASCII art:', error);
throw error;
}
}
/**
* Generate ASCII art synchronously (requires font to be pre-loaded)
*/
export function textToAsciiSync(
text: string,
fontName: string = 'Standard',
options: FigletOptions = {}
): string {
if (!text) {
return '';
}
try {
return figlet.textSync(text, {
font: fontName as any,
horizontalLayout: options.horizontalLayout || 'default',
verticalLayout: options.verticalLayout || 'default',
width: options.width,
whitespaceBreak: options.whitespaceBreak ?? true,
});
} catch (error) {
console.error('Error generating ASCII art (sync):', error);
return '';
}
}

61
lib/figlet/fontLoader.ts Normal file
View File

@@ -0,0 +1,61 @@
import type { FigletFont } from '@/types/figlet';
// Cache for loaded fonts
const fontCache = new Map<string, string>();
/**
* Get list of all available figlet fonts
*/
export async function getFontList(): Promise<FigletFont[]> {
try {
const response = await fetch('/api/fonts');
if (!response.ok) {
throw new Error('Failed to fetch font list');
}
const fonts: FigletFont[] = await response.json();
return fonts;
} catch (error) {
console.error('Error fetching font list:', error);
return [];
}
}
/**
* Load a specific font file content
*/
export async function loadFont(fontName: string): Promise<string | null> {
// Check cache first
if (fontCache.has(fontName)) {
return fontCache.get(fontName)!;
}
try {
const response = await fetch(`/fonts/figlet-fonts/${fontName}.flf`);
if (!response.ok) {
throw new Error(`Failed to load font: ${fontName}`);
}
const fontData = await response.text();
// Cache the font
fontCache.set(fontName, fontData);
return fontData;
} catch (error) {
console.error(`Error loading font ${fontName}:`, error);
return null;
}
}
/**
* Preload a font into cache
*/
export async function preloadFont(fontName: string): Promise<void> {
await loadFont(fontName);
}
/**
* Clear font cache
*/
export function clearFontCache(): void {
fontCache.clear();
}

View File

@@ -0,0 +1,34 @@
'use client';
import { useEffect } from 'react';
export interface KeyboardShortcut {
key: string;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
handler: () => void;
description: string;
}
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
for (const shortcut of shortcuts) {
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase();
const ctrlMatches = shortcut.ctrlKey ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
const metaMatches = shortcut.metaKey ? event.metaKey : true;
const shiftMatches = shortcut.shiftKey ? event.shiftKey : !event.shiftKey;
if (keyMatches && ctrlMatches && shiftMatches) {
event.preventDefault();
shortcut.handler();
break;
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [shortcuts]);
}

248
lib/pastel/api/client.ts Normal file
View File

@@ -0,0 +1,248 @@
import type {
ApiResponse,
ColorInfoRequest,
ColorInfoData,
ConvertFormatRequest,
ConvertFormatData,
ColorManipulationRequest,
ColorManipulationData,
ColorMixRequest,
ColorMixData,
RandomColorsRequest,
RandomColorsData,
DistinctColorsRequest,
DistinctColorsData,
GradientRequest,
GradientData,
ColorDistanceRequest,
ColorDistanceData,
ColorSortRequest,
ColorSortData,
ColorBlindnessRequest,
ColorBlindnessData,
TextColorRequest,
TextColorData,
NamedColorsData,
NamedColorSearchRequest,
NamedColorSearchData,
HealthData,
CapabilitiesData,
PaletteGenerateRequest,
PaletteGenerateData,
} from './types';
import { pastelWASM } from './wasm-client';
export class PastelAPIClient {
private baseURL: string;
constructor(baseURL?: string) {
// Use the Next.js API proxy route for runtime configuration
// This allows changing the backend API URL without rebuilding
this.baseURL = baseURL || '/api/pastel';
}
private async request<T>(
endpoint: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
// Endpoint already includes /api/v1 prefix on backend,
// but our proxy route expects paths after /api/v1/
const url = `${this.baseURL}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
const data = await response.json();
if (!response.ok) {
return {
success: false,
error: data.error || {
code: 'INTERNAL_ERROR',
message: 'An unknown error occurred',
},
};
}
return data;
} catch (error) {
return {
success: false,
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Network request failed',
},
};
}
}
// Color Information
async getColorInfo(request: ColorInfoRequest): Promise<ApiResponse<ColorInfoData>> {
return this.request<ColorInfoData>('/colors/info', {
method: 'POST',
body: JSON.stringify(request),
});
}
// Format Conversion
async convertFormat(request: ConvertFormatRequest): Promise<ApiResponse<ConvertFormatData>> {
return this.request<ConvertFormatData>('/colors/convert', {
method: 'POST',
body: JSON.stringify(request),
});
}
// Color Manipulation
async lighten(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/lighten', {
method: 'POST',
body: JSON.stringify(request),
});
}
async darken(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/darken', {
method: 'POST',
body: JSON.stringify(request),
});
}
async saturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/saturate', {
method: 'POST',
body: JSON.stringify(request),
});
}
async desaturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/desaturate', {
method: 'POST',
body: JSON.stringify(request),
});
}
async rotate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/rotate', {
method: 'POST',
body: JSON.stringify(request),
});
}
async complement(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/complement', {
method: 'POST',
body: JSON.stringify({ colors }),
});
}
async grayscale(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/grayscale', {
method: 'POST',
body: JSON.stringify({ colors }),
});
}
async mix(request: ColorMixRequest): Promise<ApiResponse<ColorMixData>> {
return this.request<ColorMixData>('/colors/mix', {
method: 'POST',
body: JSON.stringify(request),
});
}
// Color Generation
async generateRandom(request: RandomColorsRequest): Promise<ApiResponse<RandomColorsData>> {
return this.request<RandomColorsData>('/colors/random', {
method: 'POST',
body: JSON.stringify(request),
});
}
async generateDistinct(request: DistinctColorsRequest): Promise<ApiResponse<DistinctColorsData>> {
return this.request<DistinctColorsData>('/colors/distinct', {
method: 'POST',
body: JSON.stringify(request),
});
}
async generateGradient(request: GradientRequest): Promise<ApiResponse<GradientData>> {
return this.request<GradientData>('/colors/gradient', {
method: 'POST',
body: JSON.stringify(request),
});
}
// Color Analysis
async calculateDistance(request: ColorDistanceRequest): Promise<ApiResponse<ColorDistanceData>> {
return this.request<ColorDistanceData>('/colors/distance', {
method: 'POST',
body: JSON.stringify(request),
});
}
async sortColors(request: ColorSortRequest): Promise<ApiResponse<ColorSortData>> {
return this.request<ColorSortData>('/colors/sort', {
method: 'POST',
body: JSON.stringify(request),
});
}
// Accessibility
async simulateColorBlindness(request: ColorBlindnessRequest): Promise<ApiResponse<ColorBlindnessData>> {
return this.request<ColorBlindnessData>('/colors/colorblind', {
method: 'POST',
body: JSON.stringify(request),
});
}
async getTextColor(request: TextColorRequest): Promise<ApiResponse<TextColorData>> {
return this.request<TextColorData>('/colors/textcolor', {
method: 'POST',
body: JSON.stringify(request),
});
}
// Named Colors
async getNamedColors(): Promise<ApiResponse<NamedColorsData>> {
return this.request<NamedColorsData>('/colors/names', {
method: 'GET',
});
}
async searchNamedColors(request: NamedColorSearchRequest): Promise<ApiResponse<NamedColorSearchData>> {
return this.request<NamedColorSearchData>('/colors/names/search', {
method: 'POST',
body: JSON.stringify(request),
});
}
// System
async getHealth(): Promise<ApiResponse<HealthData>> {
return this.request<HealthData>('/health', {
method: 'GET',
});
}
async getCapabilities(): Promise<ApiResponse<CapabilitiesData>> {
return this.request<CapabilitiesData>('/capabilities', {
method: 'GET',
});
}
// Palette Generation
async generatePalette(request: PaletteGenerateRequest): Promise<ApiResponse<PaletteGenerateData>> {
return this.request<PaletteGenerateData>('/palettes/generate', {
method: 'POST',
body: JSON.stringify(request),
});
}
}
// Export singleton instance
// Now using WASM client for zero-latency, offline-first color operations
export const pastelAPI = pastelWASM;

251
lib/pastel/api/queries.ts Normal file
View File

@@ -0,0 +1,251 @@
'use client';
import { useQuery, useMutation, UseQueryOptions } from '@tanstack/react-query';
import { pastelAPI } from './client';
import type {
ColorInfoRequest,
ColorInfoData,
ConvertFormatRequest,
ConvertFormatData,
ColorManipulationRequest,
ColorManipulationData,
ColorMixRequest,
ColorMixData,
RandomColorsRequest,
RandomColorsData,
DistinctColorsRequest,
DistinctColorsData,
GradientRequest,
GradientData,
ColorBlindnessRequest,
PaletteGenerateRequest,
PaletteGenerateData,
ColorBlindnessData,
TextColorRequest,
TextColorData,
NamedColorsData,
HealthData,
} from './types';
// Color Information
export const useColorInfo = (
request: ColorInfoRequest,
options?: Omit<UseQueryOptions<ColorInfoData>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: ['colorInfo', request.colors],
queryFn: async () => {
const response = await pastelAPI.getColorInfo(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
enabled: request.colors.length > 0 && request.colors.every((c) => c.length > 0),
...options,
});
};
// Format Conversion
export const useConvertFormat = () => {
return useMutation({
mutationFn: async (request: ConvertFormatRequest) => {
const response = await pastelAPI.convertFormat(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
// Color Manipulation
export const useLighten = () => {
return useMutation({
mutationFn: async (request: ColorManipulationRequest) => {
const response = await pastelAPI.lighten(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useDarken = () => {
return useMutation({
mutationFn: async (request: ColorManipulationRequest) => {
const response = await pastelAPI.darken(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useSaturate = () => {
return useMutation({
mutationFn: async (request: ColorManipulationRequest) => {
const response = await pastelAPI.saturate(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useDesaturate = () => {
return useMutation({
mutationFn: async (request: ColorManipulationRequest) => {
const response = await pastelAPI.desaturate(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useRotate = () => {
return useMutation({
mutationFn: async (request: ColorManipulationRequest) => {
const response = await pastelAPI.rotate(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useComplement = () => {
return useMutation({
mutationFn: async (colors: string[]) => {
const response = await pastelAPI.complement(colors);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useMixColors = () => {
return useMutation({
mutationFn: async (request: ColorMixRequest) => {
const response = await pastelAPI.mix(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
// Color Generation
export const useGenerateRandom = () => {
return useMutation({
mutationFn: async (request: RandomColorsRequest) => {
const response = await pastelAPI.generateRandom(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useGenerateDistinct = () => {
return useMutation({
mutationFn: async (request: DistinctColorsRequest) => {
const response = await pastelAPI.generateDistinct(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useGenerateGradient = () => {
return useMutation({
mutationFn: async (request: GradientRequest) => {
const response = await pastelAPI.generateGradient(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
// Color Blindness Simulation
export const useSimulateColorBlindness = () => {
return useMutation({
mutationFn: async (request: ColorBlindnessRequest) => {
const response = await pastelAPI.simulateColorBlindness(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
// Text Color Optimizer
export const useTextColor = () => {
return useMutation({
mutationFn: async (request: TextColorRequest) => {
const response = await pastelAPI.getTextColor(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
// Named Colors
export const useNamedColors = () => {
return useQuery({
queryKey: ['namedColors'],
queryFn: async () => {
const response = await pastelAPI.getNamedColors();
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
staleTime: Infinity, // Named colors never change
});
};
// Health Check
export const useHealth = () => {
return useQuery({
queryKey: ['health'],
queryFn: async () => {
const response = await pastelAPI.getHealth();
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
refetchInterval: 60000, // Check every minute
});
};
// Palette Generation
export const useGeneratePalette = () => {
return useMutation({
mutationFn: async (request: PaletteGenerateRequest) => {
const response = await pastelAPI.generatePalette(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};

268
lib/pastel/api/types.ts Normal file
View File

@@ -0,0 +1,268 @@
// API Response Types
export interface SuccessResponse<T> {
success: true;
data: T;
}
export interface ErrorResponse {
success: false;
error: {
code: string;
message: string;
details?: string;
};
}
export type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
// Color Component Types
export interface RGBColor {
r: number;
g: number;
b: number;
a?: number;
}
export interface HSLColor {
h: number;
s: number;
l: number;
a?: number;
}
export interface HSVColor {
h: number;
s: number;
v: number;
}
export interface LabColor {
l: number;
a: number;
b: number;
}
export interface OkLabColor {
l: number;
a: number;
b: number;
}
export interface LCHColor {
l: number;
c: number;
h: number;
}
export interface OkLCHColor {
l: number;
c: number;
h: number;
}
export interface CMYKColor {
c: number;
m: number;
y: number;
k: number;
}
// Color Information
export interface ColorInfo {
input: string;
hex: string;
rgb: RGBColor;
hsl: HSLColor;
hsv: HSVColor;
lab: LabColor;
oklab: OkLabColor;
lch: LCHColor;
oklch: OkLCHColor;
cmyk: CMYKColor;
gray?: number;
brightness: number;
luminance: number;
is_light: boolean;
name?: string;
distance_to_named?: number;
}
// Request/Response Types for Each Endpoint
export interface ColorInfoRequest {
colors: string[];
}
export interface ColorInfoData {
colors: ColorInfo[];
}
export interface ConvertFormatRequest {
colors: string[];
format: 'hex' | 'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch' | 'cmyk' | 'gray';
}
export interface ConvertFormatData {
conversions: Array<{
input: string;
output: string;
}>;
}
export interface ColorManipulationRequest {
colors: string[];
amount: number;
}
export interface ColorManipulationData {
operation?: string;
amount?: number;
colors: Array<{
input: string;
output: string;
}>;
}
export interface ColorMixRequest {
colors: string[];
fraction: number;
colorspace?: 'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch';
}
export interface ColorMixData {
results: Array<{
color1: string;
color2: string;
mixed: string;
}>;
}
export interface RandomColorsRequest {
count: number;
strategy?: 'vivid' | 'rgb' | 'gray' | 'lch';
}
export interface RandomColorsData {
colors: string[];
}
export interface DistinctColorsRequest {
count: number;
metric?: 'cie76' | 'ciede2000';
fixed_colors?: string[];
}
export interface DistinctColorsData {
colors: string[];
stats: {
min_distance: number;
avg_distance: number;
generation_time_ms: number;
};
}
export interface GradientRequest {
stops: string[];
count: number;
colorspace?: 'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch';
}
export interface GradientData {
stops: string[];
count: number;
colorspace: string;
gradient: string[];
}
export interface ColorDistanceRequest {
color1: string;
color2: string;
metric: 'cie76' | 'ciede2000';
}
export interface ColorDistanceData {
color1: string;
color2: string;
distance: number;
metric: string;
}
export interface ColorSortRequest {
colors: string[];
order: 'hue' | 'brightness' | 'luminance' | 'chroma';
}
export interface ColorSortData {
sorted: string[];
}
export interface ColorBlindnessRequest {
colors: string[];
type: 'protanopia' | 'deuteranopia' | 'tritanopia';
}
export interface ColorBlindnessData {
type: string;
colors: Array<{
input: string;
output: string;
difference_percentage: number;
}>;
}
export interface TextColorRequest {
backgrounds: string[];
}
export interface TextColorData {
colors: Array<{
background: string;
textcolor: string;
contrast_ratio: number;
wcag_aa: boolean;
wcag_aaa: boolean;
}>;
}
export interface NamedColor {
name: string;
hex: string;
}
export interface NamedColorsData {
colors: NamedColor[];
}
export interface NamedColorSearchRequest {
query: string;
}
export interface NamedColorSearchData {
results: NamedColor[];
}
export interface HealthData {
status: string;
version: string;
}
export interface CapabilitiesData {
endpoints: string[];
formats: string[];
color_spaces: string[];
distance_metrics: string[];
colorblindness_types: string[];
}
export interface PaletteGenerateRequest {
base: string;
scheme: 'monochromatic' | 'analogous' | 'complementary' | 'split-complementary' | 'triadic' | 'tetradic';
}
export interface PaletteGenerateData {
base: string;
scheme: string;
palette: {
primary: string;
secondary: string[];
};
}

View File

@@ -0,0 +1,484 @@
import {
init,
parse_color,
lighten_color,
darken_color,
saturate_color,
desaturate_color,
rotate_hue,
complement_color,
mix_colors,
get_text_color,
calculate_contrast,
simulate_protanopia,
simulate_deuteranopia,
simulate_tritanopia,
color_distance,
generate_random_colors,
generate_gradient,
generate_palette,
get_all_named_colors,
search_named_colors,
version,
} from '@valknarthing/pastel-wasm';
import type {
ApiResponse,
ColorInfoRequest,
ColorInfoData,
ConvertFormatRequest,
ConvertFormatData,
ColorManipulationRequest,
ColorManipulationData,
ColorMixRequest,
ColorMixData,
RandomColorsRequest,
RandomColorsData,
DistinctColorsRequest,
DistinctColorsData,
GradientRequest,
GradientData,
ColorDistanceRequest,
ColorDistanceData,
ColorSortRequest,
ColorSortData,
ColorBlindnessRequest,
ColorBlindnessData,
TextColorRequest,
TextColorData,
NamedColorsData,
NamedColorSearchRequest,
NamedColorSearchData,
HealthData,
CapabilitiesData,
PaletteGenerateRequest,
PaletteGenerateData,
} from './types';
// Initialize WASM module
let wasmInitialized = false;
async function ensureWasmInit() {
if (!wasmInitialized) {
init(); // Initialize panic hook
wasmInitialized = true;
}
}
/**
* WASM-based Pastel client
* Provides the same interface as PastelAPIClient but uses WebAssembly
* Zero network latency, works offline!
*/
export class PastelWASMClient {
constructor() {
// Initialize WASM eagerly
ensureWasmInit().catch(console.error);
}
private async request<T>(fn: () => T): Promise<ApiResponse<T>> {
try {
await ensureWasmInit();
const data = fn();
return {
success: true,
data,
};
} catch (error) {
return {
success: false,
error: {
code: 'WASM_ERROR',
message: error instanceof Error ? error.message : 'Unknown error',
},
};
}
}
// Color Information
async getColorInfo(request: ColorInfoRequest): Promise<ApiResponse<ColorInfoData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => {
const info = parse_color(colorStr);
return {
input: info.input,
hex: info.hex,
rgb: {
r: info.rgb[0],
g: info.rgb[1],
b: info.rgb[2],
},
hsl: {
h: info.hsl[0],
s: info.hsl[1],
l: info.hsl[2],
},
hsv: {
h: info.hsv[0],
s: info.hsv[1],
v: info.hsv[2],
},
lab: {
l: info.lab[0],
a: info.lab[1],
b: info.lab[2],
},
oklab: {
l: info.lab[0] / 100.0,
a: info.lab[1] / 100.0,
b: info.lab[2] / 100.0,
},
lch: {
l: info.lch[0],
c: info.lch[1],
h: info.lch[2],
},
oklch: {
l: info.lch[0] / 100.0,
c: info.lch[1] / 100.0,
h: info.lch[2],
},
cmyk: {
c: 0,
m: 0,
y: 0,
k: 0,
},
brightness: info.brightness,
luminance: info.luminance,
is_light: info.is_light,
};
});
return { colors };
});
}
// Format Conversion
async convertFormat(request: ConvertFormatRequest): Promise<ApiResponse<ConvertFormatData>> {
return this.request(() => {
const conversions = request.colors.map((colorStr) => {
const parsed = parse_color(colorStr);
let output: string;
switch (request.format) {
case 'hex':
output = parsed.hex;
break;
case 'rgb':
output = `rgb(${parsed.rgb[0]}, ${parsed.rgb[1]}, ${parsed.rgb[2]})`;
break;
case 'hsl':
output = `hsl(${parsed.hsl[0].toFixed(1)}, ${(parsed.hsl[1] * 100).toFixed(1)}%, ${(parsed.hsl[2] * 100).toFixed(1)}%)`;
break;
case 'hsv':
output = `hsv(${parsed.hsv[0].toFixed(1)}, ${(parsed.hsv[1] * 100).toFixed(1)}%, ${(parsed.hsv[2] * 100).toFixed(1)}%)`;
break;
case 'lab':
output = `lab(${parsed.lab[0].toFixed(2)}, ${parsed.lab[1].toFixed(2)}, ${parsed.lab[2].toFixed(2)})`;
break;
case 'lch':
output = `lch(${parsed.lch[0].toFixed(2)}, ${parsed.lch[1].toFixed(2)}, ${parsed.lch[2].toFixed(2)})`;
break;
default:
output = parsed.hex;
}
return {
input: colorStr,
output,
};
});
return { conversions };
});
}
// Color Manipulation
async lighten(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => ({
input: colorStr,
output: lighten_color(colorStr, request.amount),
}));
return { operation: 'lighten', amount: request.amount, colors };
});
}
async darken(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => ({
input: colorStr,
output: darken_color(colorStr, request.amount),
}));
return { operation: 'darken', amount: request.amount, colors };
});
}
async saturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => ({
input: colorStr,
output: saturate_color(colorStr, request.amount),
}));
return { operation: 'saturate', amount: request.amount, colors };
});
}
async desaturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => ({
input: colorStr,
output: desaturate_color(colorStr, request.amount),
}));
return { operation: 'desaturate', amount: request.amount, colors };
});
}
async rotate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => ({
input: colorStr,
output: rotate_hue(colorStr, request.amount),
}));
return { operation: 'rotate', amount: request.amount, colors };
});
}
async complement(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const results = colors.map((colorStr) => ({
input: colorStr,
output: complement_color(colorStr),
}));
return { operation: 'complement', colors: results };
});
}
async grayscale(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const results = colors.map((colorStr) => ({
input: colorStr,
output: desaturate_color(colorStr, 1.0),
}));
return { operation: 'grayscale', colors: results };
});
}
async mix(request: ColorMixRequest): Promise<ApiResponse<ColorMixData>> {
return this.request(() => {
// Mix pairs of colors
const results = [];
for (let i = 0; i < request.colors.length - 1; i += 2) {
const color1 = request.colors[i];
const color2 = request.colors[i + 1];
const mixed = mix_colors(color1, color2, request.fraction);
results.push({ color1, color2, mixed });
}
return { results };
});
}
// Color Generation
async generateRandom(request: RandomColorsRequest): Promise<ApiResponse<RandomColorsData>> {
return this.request(() => {
const vivid = request.strategy === 'vivid' || request.strategy === 'lch';
const colors = generate_random_colors(request.count, vivid);
return { colors };
});
}
async generateDistinct(request: DistinctColorsRequest): Promise<ApiResponse<DistinctColorsData>> {
return this.request(() => {
// Note: WASM version doesn't support distinct colors with simulated annealing yet
// Fall back to vivid random colors
const colors = generate_random_colors(request.count, true);
return {
colors,
stats: {
min_distance: 0,
avg_distance: 0,
generation_time_ms: 0,
},
};
});
}
async generateGradient(request: GradientRequest): Promise<ApiResponse<GradientData>> {
return this.request(() => {
if (request.stops.length < 2) {
throw new Error('At least 2 color stops are required');
}
// For 2 stops, use the WASM gradient function
if (request.stops.length === 2) {
const gradient = generate_gradient(request.stops[0], request.stops[1], request.count);
return {
stops: request.stops,
count: request.count,
colorspace: request.colorspace || 'rgb',
gradient,
};
}
// For multiple stops, interpolate segments
const segments = request.stops.length - 1;
const colorsPerSegment = Math.floor(request.count / segments);
const gradient: string[] = [];
for (let i = 0; i < segments; i++) {
const segmentColors = generate_gradient(
request.stops[i],
request.stops[i + 1],
i === segments - 1 ? request.count - gradient.length : colorsPerSegment
);
gradient.push(...segmentColors.slice(0, -1)); // Avoid duplicates
}
gradient.push(request.stops[request.stops.length - 1]);
return {
stops: request.stops,
count: request.count,
colorspace: request.colorspace || 'rgb',
gradient,
};
});
}
// Color Analysis
async calculateDistance(request: ColorDistanceRequest): Promise<ApiResponse<ColorDistanceData>> {
return this.request(() => {
const useCiede2000 = request.metric === 'ciede2000';
const distance = color_distance(request.color1, request.color2, useCiede2000);
return {
color1: request.color1,
color2: request.color2,
distance,
metric: request.metric,
};
});
}
async sortColors(request: ColorSortRequest): Promise<ApiResponse<ColorSortData>> {
return this.request(() => {
// Note: WASM version doesn't support sorting yet
// Return colors as-is for now
return { sorted: request.colors };
});
}
// Accessibility
async simulateColorBlindness(request: ColorBlindnessRequest): Promise<ApiResponse<ColorBlindnessData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => {
let output: string;
switch (request.type) {
case 'protanopia':
output = simulate_protanopia(colorStr);
break;
case 'deuteranopia':
output = simulate_deuteranopia(colorStr);
break;
case 'tritanopia':
output = simulate_tritanopia(colorStr);
break;
default:
output = colorStr;
}
const distance = color_distance(colorStr, output, true);
return {
input: colorStr,
output,
difference_percentage: (distance / 100.0) * 100.0,
};
});
return { type: request.type, colors };
});
}
async getTextColor(request: TextColorRequest): Promise<ApiResponse<TextColorData>> {
return this.request(() => {
const colors = request.backgrounds.map((bg) => {
const textColor = get_text_color(bg);
const contrastRatio = calculate_contrast(bg, textColor);
return {
background: bg,
textcolor: textColor,
contrast_ratio: contrastRatio,
wcag_aa: contrastRatio >= 4.5,
wcag_aaa: contrastRatio >= 7.0,
};
});
return { colors };
});
}
// Named Colors
async getNamedColors(): Promise<ApiResponse<NamedColorsData>> {
return this.request(() => {
const colors = get_all_named_colors();
return { colors };
});
}
async searchNamedColors(request: NamedColorSearchRequest): Promise<ApiResponse<NamedColorSearchData>> {
return this.request(() => {
const results = search_named_colors(request.query);
return { results };
});
}
// System
async getHealth(): Promise<ApiResponse<HealthData>> {
return this.request(() => ({
status: 'healthy',
version: version(),
}));
}
async getCapabilities(): Promise<ApiResponse<CapabilitiesData>> {
return this.request(() => ({
endpoints: [
'colors/info',
'colors/convert',
'colors/lighten',
'colors/darken',
'colors/saturate',
'colors/desaturate',
'colors/rotate',
'colors/complement',
'colors/grayscale',
'colors/mix',
'colors/random',
'colors/gradient',
'colors/colorblind',
'colors/textcolor',
'colors/distance',
'colors/names',
],
formats: ['hex', 'rgb', 'hsl', 'hsv', 'lab', 'lch'],
color_spaces: ['rgb', 'hsl', 'hsv', 'lab', 'lch'],
distance_metrics: ['cie76', 'ciede2000'],
colorblindness_types: ['protanopia', 'deuteranopia', 'tritanopia'],
}));
}
// Palette Generation
async generatePalette(request: PaletteGenerateRequest): Promise<ApiResponse<PaletteGenerateData>> {
return this.request(() => {
const colors = generate_palette(request.base, request.scheme);
return {
base: request.base,
scheme: request.scheme,
palette: {
primary: colors[0],
secondary: colors.slice(1),
},
};
});
}
}
// Export singleton instance
export const pastelWASM = new PastelWASMClient();

View File

@@ -0,0 +1,106 @@
import { useEffect } from 'react';
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
meta?: boolean;
handler: (event: KeyboardEvent) => void;
description?: string;
}
/**
* Hook to register keyboard shortcuts
*
* @example
* ```tsx
* useKeyboard([
* {
* key: 'c',
* meta: true, // Cmd on Mac, Ctrl on Windows
* handler: () => copyToClipboard(),
* description: 'Copy color',
* },
* {
* key: 'k',
* meta: true,
* handler: () => openCommandPalette(),
* description: 'Open command palette',
* },
* ]);
* ```
*/
export function useKeyboard(shortcuts: KeyboardShortcut[]) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
for (const shortcut of shortcuts) {
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase();
// Check if required modifiers match (only check if explicitly required)
const ctrlMatches = shortcut.ctrl === true ? event.ctrlKey : true;
const shiftMatches = shortcut.shift === true ? event.shiftKey : true;
const altMatches = shortcut.alt === true ? event.altKey : true;
// Handle meta/cmd key with cross-platform support
let metaMatches = true;
if (shortcut.meta === true) {
// On Mac: require Cmd key
// On Windows/Linux: accept Ctrl key as Cmd equivalent
const isMac = navigator.platform.includes('Mac');
metaMatches = isMac ? event.metaKey : event.ctrlKey;
}
// Ensure unwanted modifiers are not pressed (unless explicitly required)
const noExtraCtrl = shortcut.ctrl === true || shortcut.meta === true || !event.ctrlKey;
const noExtraShift = shortcut.shift === true || !event.shiftKey;
const noExtraAlt = shortcut.alt === true || !event.altKey;
const noExtraMeta = shortcut.meta === true || !event.metaKey;
if (
keyMatches &&
ctrlMatches &&
shiftMatches &&
altMatches &&
metaMatches &&
noExtraCtrl &&
noExtraShift &&
noExtraAlt &&
noExtraMeta
) {
event.preventDefault();
shortcut.handler(event);
break;
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [shortcuts]);
}
/**
* Hook to register a single keyboard shortcut (convenience wrapper)
*/
export function useKeyboardShortcut(
key: string,
handler: (event: KeyboardEvent) => void,
modifiers?: {
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
meta?: boolean;
}
) {
useKeyboard([
{
key,
...modifiers,
handler,
},
]);
}

5
lib/pastel/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from './api/queries';
export * from './stores/historyStore';
export * from './hooks/useKeyboard';
export * from './utils/color';
export * from './utils/export';

View File

@@ -0,0 +1,68 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
export interface ColorHistoryEntry {
color: string;
timestamp: number;
}
interface ColorHistoryState {
history: ColorHistoryEntry[];
addColor: (color: string) => void;
removeColor: (color: string) => void;
clearHistory: () => void;
getRecent: (limit?: number) => ColorHistoryEntry[];
}
/**
* Color history store with localStorage persistence
*
* Tracks up to 50 most recent colors with timestamps
* Automatically removes duplicates (keeps most recent)
* Persists across browser sessions
*/
export const useColorHistory = create<ColorHistoryState>()(
persist(
(set, get) => ({
history: [],
addColor: (color) => {
const normalizedColor = color.toLowerCase();
set((state) => {
// Remove existing entry if present
const filtered = state.history.filter(
(entry) => entry.color.toLowerCase() !== normalizedColor
);
// Add new entry at the beginning
const newHistory = [
{ color: normalizedColor, timestamp: Date.now() },
...filtered,
].slice(0, 50); // Keep only 50 most recent
return { history: newHistory };
});
},
removeColor: (color) => {
const normalizedColor = color.toLowerCase();
set((state) => ({
history: state.history.filter(
(entry) => entry.color.toLowerCase() !== normalizedColor
),
}));
},
clearHistory: () => set({ history: [] }),
getRecent: (limit = 10) => {
const { history } = get();
return history.slice(0, limit);
},
}),
{
name: 'pastel-color-history',
storage: createJSONStorage(() => localStorage),
}
)
);

6
lib/pastel/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

57
lib/pastel/utils/color.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Calculate relative luminance of a color
* Based on WCAG 2.1 specification
*/
export function getRelativeLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
const sRGB = c / 255;
return sRGB <= 0.03928 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
/**
* Calculate contrast ratio between two colors
* Returns ratio from 1 to 21
*/
export function getContrastRatio(
fg: { r: number; g: number; b: number },
bg: { r: number; g: number; b: number }
): number {
const l1 = getRelativeLuminance(fg.r, fg.g, fg.b);
const l2 = getRelativeLuminance(bg.r, bg.g, bg.b);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Parse hex color to RGB
*/
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Check if a contrast ratio meets WCAG standards
*/
export function checkWCAGCompliance(ratio: number) {
return {
aa: {
normalText: ratio >= 4.5,
largeText: ratio >= 3,
ui: ratio >= 3,
},
aaa: {
normalText: ratio >= 7,
largeText: ratio >= 4.5,
},
};
}

View File

@@ -0,0 +1,83 @@
/**
* Export utilities for color palettes
*/
export interface ExportColor {
name?: string;
hex: string;
}
/**
* Export colors as CSS variables
*/
export function exportAsCSS(colors: ExportColor[]): string {
const variables = colors
.map((color, index) => {
const name = color.name || `color-${index + 1}`;
return ` --${name}: ${color.hex};`;
})
.join('\n');
return `:root {\n${variables}\n}`;
}
/**
* Export colors as SCSS variables
*/
export function exportAsSCSS(colors: ExportColor[]): string {
return colors
.map((color, index) => {
const name = color.name || `color-${index + 1}`;
return `$${name}: ${color.hex};`;
})
.join('\n');
}
/**
* Export colors as Tailwind config
*/
export function exportAsTailwind(colors: ExportColor[]): string {
const colorEntries = colors
.map((color, index) => {
const name = color.name || `color-${index + 1}`;
return ` '${name}': '${color.hex}',`;
})
.join('\n');
return `module.exports = {\n theme: {\n extend: {\n colors: {\n${colorEntries}\n },\n },\n },\n};`;
}
/**
* Export colors as JSON
*/
export function exportAsJSON(colors: ExportColor[]): string {
const colorObjects = colors.map((color, index) => ({
name: color.name || `color-${index + 1}`,
hex: color.hex,
}));
return JSON.stringify({ colors: colorObjects }, null, 2);
}
/**
* Export colors as JavaScript array
*/
export function exportAsJavaScript(colors: ExportColor[]): string {
const colorArray = colors.map((c) => `'${c.hex}'`).join(', ');
return `const colors = [${colorArray}];`;
}
/**
* Download text as file
*/
export function downloadAsFile(content: string, filename: string, mimeType: string = 'text/plain') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}

70
lib/storage/favorites.ts Normal file
View File

@@ -0,0 +1,70 @@
'use client';
const FAVORITES_KEY = 'figlet-ui-favorites';
const RECENT_FONTS_KEY = 'figlet-ui-recent-fonts';
const MAX_RECENT = 10;
export function getFavorites(): string[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(FAVORITES_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
export function addFavorite(fontName: string): void {
const favorites = getFavorites();
if (!favorites.includes(fontName)) {
favorites.push(fontName);
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
}
}
export function removeFavorite(fontName: string): void {
const favorites = getFavorites();
const filtered = favorites.filter(f => f !== fontName);
localStorage.setItem(FAVORITES_KEY, JSON.stringify(filtered));
}
export function isFavorite(fontName: string): boolean {
return getFavorites().includes(fontName);
}
export function toggleFavorite(fontName: string): boolean {
const isCurrentlyFavorite = isFavorite(fontName);
if (isCurrentlyFavorite) {
removeFavorite(fontName);
} else {
addFavorite(fontName);
}
return !isCurrentlyFavorite;
}
export function getRecentFonts(): string[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(RECENT_FONTS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
export function addRecentFont(fontName: string): void {
let recent = getRecentFonts();
// Remove if already exists
recent = recent.filter(f => f !== fontName);
// Add to beginning
recent.unshift(fontName);
// Keep only MAX_RECENT items
recent = recent.slice(0, MAX_RECENT);
localStorage.setItem(RECENT_FONTS_KEY, JSON.stringify(recent));
}

53
lib/storage/history.ts Normal file
View File

@@ -0,0 +1,53 @@
'use client';
export interface HistoryItem {
id: string;
text: string;
font: string;
result: string;
timestamp: number;
}
const HISTORY_KEY = 'figlet-ui-history';
const MAX_HISTORY = 10;
export function getHistory(): HistoryItem[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(HISTORY_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
export function addToHistory(text: string, font: string, result: string): void {
let history = getHistory();
const newItem: HistoryItem = {
id: `${Date.now()}-${Math.random()}`,
text,
font,
result,
timestamp: Date.now(),
};
// Add to beginning
history.unshift(newItem);
// Keep only MAX_HISTORY items
history = history.slice(0, MAX_HISTORY);
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}
export function clearHistory(): void {
localStorage.removeItem(HISTORY_KEY);
}
export function removeHistoryItem(id: string): void {
const history = getHistory();
const filtered = history.filter(item => item.id !== id);
localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered));
}

4
lib/units/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './units';
export * from './storage';
export * from './utils';
export * from './tempo';

115
lib/units/storage.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* LocalStorage utilities for persisting user data
*/
export interface ConversionRecord {
id: string;
timestamp: number;
from: {
value: number;
unit: string;
};
to: {
value: number;
unit: string;
};
measure: string;
}
const HISTORY_KEY = 'units-ui-history';
const FAVORITES_KEY = 'units-ui-favorites';
const MAX_HISTORY = 50;
/**
* Save conversion to history
*/
export function saveToHistory(record: Omit<ConversionRecord, 'id' | 'timestamp'>): void {
if (typeof window === 'undefined') return;
const history = getHistory();
const newRecord: ConversionRecord = {
...record,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
// Add to beginning and limit size
const updated = [newRecord, ...history].slice(0, MAX_HISTORY);
localStorage.setItem(HISTORY_KEY, JSON.stringify(updated));
}
/**
* Get conversion history
*/
export function getHistory(): ConversionRecord[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(HISTORY_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
* Clear conversion history
*/
export function clearHistory(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(HISTORY_KEY);
}
/**
* Get favorite units
*/
export function getFavorites(): string[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(FAVORITES_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
* Add unit to favorites
*/
export function addToFavorites(unit: string): void {
if (typeof window === 'undefined') return;
const favorites = getFavorites();
if (!favorites.includes(unit)) {
favorites.push(unit);
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
}
}
/**
* Remove unit from favorites
*/
export function removeFromFavorites(unit: string): void {
if (typeof window === 'undefined') return;
const favorites = getFavorites();
const filtered = favorites.filter(u => u !== unit);
localStorage.setItem(FAVORITES_KEY, JSON.stringify(filtered));
}
/**
* Toggle favorite status
*/
export function toggleFavorite(unit: string): boolean {
const favorites = getFavorites();
const isFavorite = favorites.includes(unit);
if (isFavorite) {
removeFromFavorites(unit);
return false;
} else {
addToFavorites(unit);
return true;
}
}

117
lib/units/tempo.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Custom tempo/BPM measure for convert-units
*
* Converts between BPM and note durations in milliseconds
* Uses a reciprocal relationship where BPM (beats per minute) is the base unit
*
* Formula: milliseconds per beat = 60000 / BPM
*
* The to_anchor value represents the conversion factor:
* - For BPM → time units: multiply by to_anchor to get milliseconds
* - For time units → BPM: divide by to_anchor to get BPM
*/
export const tempoMeasure = {
tempo: {
systems: {
metric: {
// BPM as the base unit (1 BPM = 60000 ms per beat)
'BPM': {
name: { singular: 'Beat per Minute', plural: 'Beats per Minute' },
to_anchor: 1
},
// Whole note (4 beats) = 240000 / BPM
'whole': {
name: { singular: 'Whole Note (ms)', plural: 'Whole Note (ms)' },
to_anchor: 240000
},
// Half note (2 beats) = 120000 / BPM
'half': {
name: { singular: 'Half Note (ms)', plural: 'Half Note (ms)' },
to_anchor: 120000
},
// Quarter note (1 beat) = 60000 / BPM
'quarter': {
name: { singular: 'Quarter Note (ms)', plural: 'Quarter Note (ms)' },
to_anchor: 60000
},
// Eighth note (0.5 beats) = 30000 / BPM
'eighth': {
name: { singular: 'Eighth Note (ms)', plural: 'Eighth Note (ms)' },
to_anchor: 30000
},
// Sixteenth note (0.25 beats) = 15000 / BPM
'sixteenth': {
name: { singular: 'Sixteenth Note (ms)', plural: 'Sixteenth Note (ms)' },
to_anchor: 15000
},
// Thirty-second note (0.125 beats) = 7500 / BPM
'thirty-second': {
name: { singular: 'Thirty-Second Note (ms)', plural: 'Thirty-Second Note (ms)' },
to_anchor: 7500
},
// Dotted notes (1.5x the duration)
'dotted-half': {
name: { singular: 'Dotted Half Note (ms)', plural: 'Dotted Half Note (ms)' },
to_anchor: 180000 // 3 beats
},
'dotted-quarter': {
name: { singular: 'Dotted Quarter Note (ms)', plural: 'Dotted Quarter Note (ms)' },
to_anchor: 90000 // 1.5 beats
},
'dotted-eighth': {
name: { singular: 'Dotted Eighth Note (ms)', plural: 'Dotted Eighth Note (ms)' },
to_anchor: 45000 // 0.75 beats
},
'dotted-sixteenth': {
name: { singular: 'Dotted Sixteenth Note (ms)', plural: 'Dotted Sixteenth Note (ms)' },
to_anchor: 22500 // 0.375 beats
},
// Triplet notes (2/3 of the duration)
'quarter-triplet': {
name: { singular: 'Quarter Triplet (ms)', plural: 'Quarter Triplet (ms)' },
to_anchor: 40000 // 2/3 beat
},
'eighth-triplet': {
name: { singular: 'Eighth Triplet (ms)', plural: 'Eighth Triplet (ms)' },
to_anchor: 20000 // 1/3 beat
},
'sixteenth-triplet': {
name: { singular: 'Sixteenth Triplet (ms)', plural: 'Sixteenth Triplet (ms)' },
to_anchor: 10000 // 1/6 beat
},
// Milliseconds as direct time unit
'ms': {
name: { singular: 'Millisecond', plural: 'Milliseconds' },
to_anchor: 60000 // Same as quarter note
},
// Seconds
's': {
name: { singular: 'Second', plural: 'Seconds' },
to_anchor: 60 // 60 seconds per beat at 1 BPM
},
// Hertz (beats per second)
'Hz': {
name: { singular: 'Hertz', plural: 'Hertz' },
to_anchor: 1 / 60 // 1 BPM = 1/60 Hz
}
}
}
}
};

306
lib/units/units.ts Normal file
View File

@@ -0,0 +1,306 @@
/**
* Unit conversion service wrapper for convert-units library
* Provides type-safe conversion utilities and metadata
*/
import convert from 'convert-units';
import { tempoMeasure } from './tempo';
export type Measure =
| 'angle'
| 'apparentPower'
| 'area'
| 'current'
| 'digital'
| 'each'
| 'energy'
| 'frequency'
| 'illuminance'
| 'length'
| 'mass'
| 'pace'
| 'partsPer'
| 'power'
| 'pressure'
| 'reactiveEnergy'
| 'reactivePower'
| 'speed'
| 'temperature'
| 'tempo'
| 'time'
| 'voltage'
| 'volume'
| 'volumeFlowRate';
export interface UnitInfo {
abbr: string;
measure: Measure;
system: 'metric' | 'imperial' | 'bits' | 'bytes' | string;
singular: string;
plural: string;
}
export interface ConversionResult {
value: number;
unit: string;
unitInfo: UnitInfo;
}
/**
* Get all available measures/categories
*/
export function getAllMeasures(): Measure[] {
const standardMeasures = convert().measures() as Measure[];
return [...standardMeasures, 'tempo'];
}
/**
* Get all units for a specific measure
*/
export function getUnitsForMeasure(measure: Measure): string[] {
if (measure === 'tempo') {
return Object.keys(tempoMeasure.tempo.systems.metric);
}
return convert().possibilities(measure);
}
/**
* Get detailed information about a unit
*/
export function getUnitInfo(unit: string): UnitInfo | null {
try {
// Check if it's a tempo unit
const tempoUnits = tempoMeasure.tempo.systems.metric;
if (unit in tempoUnits) {
const tempoUnit = tempoUnits[unit as keyof typeof tempoUnits];
return {
abbr: unit,
measure: 'tempo',
system: 'metric',
singular: tempoUnit.name.singular,
plural: tempoUnit.name.plural,
};
}
const description = convert().describe(unit);
return description as UnitInfo;
} catch {
return null;
}
}
/**
* Convert a value from one unit to another
*/
export function convertUnit(
value: number,
fromUnit: string,
toUnit: string
): number {
try {
// Handle tempo conversions
const tempoUnits = tempoMeasure.tempo.systems.metric;
const isFromTempo = fromUnit in tempoUnits;
const isToTempo = toUnit in tempoUnits;
if (isFromTempo && isToTempo) {
// Same unit - no conversion needed
if (fromUnit === toUnit) {
return value;
}
const fromAnchor = tempoUnits[fromUnit as keyof typeof tempoUnits].to_anchor;
const toAnchor = tempoUnits[toUnit as keyof typeof tempoUnits].to_anchor;
// Special handling for BPM conversions (reciprocal relationship)
if (fromUnit === 'BPM') {
// BPM → time unit: divide anchor by BPM value
// Example: 120 BPM → quarter = 60000 / 120 = 500ms
return toAnchor / value;
} else if (toUnit === 'BPM') {
// Time unit → BPM: divide anchor by time value
// Example: 500ms quarter → BPM = 60000 / 500 = 120
return fromAnchor / value;
} else {
// Time unit → time unit: convert through BPM
// Example: 500ms quarter → eighth
// Step 1: 500ms → BPM = 60000 / 500 = 120
// Step 2: 120 BPM → eighth = 30000 / 120 = 250ms
const bpm = fromAnchor / value;
return toAnchor / bpm;
}
}
return convert(value).from(fromUnit).to(toUnit);
} catch (error) {
console.error('Conversion error:', error);
return value;
}
}
/**
* Convert a value to all compatible units in the same measure
*/
export function convertToAll(
value: number,
fromUnit: string
): ConversionResult[] {
try {
const unitInfo = getUnitInfo(fromUnit);
if (!unitInfo) return [];
const compatibleUnits = getUnitsForMeasure(unitInfo.measure);
return compatibleUnits.map(toUnit => {
const convertedValue = convertUnit(value, fromUnit, toUnit);
const toUnitInfo = getUnitInfo(toUnit);
return {
value: convertedValue,
unit: toUnit,
unitInfo: toUnitInfo!,
};
});
} catch (error) {
console.error('Conversion error:', error);
return [];
}
}
/**
* Get category color for a measure (Tailwind class name)
*/
export function getCategoryColor(measure: Measure): string {
const colorMap: Record<Measure, string> = {
angle: 'category-angle',
apparentPower: 'category-apparent-power',
area: 'category-area',
current: 'category-current',
digital: 'category-digital',
each: 'category-each',
energy: 'category-energy',
frequency: 'category-frequency',
illuminance: 'category-illuminance',
length: 'category-length',
mass: 'category-mass',
pace: 'category-pace',
partsPer: 'category-parts-per',
power: 'category-power',
pressure: 'category-pressure',
reactiveEnergy: 'category-reactive-energy',
reactivePower: 'category-reactive-power',
speed: 'category-speed',
temperature: 'category-temperature',
tempo: 'category-tempo',
time: 'category-time',
voltage: 'category-voltage',
volume: 'category-volume',
volumeFlowRate: 'category-volume-flow-rate',
};
return colorMap[measure];
}
/**
* Get category color hex value for a measure
*/
export function getCategoryColorHex(measure: Measure): string {
const colorMap: Record<Measure, string> = {
angle: '#0EA5E9',
apparentPower: '#8B5CF6',
area: '#F59E0B',
current: '#F59E0B',
digital: '#06B6D4',
each: '#64748B',
energy: '#EAB308',
frequency: '#A855F7',
illuminance: '#84CC16',
length: '#3B82F6',
mass: '#10B981',
pace: '#14B8A6',
partsPer: '#EC4899',
power: '#F43F5E',
pressure: '#6366F1',
reactiveEnergy: '#D946EF',
reactivePower: '#E879F9',
speed: '#10B981',
temperature: '#EF4444',
tempo: '#F97316', // Orange for music/tempo
time: '#7C3AED',
voltage: '#FB923C',
volume: '#8B5CF6',
volumeFlowRate: '#22D3EE',
};
return colorMap[measure];
}
/**
* Format measure name for display
*/
export function formatMeasureName(measure: Measure): string {
const nameMap: Record<Measure, string> = {
angle: 'Angle',
apparentPower: 'Apparent Power',
area: 'Area',
current: 'Current',
digital: 'Digital Storage',
each: 'Each',
energy: 'Energy',
frequency: 'Frequency',
illuminance: 'Illuminance',
length: 'Length',
mass: 'Mass',
pace: 'Pace',
partsPer: 'Parts Per',
power: 'Power',
pressure: 'Pressure',
reactiveEnergy: 'Reactive Energy',
reactivePower: 'Reactive Power',
speed: 'Speed',
temperature: 'Temperature',
tempo: 'Tempo / BPM',
time: 'Time',
voltage: 'Voltage',
volume: 'Volume',
volumeFlowRate: 'Volume Flow Rate',
};
return nameMap[measure];
}
/**
* Search units by query string (fuzzy search)
*/
export function searchUnits(query: string): UnitInfo[] {
if (!query) return [];
const allMeasures = getAllMeasures();
const results: UnitInfo[] = [];
const lowerQuery = query.toLowerCase();
for (const measure of allMeasures) {
const units = getUnitsForMeasure(measure);
for (const unit of units) {
const info = getUnitInfo(unit);
if (!info) continue;
const searchableText = [
info.abbr,
info.singular,
info.plural,
measure,
formatMeasureName(measure),
]
.join(' ')
.toLowerCase();
if (searchableText.includes(lowerQuery)) {
results.push(info);
}
}
}
return results;
}

106
lib/units/utils.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Utility functions for the application
*/
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merge Tailwind CSS classes with clsx
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Format a number for display with proper precision
*/
export function formatNumber(
value: number,
options: {
maxDecimals?: number;
minDecimals?: number;
notation?: 'standard' | 'scientific' | 'engineering' | 'compact';
} = {}
): string {
const {
maxDecimals = 6,
minDecimals = 0,
notation = 'standard',
} = options;
// Handle edge cases
if (!isFinite(value)) return value.toString();
if (value === 0) return '0';
// Use scientific notation for very large or very small numbers
const absValue = Math.abs(value);
const useScientific =
notation === 'scientific' ||
(notation === 'standard' && (absValue >= 1e10 || absValue < 1e-6));
if (useScientific) {
return value.toExponential(maxDecimals);
}
// Format with appropriate decimal places
const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: minDecimals,
maximumFractionDigits: maxDecimals,
notation: notation === 'compact' ? 'compact' : 'standard',
}).format(value);
return formatted;
}
/**
* Debounce function for input handling
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Parse a number input string
*/
export function parseNumberInput(input: string): number | null {
if (!input || input.trim() === '') return null;
// Remove spaces and replace comma with dot
const cleaned = input.replace(/\s/g, '').replace(',', '.');
const parsed = parseFloat(cleaned);
return isNaN(parsed) ? null : parsed;
}
/**
* Get relative time from timestamp
*/
export function getRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}

38
lib/utils/animations.ts Normal file
View File

@@ -0,0 +1,38 @@
// Animation utility classes and keyframes
export const fadeIn = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const slideUp = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
};
export const slideDown = {
initial: { opacity: 0, y: -20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 20 },
};
export const scaleIn = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.95 },
};
export const staggerChildren = {
animate: {
transition: {
staggerChildren: 0.05,
},
},
};
export const staggerItem = {
initial: { opacity: 0, y: 10 },
animate: { opacity: 1, y: 0 },
};

6
lib/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

18
lib/utils/debounce.ts Normal file
View File

@@ -0,0 +1,18 @@
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}

4
lib/utils/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './cn';
export * from './debounce';
export * from './urlSharing';
export * from './animations';

64
lib/utils/urlSharing.ts Normal file
View File

@@ -0,0 +1,64 @@
'use client';
export interface ShareableState {
text: string;
font: string;
}
/**
* Encode text and font to URL parameters
*/
export function encodeToUrl(text: string, font: string): string {
const params = new URLSearchParams();
if (text) {
params.set('text', text);
}
if (font && font !== 'Standard') {
params.set('font', font);
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
/**
* Decode URL parameters to get text and font
*/
export function decodeFromUrl(): ShareableState | null {
if (typeof window === 'undefined') return null;
const params = new URLSearchParams(window.location.search);
const text = params.get('text');
const font = params.get('font');
if (!text && !font) return null;
return {
text: text || '',
font: font || 'Standard',
};
}
/**
* Update the URL without reloading the page
*/
export function updateUrl(text: string, font: string): void {
if (typeof window === 'undefined') return;
const url = encodeToUrl(text, font);
const newUrl = url ? `${window.location.pathname}${url}` : window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
/**
* Get shareable URL
*/
export function getShareableUrl(text: string, font: string): string {
if (typeof window === 'undefined') return '';
const query = encodeToUrl(text, font);
return `${window.location.origin}${window.location.pathname}${query}`;
}

View File

@@ -9,20 +9,34 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"framer-motion": "^11.11.17", "@tanstack/react-query": "^5.90.21",
"next": "^16.0.1", "@valknarthing/pastel-wasm": "^0.1.0",
"react": "^19.0.0", "clsx": "^2.1.1",
"react-dom": "^19.0.0" "cmdk": "^1.1.1",
"convert-units": "^2.3.4",
"figlet": "^1.10.0",
"framer-motion": "^12.34.3",
"fuse.js": "^7.1.0",
"html-to-image": "^1.11.13",
"lucide-react": "^0.575.0",
"next": "^16.1.6",
"react": "^19.2.4",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.2.0",
"@types/node": "^20", "@types/figlet": "^1.7.0",
"@types/react": "^19", "@types/node": "^25.3.0",
"@types/react-dom": "^19", "@types/react": "^19.2.14",
"eslint": "^9.39.1", "@types/react-dom": "^19.2.3",
"eslint-config-next": "^16.0.1", "eslint": "^10.0.1",
"postcss": "^8", "eslint-config-next": "^16.1.6",
"tailwindcss": "^4.0.0", "postcss": "^8.5.6",
"typescript": "^5" "tailwindcss": "^4.2.0",
"typescript": "^5.9.3"
} }
} }

2135
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- sharp
- unrs-resolver

View File

@@ -0,0 +1,218 @@
flf2a$ 2 1 8 -1 13
1row font by unknown
=======================
-> Conversion to FigLet font by MEPH. (Part of ASCII Editor Service Pack I)
(http://studenten.freepage.de/meph/ascii/ascii/editor/_index.htm)
-> Defined: ASCII code alphanumeric
-> Uppercase characters only.
Was a part of a '1row' font collection. Author unknown.
$@
$@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
(\) @
@@
'| @
@@
^/_ @
@@
-} @
@@
+| @
@@
;~ @
@@
(o @
@@
"/ @
@@
{} @
@@
"| @
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
/\ @
@@
]3 @
@@
( @
@@
|) @
@@
[- @
@@
/= @
@@
(_, @
@@
|-| @
@@
| @
@@
_T @
@@
/< @
@@
|_ @
@@
|\/| @
@@
|\| @
@@
() @
@@
|^ @
@@
()_ @
@@
/? @
@@
_\~ @
@@
~|~ @
@@
|_| @
@@
\/ @
@@
\/\/ @
@@
>< @
@@
`/ @
@@
~/_ @
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
/\ @
@@
]3 @
@@
( @
@@
|) @
@@
[- @
@@
/= @
@@
(_, @
@@
|-| @
@@
| @
@@
_T @
@@
/< @
@@
|_ @
@@
|\/| @
@@
|\| @
@@
() @
@@
|^ @
@@
()_ @
@@
/? @
@@
_\~ @
@@
~|~ @
@@
|_| @
@@
\/ @
@@
\/\/ @
@@
>< @
@@
`/ @
@@
~/_ @
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@
@
@@

View File

@@ -0,0 +1,823 @@
flf2a$ 8 8 20 -1 6
3-D font created by Daniel Henninger <dahennin@eos.ncsu.edu>
---
Font modified June 17, 2007 by patorjk
This was to widen the space character.
$ $@
$ $@
$ $@
$ $@
$ $@
$ $@
$ $@
$ $@@
**@
/**@
/**@
/**@
/**@
// @
**@
// @@
* *@
/* /*@
/ / @
@
@
@
@
@@
@
** ** @
************@
///**////**/ @
/** /** @
************@
///**////**/ @
// // @@
* @
*****@
/*/*/ @
/*****@
///*/*@
*****@
///*/ @
/ @@
@
** ** @
// ** @
** @
** @
** @
** ** @
// // @@
** @
*/ * @
/ ** @
*/ * *@
* / * @
/* /* @
/ **** *@
//// / @@
**@
//*@
/ @
@
@
@
@
@@
**@
** @
** @
/** @
/** @
//** @
//**@
// @@
** @
//** @
//**@
/**@
/**@
** @
** @
// @@
** @
** /** ** @
//** /** ** @
**************@
///**//**//**/ @
** /** //** @
// /** // @
// @@
@
* @
/* @
*********@
/////*/// @
/* @
/ @
@@
@
@
@
@
@
**@
//*@
/ @@
@
@
@
*****@
///// @
@
@
@@
@
@
@
@
@
**@
/**@
// @@
**@
** @
** @
** @
** @
** @
** @
// @@
**** @
*///**@
/* */*@
/* * /*@
/** /*@
/* /*@
/ **** @
//// @@
** @
*** @
//** @
/** @
/** @
/** @
****@
//// @@
**** @
*/// *@
/ /*@
*** @
*// @
* @
/******@
////// @@
**** @
*/// *@
/ /*@
*** @
/// *@
* /*@
/ **** @
//// @@
** @
*/* @
* /* @
******@
/////* @
/* @
/* @
/ @@
******@
/*//// @
/***** @
///// *@
/*@
* /*@
/ **** @
//// @@
**** @
*/// *@
/* / @
/***** @
/*/// *@
/* /*@
/ **** @
//// @@
******@
//////*@
/*@
* @
* @
* @
* @
/ @@
**** @
*/// *@
/* /*@
/ **** @
*/// *@
/* /*@
/ **** @
//// @@
**** @
*/// *@
/* /*@
/ **** @
///* @
* @
* @
/ @@
@
@
@
@
**@
// @
**@
// @@
@
@
@
**@
// @
**@
//*@
/ @@
**@
**/ @
**/ @
**/ @
// ** @
// ** @
// **@
// @@
@
@
******@
////// @
******@
////// @
@
@@
** @
// ** @
// ** @
// **@
**/ @
**/ @
**/ @
// @@
**** @
**//**@
/** /**@
// ** @
** @
// @
** @
// @@
**** @
*/// *@
/* **/*@
/*/* /*@
/*/ ** @
/* // @
/ *****@
///// @@
** @
**** @
**//** @
** //** @
**********@
/**//////**@
/** /**@
// // @@
****** @
/*////** @
/* /** @
/****** @
/*//// **@
/* /**@
/******* @
/////// @@
****** @
**////**@
** // @
/** @
/** @
//** **@
//****** @
////// @@
******* @
/**////** @
/** /**@
/** /**@
/** /**@
/** ** @
/******* @
/////// @@
********@
/**///// @
/** @
/******* @
/**//// @
/** @
/********@
//////// @@
********@
/**///// @
/** @
/******* @
/**//// @
/** @
/** @
// @@
******** @
**//////**@
** // @
/** @
/** *****@
//** ////**@
//******** @
//////// @@
** **@
/** /**@
/** /**@
/**********@
/**//////**@
/** /**@
/** /**@
// // @@
**@
/**@
/**@
/**@
/**@
/**@
/**@
// @@
**@
/**@
/**@
/**@
/**@
** /**@
//***** @
///// @@
** **@
/** ** @
/** ** @
/**** @
/**/** @
/**//** @
/** //**@
// // @@
** @
/** @
/** @
/** @
/** @
/** @
/********@
//////// @@
**** ****@
/**/** **/**@
/**//** ** /**@
/** //*** /**@
/** //* /**@
/** / /**@
/** /**@
// // @@
**** **@
/**/** /**@
/**//** /**@
/** //** /**@
/** //**/**@
/** //****@
/** //***@
// /// @@
******* @
**/////** @
** //**@
/** /**@
/** /**@
//** ** @
//******* @
/////// @@
******* @
/**////**@
/** /**@
/******* @
/**//// @
/** @
/** @
// @@
******* @
**/////** @
** //** @
/** /** @
/** **/** @
//** // ** @
//******* **@
/////// // @@
******* @
/**////** @
/** /** @
/******* @
/**///** @
/** //** @
/** //**@
// // @@
********@
**////// @
/** @
/*********@
////////**@
/**@
******** @
//////// @@
**********@
/////**/// @
/** @
/** @
/** @
/** @
/** @
// @@
** **@
/** /**@
/** /**@
/** /**@
/** /**@
/** /**@
//******* @
/////// @@
** **@
/** /**@
/** /**@
//** ** @
//** ** @
//**** @
//** @
// @@
** **@
/** /**@
/** * /**@
/** *** /**@
/** **/**/**@
/**** //****@
/**/ ///**@
// // @@
** **@
//** ** @
//** ** @
//*** @
**/** @
** //** @
** //**@
// // @@
** **@
//** ** @
//**** @
//** @
/** @
/** @
/** @
// @@
********@
//////** @
** @
** @
** @
** @
********@
//////// @@
*****@
/**// @
/** @
/** @
/** @
/** @
/*****@
///// @@
** @
//** @
//** @
//** @
//** @
//** @
//**@
// @@
*****@
////**@
/**@
/**@
/**@
/**@
*****@
///// @@
** @
**/ ** @
** // **@
// // @
@
@
@
@@
@
@
@
@
@
@
*****@
///// @@
**@
/* @
/ @
@
@
@
@
@@
@
@
****** @
//////** @
******* @
**////** @
//********@
//////// @@
** @
/** @
/** @
/****** @
/**///**@
/** /**@
/****** @
///// @@
@
@
***** @
**///**@
/** // @
/** **@
//***** @
///// @@
**@
/**@
/**@
******@
**///**@
/** /**@
//******@
////// @@
@
@
***** @
**///**@
/*******@
/**//// @
//******@
////// @@
****@
/**/ @
******@
///**/ @
/** @
/** @
/** @
// @@
@
***** @
**///**@
/** /**@
//******@
/////**@
***** @
///// @@
** @
/** @
/** @
/****** @
/**///**@
/** /**@
/** /**@
// // @@
**@
// @
**@
/**@
/**@
/**@
/**@
// @@
**@
// @
**@
/**@
/**@
**/**@
//*** @
/// @@
** @
/** @
/** **@
/** ** @
/**** @
/**/** @
/**//**@
// // @@
**@
/**@
/**@
/**@
/**@
/**@
***@
/// @@
@
@
********** @
//**//**//**@
/** /** /**@
/** /** /**@
*** /** /**@
/// // // @@
@
@
******* @
//**///**@
/** /**@
/** /**@
*** /**@
/// // @@
@
@
****** @
**////**@
/** /**@
/** /**@
//****** @
////// @@
@
****** @
/**///**@
/** /**@
/****** @
/**/// @
/** @
// @@
@
**** @
**//** @
/** /** @
//***** @
////** @
/***@
/// @@
@
@
******@
//**//*@
/** / @
/** @
/*** @
/// @@
@
@
******@
**//// @
//***** @
/////**@
****** @
////// @@
** @
/** @
******@
///**/ @
/** @
/** @
//** @
// @@
@
@
** **@
/** /**@
/** /**@
/** /**@
//******@
////// @@
@
@
** **@
/** /**@
//** /** @
//**** @
//** @
// @@
@
@
*** **@
//** * /**@
/** ***/**@
/****/****@
***/ ///**@
/// /// @@
@
@
** **@
//** ** @
//*** @
**/** @
** //**@
// // @@
@
** **@
//** ** @
//*** @
/** @
** @
** @
// @@
@
@
******@
////** @
** @
** @
******@
////// @@
***@
**/ @
/** @
*** @
///** @
/** @
//***@
/// @@
*@
/*@
/*@
/ @
*@
/*@
/*@
/ @@
*** @
///** @
/** @
//***@
**/ @
/** @
*** @
/// @@
** *** @
//***//**@
/// // @
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More