Compare commits
3 Commits
ff6bb873eb
...
a9d0fd8443
| Author | SHA1 | Date | |
|---|---|---|---|
| a9d0fd8443 | |||
| 09838a203c | |||
| 2000623c67 |
17
app/(app)/figlet/page.tsx
Normal file
17
app/(app)/figlet/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { FigletConverter } from '@/components/figlet/FigletConverter';
|
||||
|
||||
export default function FigletPage() {
|
||||
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">Figlet ASCII</h1>
|
||||
<p className="text-muted-foreground">
|
||||
ASCII Art Text Generator with 373 Fonts
|
||||
</p>
|
||||
</div>
|
||||
<FigletConverter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
app/(app)/layout.tsx
Normal file
16
app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
app/(app)/pastel/accessibility/colorblind/page.tsx
Normal file
214
app/(app)/pastel/accessibility/colorblind/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
app/(app)/pastel/accessibility/contrast/page.tsx
Normal file
172
app/(app)/pastel/accessibility/contrast/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
app/(app)/pastel/accessibility/page.tsx
Normal file
96
app/(app)/pastel/accessibility/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
app/(app)/pastel/accessibility/textcolor/page.tsx
Normal file
221
app/(app)/pastel/accessibility/textcolor/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
app/(app)/pastel/batch/page.tsx
Normal file
193
app/(app)/pastel/batch/page.tsx
Normal 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 #ff5533 #3355ff"
|
||||
className="w-full h-48 p-3 border border-border rounded-xl bg-input font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary/50 transition-all duration-200"
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
192
app/(app)/pastel/globals.css
Normal file
192
app/(app)/pastel/globals.css
Normal 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; }
|
||||
}
|
||||
11
app/(app)/pastel/layout.tsx
Normal file
11
app/(app)/pastel/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function PastelLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
app/(app)/pastel/names/page.tsx
Normal file
106
app/(app)/pastel/names/page.tsx
Normal 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
218
app/(app)/pastel/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
app/(app)/pastel/palettes/distinct/page.tsx
Normal file
147
app/(app)/pastel/palettes/distinct/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
app/(app)/pastel/palettes/gradient/page.tsx
Normal file
188
app/(app)/pastel/palettes/gradient/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
app/(app)/pastel/palettes/harmony/page.tsx
Normal file
141
app/(app)/pastel/palettes/harmony/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
app/(app)/pastel/palettes/page.tsx
Normal file
68
app/(app)/pastel/palettes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/(app)/units/page.tsx
Normal file
17
app/(app)/units/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import MainConverter from '@/components/units/converter/MainConverter';
|
||||
|
||||
export default function UnitsPage() {
|
||||
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">Units Converter</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Smart unit converter with 187 units across 23 categories
|
||||
</p>
|
||||
</div>
|
||||
<MainConverter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
app/api/fonts/route.ts
Normal file
29
app/api/fonts/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
156
app/globals.css
156
app/globals.css
@@ -5,16 +5,63 @@
|
||||
@source "*.{js,ts,jsx,tsx}";
|
||||
|
||||
@theme {
|
||||
--color-background: #0a0a0f;
|
||||
--color-foreground: #ffffff;
|
||||
--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);
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'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 */
|
||||
--animate-gradient: gradient 8s linear infinite;
|
||||
--animate-float: float 3s ease-in-out infinite;
|
||||
--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 {
|
||||
0%, 100% {
|
||||
@@ -40,20 +87,97 @@
|
||||
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, .dark {
|
||||
color-scheme: dark;
|
||||
/* 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 {
|
||||
color-scheme: 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;
|
||||
}
|
||||
/* Fix native select dropdown styling */
|
||||
select option {
|
||||
@apply bg-popover text-popover-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
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) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
@@ -73,10 +197,10 @@ body {
|
||||
}
|
||||
|
||||
@utility glass {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--card);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@utility gradient-purple-blue {
|
||||
@@ -94,3 +218,7 @@ body {
|
||||
@utility gradient-yellow-amber {
|
||||
background: linear-gradient(135deg, #eab308 0%, #f59e0b 100%);
|
||||
}
|
||||
|
||||
@utility gradient-brand {
|
||||
background: linear-gradient(to right, #a78bfa, #f472b6, #22d3ee);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { Providers } from '@/components/providers/Providers';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Kit - Your Creative Toolkit',
|
||||
@@ -50,10 +51,32 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://kit.pivoine.art" />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
var theme = localStorage.getItem('theme');
|
||||
var isLanding = window.location.pathname === '/';
|
||||
if (isLanding) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.classList.remove('light');
|
||||
} else if (theme === 'light' || (!theme && window.matchMedia('(prefers-color-scheme: light)').matches)) {
|
||||
document.documentElement.classList.add('light');
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.classList.remove('light');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
{children}
|
||||
|
||||
11
app/page.tsx
11
app/page.tsx
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||
import Hero from '@/components/Hero';
|
||||
import Stats from '@/components/Stats';
|
||||
@@ -6,8 +9,14 @@ import Footer from '@/components/Footer';
|
||||
import BackToTop from '@/components/BackToTop';
|
||||
|
||||
export default function Home() {
|
||||
useEffect(() => {
|
||||
// Force dark mode on html element for the landing page
|
||||
document.documentElement.classList.remove('light');
|
||||
document.documentElement.classList.add('dark');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="relative min-h-screen">
|
||||
<main className="relative min-h-screen dark text-foreground">
|
||||
<AnimatedBackground />
|
||||
<BackToTop />
|
||||
<Hero />
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
export default function AnimatedBackground() {
|
||||
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 */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-50"
|
||||
className="absolute inset-0 opacity-[0.08] dark:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #667eea 100%)',
|
||||
backgroundSize: '400% 400%',
|
||||
@@ -13,9 +13,9 @@ export default function AnimatedBackground() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Grid pattern overlay */}
|
||||
{/* Signature Grid pattern overlay - Original landing page specification */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
className="absolute inset-0 opacity-[0.05] dark:opacity-10"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||
@@ -26,9 +26,9 @@ export default function AnimatedBackground() {
|
||||
/>
|
||||
|
||||
{/* 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/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 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 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 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 dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark:opacity-20 animate-float" style={{ animationDelay: '4s' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function BackToTop() {
|
||||
{isVisible && (
|
||||
<motion.button
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-8 right-8 p-4 rounded-full glass hover:bg-white/10 text-purple-400 hover:text-purple-300 transition-colors shadow-lg z-40 group"
|
||||
className="fixed bottom-8 right-8 p-4 rounded-full glass hover:bg-accent/50 text-purple-400 hover:text-purple-300 transition-colors shadow-lg z-40 group"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function Footer() {
|
||||
|
||||
return (
|
||||
<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
|
||||
className="flex flex-col md:flex-row items-center justify-between gap-6"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -16,16 +16,16 @@ export default function Footer() {
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* 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 text-gray-600">•</span>
|
||||
<span className="text-base text-purple-400">Open Source</span>
|
||||
<span className="text-base text-muted-foreground/30">•</span>
|
||||
<span className="text-base text-primary font-medium">Open Source</span>
|
||||
</div>
|
||||
|
||||
{/* Copyright - centered */}
|
||||
<div className="text-center">
|
||||
<p className="text-base text-gray-500">
|
||||
© {currentYear} Kit. Built with Next.js 16 & Tailwind CSS 4.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© {currentYear} Kit. Built with Next.js 16 & Tailwind CSS 4
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -34,15 +34,15 @@ export default function Footer() {
|
||||
href="https://dev.pivoine.art/valknar/kit-ui"
|
||||
target="_blank"
|
||||
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" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 01-9 9" strokeLinecap="round" />
|
||||
</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
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import Logo from './Logo';
|
||||
import Link from 'next/link';
|
||||
|
||||
const MotionLink = motion.create(Link);
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
@@ -29,7 +32,7 @@ export default function Hero() {
|
||||
|
||||
{/* Subtitle */}
|
||||
<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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
@@ -39,13 +42,13 @@ export default function Hero() {
|
||||
|
||||
{/* Description */}
|
||||
<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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
>
|
||||
A curated collection of creative and utility tools for developers and creators.
|
||||
Simple, powerful, and always at your fingertips.
|
||||
A curated collection of creative and utility tools for developers and creators
|
||||
Simple, powerful, and always at your fingertips
|
||||
</motion.p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
@@ -55,7 +58,7 @@ export default function Hero() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.8 }}
|
||||
>
|
||||
<motion.a
|
||||
<MotionLink
|
||||
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"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
@@ -68,7 +71,7 @@ export default function Hero() {
|
||||
whileHover={{ x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</motion.a>
|
||||
</MotionLink>
|
||||
|
||||
<motion.a
|
||||
href="https://dev.pivoine.art/valknar/kit-ui"
|
||||
@@ -89,7 +92,7 @@ export default function Hero() {
|
||||
</motion.div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<motion.a
|
||||
<MotionLink
|
||||
href="#tools"
|
||||
className="flex flex-col items-center gap-2 cursor-pointer group"
|
||||
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" />
|
||||
</motion.div>
|
||||
</motion.a>
|
||||
</MotionLink>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function Stats() {
|
||||
whileHover={{ y: -5 }}
|
||||
>
|
||||
<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 }}
|
||||
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">
|
||||
{stat.number}
|
||||
</div>
|
||||
<div className="text-gray-400 text-base font-medium">
|
||||
<div className="text-muted-foreground text-base font-medium">
|
||||
{stat.label}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const MotionLink = motion.create(Link);
|
||||
|
||||
interface ToolCardProps {
|
||||
title: string;
|
||||
@@ -16,10 +19,8 @@ interface ToolCardProps {
|
||||
|
||||
export default function ToolCard({ title, description, icon, url, gradient, accentColor, index, badges }: ToolCardProps) {
|
||||
return (
|
||||
<motion.a
|
||||
<MotionLink
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative block"
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
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 }}
|
||||
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 */}
|
||||
<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 */}
|
||||
<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>
|
||||
|
||||
{/* Icon */}
|
||||
@@ -44,23 +45,14 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
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}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<h3
|
||||
className="text-2xl font-bold mb-3 text-white transition-all duration-300"
|
||||
style={{
|
||||
color: 'white',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = accentColor;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'white';
|
||||
}}
|
||||
className="text-2xl font-bold mb-3 text-foreground transition-all duration-300 group-hover:text-primary"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
@@ -71,7 +63,7 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
||||
{badges.map((badge) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
@@ -80,13 +72,13 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
</p>
|
||||
|
||||
{/* Arrow icon */}
|
||||
<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 }}
|
||||
whileHover={{ x: 5 }}
|
||||
>
|
||||
@@ -105,6 +97,6 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
||||
</svg>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.a>
|
||||
</MotionLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const tools = [
|
||||
{
|
||||
title: 'Pastel',
|
||||
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',
|
||||
accentColor: '#a855f7',
|
||||
badges: ['Open Source', 'WCAG', 'Free'],
|
||||
@@ -24,7 +24,7 @@ const tools = [
|
||||
{
|
||||
title: 'Units',
|
||||
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',
|
||||
accentColor: '#2dd4bf',
|
||||
badges: ['Open Source', 'Real-time', 'Free'],
|
||||
@@ -37,7 +37,7 @@ const tools = [
|
||||
{
|
||||
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.',
|
||||
url: 'https://figlet.kit.pivoine.art',
|
||||
url: '/figlet',
|
||||
gradient: 'gradient-yellow-amber',
|
||||
accentColor: '#eab308',
|
||||
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">
|
||||
Available Tools
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
|
||||
Explore our collection of carefully crafted tools designed to boost your productivity and creativity.
|
||||
<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
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
|
||||
151
components/figlet/FigletConverter.tsx
Normal file
151
components/figlet/FigletConverter.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { TextInput } from './TextInput';
|
||||
import { FontPreview } from './FontPreview';
|
||||
import { FontSelector } from './FontSelector';
|
||||
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 { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
|
||||
import { toast } from 'sonner';
|
||||
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);
|
||||
|
||||
// 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);
|
||||
toast.success('Copied to clipboard!');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
toast.success('Shareable URL copied!');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy URL:', error);
|
||||
toast.error('Failed to copy URL');
|
||||
}
|
||||
};
|
||||
|
||||
// Random font
|
||||
const handleRandomFont = () => {
|
||||
if (fonts.length === 0) return;
|
||||
const randomIndex = Math.floor(Math.random() * fonts.length);
|
||||
setSelectedFont(fonts[randomIndex].name);
|
||||
toast.info(`Random font: ${fonts[randomIndex].name}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch lg:max-h-[800px]">
|
||||
{/* Left Column - Input and Preview */}
|
||||
<div className="lg:col-span-2 space-y-6 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<TextInput
|
||||
value={text}
|
||||
onChange={setText}
|
||||
placeholder="Type your text here..."
|
||||
/>
|
||||
|
||||
<FontPreview
|
||||
text={asciiArt}
|
||||
font={selectedFont}
|
||||
isLoading={isLoading}
|
||||
onCopy={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Font Selector */}
|
||||
<div className="lg:col-span-1 h-[500px] lg:h-auto relative">
|
||||
<div className="lg:absolute lg:inset-0 h-full">
|
||||
<FontSelector
|
||||
fonts={fonts}
|
||||
selectedFont={selectedFont}
|
||||
onSelectFont={setSelectedFont}
|
||||
onRandomFont={handleRandomFont}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
components/figlet/FontPreview.tsx
Normal file
204
components/figlet/FontPreview.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
'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 { toast } from 'sonner';
|
||||
|
||||
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 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();
|
||||
|
||||
toast.success('Exported as PNG!');
|
||||
} catch (error) {
|
||||
console.error('Failed to export PNG:', error);
|
||||
toast.error('Failed to export PNG');
|
||||
}
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
247
components/figlet/FontSelector.tsx
Normal file
247
components/figlet/FontSelector.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
'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 } 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;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type FilterType = 'all' | 'favorites' | 'recent';
|
||||
|
||||
export function FontSelector({
|
||||
fonts,
|
||||
selectedFont,
|
||||
onSelectFont,
|
||||
onRandomFont,
|
||||
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={cn("flex flex-col min-h-0 overflow-hidden", className)}>
|
||||
<div className="p-6 flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||
<h3 className="text-sm font-medium">Select Font</h3>
|
||||
{onRandomFont && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRandomFont}
|
||||
title="Random font"
|
||||
>
|
||||
<Shuffle className="h-3 w-3 mr-2" />
|
||||
Random
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-1 mb-4 p-1 bg-muted rounded-lg shrink-0">
|
||||
<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 shrink-0">
|
||||
<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="flex-1 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) => (
|
||||
<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>
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(font.name, e)}
|
||||
className="p-1"
|
||||
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/30 hover:text-red-500/50'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-4 pt-4 border-t text-xs text-muted-foreground shrink-0">
|
||||
{filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''}
|
||||
{filter === 'favorites' && ` • ${favorites.length} total favorites`}
|
||||
{filter === 'recent' && ` • ${recentFonts.length} recent`}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
28
components/figlet/TextInput.tsx
Normal file
28
components/figlet/TextInput.tsx
Normal 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-border rounded-lg bg-input resize-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground transition-all duration-200"
|
||||
maxLength={100}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 text-xs text-muted-foreground">
|
||||
{value.length}/100
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
components/layout/AppHeader.tsx
Normal file
82
components/layout/AppHeader.tsx
Normal 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-border 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-accent/50"
|
||||
title={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
28
components/layout/AppShell.tsx
Normal file
28
components/layout/AppShell.tsx
Normal 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 h-screen overflow-hidden 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>
|
||||
);
|
||||
}
|
||||
230
components/layout/AppSidebar.tsx
Normal file
230
components/layout/AppSidebar.tsx
Normal 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-border bg-background/40 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative lg:h-full",
|
||||
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-border">
|
||||
<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-accent/50 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-border 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-accent/50"
|
||||
)}
|
||||
>
|
||||
{subItem.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Footer / Desktop Toggle */}
|
||||
<div className="p-4 border-t border-border 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
components/layout/SidebarProvider.tsx
Normal file
36
components/layout/SidebarProvider.tsx
Normal 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;
|
||||
}
|
||||
38
components/pastel/color/ColorDisplay.tsx
Normal file
38
components/pastel/color/ColorDisplay.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ColorDisplayProps {
|
||||
color: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
showBorder?: boolean;
|
||||
}
|
||||
|
||||
export function ColorDisplay({
|
||||
color,
|
||||
size = 'lg',
|
||||
className,
|
||||
showBorder = true,
|
||||
}: ColorDisplayProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-16 w-16',
|
||||
md: 'h-32 w-32',
|
||||
lg: 'h-48 w-48',
|
||||
xl: 'h-64 w-64',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg transition-all',
|
||||
showBorder && 'ring-2 ring-border',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
role="img"
|
||||
aria-label={`Color swatch: ${color}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
92
components/pastel/color/ColorInfo.tsx
Normal file
92
components/pastel/color/ColorInfo.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { ColorInfo as ColorInfoType } from '@/lib/pastel/api/types';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ColorInfoProps {
|
||||
info: ColorInfoType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColorInfo({ info, className }: ColorInfoProps) {
|
||||
const copyToClipboard = (value: string, label: string) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
toast.success(`Copied ${label} to clipboard`);
|
||||
};
|
||||
|
||||
const formatRgb = (rgb: { r: number; g: number; b: number; a?: number }) => {
|
||||
if (rgb.a !== undefined && rgb.a < 1) {
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})`;
|
||||
}
|
||||
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
|
||||
};
|
||||
|
||||
const formatHsl = (hsl: { h: number; s: number; l: number; a?: number }) => {
|
||||
if (hsl.a !== undefined && hsl.a < 1) {
|
||||
return `hsla(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%, ${hsl.a})`;
|
||||
}
|
||||
return `hsl(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%)`;
|
||||
};
|
||||
|
||||
const formatLab = (lab: { l: number; a: number; b: number }) => {
|
||||
return `lab(${lab.l.toFixed(1)}, ${lab.a.toFixed(1)}, ${lab.b.toFixed(1)})`;
|
||||
};
|
||||
|
||||
const formats = [
|
||||
{ label: 'Hex', value: info.hex },
|
||||
{ label: 'RGB', value: formatRgb(info.rgb) },
|
||||
{ label: 'HSL', value: formatHsl(info.hsl) },
|
||||
{ label: 'Lab', value: formatLab(info.lab) },
|
||||
{ label: 'OkLab', value: formatLab(info.oklab) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{formats.map((format) => (
|
||||
<div
|
||||
key={format.label}
|
||||
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-muted-foreground mb-1">{format.label}</div>
|
||||
<div className="font-mono text-sm">{format.value}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(format.value, format.label)}
|
||||
aria-label={`Copy ${format.label} value`}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Brightness</div>
|
||||
<div className="text-sm font-medium">{(info.brightness * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Luminance</div>
|
||||
<div className="text-sm font-medium">{(info.luminance * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Type</div>
|
||||
<div className="text-sm font-medium">{info.is_light ? 'Light' : 'Dark'}</div>
|
||||
</div>
|
||||
{info.name && typeof info.name === 'string' && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Named</div>
|
||||
<div className="text-sm font-medium">{info.name}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
components/pastel/color/ColorPicker.tsx
Normal file
38
components/pastel/color/ColorPicker.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ColorPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
// Allow partial input while typing
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<HexColorPicker color={color} onChange={onChange} className="w-full" />
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="color-input" className="text-sm font-medium">
|
||||
Color Value
|
||||
</label>
|
||||
<Input
|
||||
id="color-input"
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={handleInputChange}
|
||||
placeholder="#ff0099 or rgb(255, 0, 153)"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
components/pastel/color/ColorSwatch.tsx
Normal file
66
components/pastel/color/ColorSwatch.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ColorSwatchProps {
|
||||
color: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColorSwatch({
|
||||
color,
|
||||
size = 'md',
|
||||
showLabel = true,
|
||||
onClick,
|
||||
className,
|
||||
}: ColorSwatchProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-12 w-12',
|
||||
md: 'h-16 w-16',
|
||||
lg: 'h-24 w-24',
|
||||
};
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(color);
|
||||
setCopied(true);
|
||||
toast.success(`Copied ${color}`);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center gap-2', className)}>
|
||||
<button
|
||||
className={cn(
|
||||
'relative rounded-lg ring-2 ring-border transition-all duration-200',
|
||||
'hover:scale-110 hover:ring-primary hover:shadow-lg',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'group active:scale-95',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={onClick || handleCopy}
|
||||
aria-label={`Color ${color}`}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 bg-black/30 rounded-lg backdrop-blur-sm">
|
||||
{copied ? (
|
||||
<Check className="h-5 w-5 text-white animate-scale-in" />
|
||||
) : (
|
||||
<Copy className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{showLabel && (
|
||||
<span className="text-xs font-mono text-muted-foreground">{color}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
components/pastel/color/PaletteGrid.tsx
Normal file
37
components/pastel/color/PaletteGrid.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { ColorSwatch } from './ColorSwatch';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface PaletteGridProps {
|
||||
colors: string[];
|
||||
onColorClick?: (color: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PaletteGrid({ colors, onColorClick, className }: PaletteGridProps) {
|
||||
if (colors.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No colors in palette yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{colors.map((color, index) => (
|
||||
<ColorSwatch
|
||||
key={`${color}-${index}`}
|
||||
color={color}
|
||||
onClick={onColorClick ? () => onColorClick(color) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
components/pastel/layout/Footer.tsx
Normal file
203
components/pastel/layout/Footer.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Github, Heart, ExternalLink } from 'lucide-react';
|
||||
|
||||
export function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="border-t bg-card/50 backdrop-blur-sm">
|
||||
<div className="max-w-7xl mx-auto px-8 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* About */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">Pastel UI</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A modern, interactive web application for color manipulation, palette generation,
|
||||
and accessibility analysis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Resources</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link href="/pastel/playground" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Color Playground
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/pastel/palettes" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Palette Generator
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/pastel/accessibility" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Accessibility Tools
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/pastel/batch" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Batch Operations
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Documentation */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Documentation</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-ui#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Getting Started
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-api#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
API Documentation
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/sharkdp/pastel"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Pastel CLI
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Community */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Community</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-ui"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
Pastel UI
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
Pastel API
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-ui/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Report an Issue
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-ui/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Discussions
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="mt-12 pt-8 border-t">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1">
|
||||
© {currentYear} Pastel UI. Built with
|
||||
<Heart className="h-4 w-4 text-red-500 fill-red-500" />
|
||||
using
|
||||
<a
|
||||
href="https://nextjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors underline"
|
||||
>
|
||||
Next.js
|
||||
</a>
|
||||
and
|
||||
<a
|
||||
href="https://tailwindcss.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors underline"
|
||||
>
|
||||
Tailwind CSS
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Based on{' '}
|
||||
<a
|
||||
href="https://github.com/sharkdp/pastel"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors underline"
|
||||
>
|
||||
Pastel
|
||||
</a>
|
||||
{' '}by{' '}
|
||||
<a
|
||||
href="https://github.com/sharkdp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors underline"
|
||||
>
|
||||
David Peter
|
||||
</a>
|
||||
</p>
|
||||
<span>•</span>
|
||||
<a
|
||||
href="https://github.com/valknarness/pastel-ui/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
MIT License
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
77
components/pastel/layout/Navbar.tsx
Normal file
77
components/pastel/layout/Navbar.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Palette } from 'lucide-react';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Home', href: '/pastel' },
|
||||
{ name: 'Playground', href: '/pastel/playground' },
|
||||
{ name: 'Palettes', href: '/pastel/palettes' },
|
||||
{ name: 'Accessibility', href: '/pastel/accessibility' },
|
||||
{ name: 'Named Colors', href: '/pastel/names' },
|
||||
{ name: 'Batch', href: '/pastel/batch' },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center space-x-2 font-bold text-xl">
|
||||
<Palette className="h-6 w-6 text-primary" />
|
||||
<span className="bg-gradient-to-r from-pink-500 via-purple-500 to-blue-500 bg-clip-text text-transparent">
|
||||
Pastel UI
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-1">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
pathname === item.href
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<div className="md:hidden pb-3 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'block px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
pathname === item.href
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
28
components/pastel/layout/ThemeToggle.tsx
Normal file
28
components/pastel/layout/ThemeToggle.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
29
components/pastel/providers/Providers.tsx
Normal file
29
components/pastel/providers/Providers.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<Toaster position="top-right" richColors />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
78
components/pastel/providers/ThemeProvider.tsx
Normal file
78
components/pastel/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('system');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
// Load theme from localStorage
|
||||
const stored = localStorage.getItem('theme') as Theme | null;
|
||||
if (stored) {
|
||||
setTheme(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
// Remove previous theme classes
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
root.classList.add(systemTheme);
|
||||
setResolvedTheme(systemTheme);
|
||||
} else {
|
||||
root.classList.add(theme);
|
||||
setResolvedTheme(theme);
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = () => {
|
||||
if (theme === 'system') {
|
||||
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
|
||||
setResolvedTheme(systemTheme);
|
||||
window.document.documentElement.classList.remove('light', 'dark');
|
||||
window.document.documentElement.classList.add(systemTheme);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
125
components/pastel/tools/ExportMenu.tsx
Normal file
125
components/pastel/tools/ExportMenu.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { useState } from 'react';
|
||||
import { Download, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
exportAsCSS,
|
||||
exportAsSCSS,
|
||||
exportAsTailwind,
|
||||
exportAsJSON,
|
||||
exportAsJavaScript,
|
||||
downloadAsFile,
|
||||
type ExportColor,
|
||||
} from '@/lib/pastel/utils/export';
|
||||
|
||||
interface ExportMenuProps {
|
||||
colors: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript';
|
||||
|
||||
export function ExportMenu({ colors, className }: ExportMenuProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>('css');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const exportColors: ExportColor[] = colors.map((hex) => ({ hex }));
|
||||
|
||||
const getExportContent = (): string => {
|
||||
switch (format) {
|
||||
case 'css':
|
||||
return exportAsCSS(exportColors);
|
||||
case 'scss':
|
||||
return exportAsSCSS(exportColors);
|
||||
case 'tailwind':
|
||||
return exportAsTailwind(exportColors);
|
||||
case 'json':
|
||||
return exportAsJSON(exportColors);
|
||||
case 'javascript':
|
||||
return exportAsJavaScript(exportColors);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileExtension = (): string => {
|
||||
switch (format) {
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'scss':
|
||||
return 'scss';
|
||||
case 'tailwind':
|
||||
return 'js';
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'javascript':
|
||||
return 'js';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
const content = getExportContent();
|
||||
navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
toast.success('Copied to clipboard!');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const content = getExportContent();
|
||||
const extension = getFileExtension();
|
||||
downloadAsFile(content, `palette.${extension}`, 'text/plain');
|
||||
toast.success('Downloaded!');
|
||||
};
|
||||
|
||||
if (colors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Export Palette</h3>
|
||||
<Select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as ExportFormat)}
|
||||
>
|
||||
<option value="css">CSS Variables</option>
|
||||
<option value="scss">SCSS Variables</option>
|
||||
<option value="tailwind">Tailwind Config</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="javascript">JavaScript Array</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<pre className="text-xs overflow-x-auto">
|
||||
<code>{getExportContent()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCopy} variant="outline" className="flex-1">
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleDownload} variant="default" className="flex-1">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
components/pastel/tools/ManipulationPanel.tsx
Normal file
231
components/pastel/tools/ManipulationPanel.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Slider } from '@/components/ui/Slider';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
useLighten,
|
||||
useDarken,
|
||||
useSaturate,
|
||||
useDesaturate,
|
||||
useRotate,
|
||||
useComplement
|
||||
} from '@/lib/pastel/api/queries';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ManipulationPanelProps {
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export function ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) {
|
||||
const [lightenAmount, setLightenAmount] = useState(0.2);
|
||||
const [darkenAmount, setDarkenAmount] = useState(0.2);
|
||||
const [saturateAmount, setSaturateAmount] = useState(0.2);
|
||||
const [desaturateAmount, setDesaturateAmount] = useState(0.2);
|
||||
const [rotateAmount, setRotateAmount] = useState(30);
|
||||
|
||||
const lightenMutation = useLighten();
|
||||
const darkenMutation = useDarken();
|
||||
const saturateMutation = useSaturate();
|
||||
const desaturateMutation = useDesaturate();
|
||||
const rotateMutation = useRotate();
|
||||
const complementMutation = useComplement();
|
||||
|
||||
const handleLighten = async () => {
|
||||
try {
|
||||
const result = await lightenMutation.mutateAsync({
|
||||
colors: [color],
|
||||
amount: lightenAmount,
|
||||
});
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(`Lightened by ${(lightenAmount * 100).toFixed(0)}%`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to lighten color');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDarken = async () => {
|
||||
try {
|
||||
const result = await darkenMutation.mutateAsync({
|
||||
colors: [color],
|
||||
amount: darkenAmount,
|
||||
});
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(`Darkened by ${(darkenAmount * 100).toFixed(0)}%`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to darken color');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaturate = async () => {
|
||||
try {
|
||||
const result = await saturateMutation.mutateAsync({
|
||||
colors: [color],
|
||||
amount: saturateAmount,
|
||||
});
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(`Saturated by ${(saturateAmount * 100).toFixed(0)}%`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to saturate color');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDesaturate = async () => {
|
||||
try {
|
||||
const result = await desaturateMutation.mutateAsync({
|
||||
colors: [color],
|
||||
amount: desaturateAmount,
|
||||
});
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(`Desaturated by ${(desaturateAmount * 100).toFixed(0)}%`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to desaturate color');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotate = async () => {
|
||||
try {
|
||||
const result = await rotateMutation.mutateAsync({
|
||||
colors: [color],
|
||||
amount: rotateAmount,
|
||||
});
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success(`Rotated hue by ${rotateAmount}°`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to rotate hue');
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplement = async () => {
|
||||
try {
|
||||
const result = await complementMutation.mutateAsync([color]);
|
||||
if (result.colors[0]) {
|
||||
onColorChange(result.colors[0].output);
|
||||
toast.success('Generated complementary color');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate complement');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
lightenMutation.isPending ||
|
||||
darkenMutation.isPending ||
|
||||
saturateMutation.isPending ||
|
||||
desaturateMutation.isPending ||
|
||||
rotateMutation.isPending ||
|
||||
complementMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Lighten */}
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
label="Lighten"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={lightenAmount}
|
||||
onChange={(e) => setLightenAmount(parseFloat(e.target.value))}
|
||||
suffix="%"
|
||||
showValue
|
||||
/>
|
||||
<Button onClick={handleLighten} disabled={isLoading} className="w-full">
|
||||
Apply Lighten
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Darken */}
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
label="Darken"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={darkenAmount}
|
||||
onChange={(e) => setDarkenAmount(parseFloat(e.target.value))}
|
||||
suffix="%"
|
||||
showValue
|
||||
/>
|
||||
<Button onClick={handleDarken} disabled={isLoading} className="w-full">
|
||||
Apply Darken
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Saturate */}
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
label="Saturate"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={saturateAmount}
|
||||
onChange={(e) => setSaturateAmount(parseFloat(e.target.value))}
|
||||
suffix="%"
|
||||
showValue
|
||||
/>
|
||||
<Button onClick={handleSaturate} disabled={isLoading} className="w-full">
|
||||
Apply Saturate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Desaturate */}
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
label="Desaturate"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={desaturateAmount}
|
||||
onChange={(e) => setDesaturateAmount(parseFloat(e.target.value))}
|
||||
suffix="%"
|
||||
showValue
|
||||
/>
|
||||
<Button onClick={handleDesaturate} disabled={isLoading} className="w-full">
|
||||
Apply Desaturate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Rotate Hue */}
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
label="Rotate Hue"
|
||||
min={-180}
|
||||
max={180}
|
||||
step={5}
|
||||
value={rotateAmount}
|
||||
onChange={(e) => setRotateAmount(parseInt(e.target.value))}
|
||||
suffix="°"
|
||||
showValue
|
||||
/>
|
||||
<Button onClick={handleRotate} disabled={isLoading} className="w-full">
|
||||
Apply Rotation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="pt-4 border-t space-y-2">
|
||||
<h3 className="text-sm font-medium mb-3">Quick Actions</h3>
|
||||
<Button
|
||||
onClick={handleComplement}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Get Complementary Color
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
components/providers/Providers.tsx
Normal file
29
components/providers/Providers.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<Toaster position="top-right" richColors />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
85
components/providers/ThemeProvider.tsx
Normal file
85
components/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'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');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Load theme from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme | null;
|
||||
if (stored) {
|
||||
setTheme(stored);
|
||||
}
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Apply theme to document element and save to localStorage
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
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, mounted]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
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, mounted]);
|
||||
|
||||
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
35
components/ui/Badge.tsx
Normal 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
45
components/ui/Button.tsx
Normal 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 hover:bg-accent/10 hover:border-primary/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
50
components/ui/Card.tsx
Normal 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('bg-card text-card-foreground border rounded-lg 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 };
|
||||
36
components/ui/EmptyState.tsx
Normal file
36
components/ui/EmptyState.tsx
Normal 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
28
components/ui/Input.tsx
Normal 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-border bg-input 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 };
|
||||
39
components/ui/Select.tsx
Normal file
39
components/ui/Select.tsx
Normal 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-border bg-input 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 };
|
||||
14
components/ui/Skeleton.tsx
Normal file
14
components/ui/Skeleton.tsx
Normal 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
65
components/ui/Slider.tsx
Normal 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 };
|
||||
122
components/units/converter/ConversionHistory.tsx
Normal file
122
components/units/converter/ConversionHistory.tsx
Normal 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/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>
|
||||
);
|
||||
}
|
||||
345
components/units/converter/MainConverter.tsx
Normal file
345
components/units/converter/MainConverter.tsx
Normal 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/units/ui/CommandPalette';
|
||||
import {
|
||||
getAllMeasures,
|
||||
getUnitsForMeasure,
|
||||
convertToAll,
|
||||
convertUnit,
|
||||
formatMeasureName,
|
||||
getCategoryColor,
|
||||
getCategoryColorHex,
|
||||
type Measure,
|
||||
type ConversionResult,
|
||||
} from '@/lib/units/units';
|
||||
import { parseNumberInput, formatNumber, cn } from '@/lib/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>
|
||||
);
|
||||
}
|
||||
201
components/units/converter/SearchUnits.tsx
Normal file
201
components/units/converter/SearchUnits.tsx
Normal 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/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>
|
||||
);
|
||||
}
|
||||
314
components/units/converter/VisualComparison.tsx
Normal file
314
components/units/converter/VisualComparison.tsx
Normal 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/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>
|
||||
);
|
||||
}
|
||||
77
components/units/providers/ThemeProvider.tsx
Normal file
77
components/units/providers/ThemeProvider.tsx
Normal 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;
|
||||
};
|
||||
231
components/units/ui/CommandPalette.tsx
Normal file
231
components/units/ui/CommandPalette.tsx
Normal 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/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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
components/units/ui/Footer.tsx
Normal file
103
components/units/ui/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
lib/figlet/figletService.ts
Normal file
80
lib/figlet/figletService.ts
Normal 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
61
lib/figlet/fontLoader.ts
Normal 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();
|
||||
}
|
||||
34
lib/figlet/hooks/useKeyboardShortcuts.ts
Normal file
34
lib/figlet/hooks/useKeyboardShortcuts.ts
Normal 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
248
lib/pastel/api/client.ts
Normal 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
251
lib/pastel/api/queries.ts
Normal 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
268
lib/pastel/api/types.ts
Normal 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[];
|
||||
};
|
||||
}
|
||||
484
lib/pastel/api/wasm-client.ts
Normal file
484
lib/pastel/api/wasm-client.ts
Normal 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();
|
||||
106
lib/pastel/hooks/useKeyboard.ts
Normal file
106
lib/pastel/hooks/useKeyboard.ts
Normal 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
5
lib/pastel/index.ts
Normal 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';
|
||||
68
lib/pastel/stores/historyStore.ts
Normal file
68
lib/pastel/stores/historyStore.ts
Normal 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),
|
||||
}
|
||||
)
|
||||
);
|
||||
57
lib/pastel/utils/color.ts
Normal file
57
lib/pastel/utils/color.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
83
lib/pastel/utils/export.ts
Normal file
83
lib/pastel/utils/export.ts
Normal 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
70
lib/storage/favorites.ts
Normal 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));
|
||||
}
|
||||
3
lib/units/index.ts
Normal file
3
lib/units/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './units';
|
||||
export * from './storage';
|
||||
export * from './tempo';
|
||||
115
lib/units/storage.ts
Normal file
115
lib/units/storage.ts
Normal 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
117
lib/units/tempo.ts
Normal 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
306
lib/units/units.ts
Normal 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;
|
||||
}
|
||||
38
lib/utils/animations.ts
Normal file
38
lib/utils/animations.ts
Normal 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
6
lib/utils/cn.ts
Normal 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
18
lib/utils/debounce.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
54
lib/utils/format.ts
Normal file
54
lib/utils/format.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
6
lib/utils/index.ts
Normal file
6
lib/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './cn';
|
||||
export * from './debounce';
|
||||
export * from './urlSharing';
|
||||
export * from './animations';
|
||||
export * from './format';
|
||||
export * from './time';
|
||||
17
lib/utils/time.ts
Normal file
17
lib/utils/time.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
64
lib/utils/urlSharing.ts
Normal file
64
lib/utils/urlSharing.ts
Normal 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}`;
|
||||
}
|
||||
40
package.json
40
package.json
@@ -9,20 +9,34 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^11.11.17",
|
||||
"next": "^16.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@valknarthing/pastel-wasm": "^0.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"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": {
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5"
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/figlet": "^1.7.0",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^10.0.1",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
2135
pnpm-lock.yaml
generated
2135
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
218
public/fonts/figlet-fonts/1Row.flf
Normal file
218
public/fonts/figlet-fonts/1Row.flf
Normal 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 @
|
||||
@@
|
||||
/< @
|
||||
@@
|
||||
|_ @
|
||||
@@
|
||||
|\/| @
|
||||
@@
|
||||
|\| @
|
||||
@@
|
||||
() @
|
||||
@@
|
||||
|^ @
|
||||
@@
|
||||
()_ @
|
||||
@@
|
||||
/? @
|
||||
@@
|
||||
_\~ @
|
||||
@@
|
||||
~|~ @
|
||||
@@
|
||||
|_| @
|
||||
@@
|
||||
\/ @
|
||||
@@
|
||||
\/\/ @
|
||||
@@
|
||||
>< @
|
||||
@@
|
||||
`/ @
|
||||
@@
|
||||
~/_ @
|
||||
@@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@@
|
||||
823
public/fonts/figlet-fonts/3-D.flf
Normal file
823
public/fonts/figlet-fonts/3-D.flf
Normal 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.
|
||||
$ $@
|
||||
$ $@
|
||||
$ $@
|
||||
$ $@
|
||||
$ $@
|
||||
$ $@
|
||||
$ $@
|
||||
$ $@@
|
||||
**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
// @
|
||||
**@
|
||||
// @@
|
||||
* *@
|
||||
/* /*@
|
||||
/ / @
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
** ** @
|
||||
************@
|
||||
///**////**/ @
|
||||
/** /** @
|
||||
************@
|
||||
///**////**/ @
|
||||
// // @@
|
||||
* @
|
||||
*****@
|
||||
/*/*/ @
|
||||
/*****@
|
||||
///*/*@
|
||||
*****@
|
||||
///*/ @
|
||||
/ @@
|
||||
@
|
||||
** ** @
|
||||
// ** @
|
||||
** @
|
||||
** @
|
||||
** @
|
||||
** ** @
|
||||
// // @@
|
||||
** @
|
||||
*/ * @
|
||||
/ ** @
|
||||
*/ * *@
|
||||
* / * @
|
||||
/* /* @
|
||||
/ **** *@
|
||||
//// / @@
|
||||
**@
|
||||
//*@
|
||||
/ @
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
**@
|
||||
** @
|
||||
** @
|
||||
/** @
|
||||
/** @
|
||||
//** @
|
||||
//**@
|
||||
// @@
|
||||
** @
|
||||
//** @
|
||||
//**@
|
||||
/**@
|
||||
/**@
|
||||
** @
|
||||
** @
|
||||
// @@
|
||||
** @
|
||||
** /** ** @
|
||||
//** /** ** @
|
||||
**************@
|
||||
///**//**//**/ @
|
||||
** /** //** @
|
||||
// /** // @
|
||||
// @@
|
||||
@
|
||||
* @
|
||||
/* @
|
||||
*********@
|
||||
/////*/// @
|
||||
/* @
|
||||
/ @
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
**@
|
||||
//*@
|
||||
/ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
*****@
|
||||
///// @
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
**@
|
||||
/**@
|
||||
// @@
|
||||
**@
|
||||
** @
|
||||
** @
|
||||
** @
|
||||
** @
|
||||
** @
|
||||
** @
|
||||
// @@
|
||||
**** @
|
||||
*///**@
|
||||
/* */*@
|
||||
/* * /*@
|
||||
/** /*@
|
||||
/* /*@
|
||||
/ **** @
|
||||
//// @@
|
||||
** @
|
||||
*** @
|
||||
//** @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
****@
|
||||
//// @@
|
||||
**** @
|
||||
*/// *@
|
||||
/ /*@
|
||||
*** @
|
||||
*// @
|
||||
* @
|
||||
/******@
|
||||
////// @@
|
||||
**** @
|
||||
*/// *@
|
||||
/ /*@
|
||||
*** @
|
||||
/// *@
|
||||
* /*@
|
||||
/ **** @
|
||||
//// @@
|
||||
** @
|
||||
*/* @
|
||||
* /* @
|
||||
******@
|
||||
/////* @
|
||||
/* @
|
||||
/* @
|
||||
/ @@
|
||||
******@
|
||||
/*//// @
|
||||
/***** @
|
||||
///// *@
|
||||
/*@
|
||||
* /*@
|
||||
/ **** @
|
||||
//// @@
|
||||
**** @
|
||||
*/// *@
|
||||
/* / @
|
||||
/***** @
|
||||
/*/// *@
|
||||
/* /*@
|
||||
/ **** @
|
||||
//// @@
|
||||
******@
|
||||
//////*@
|
||||
/*@
|
||||
* @
|
||||
* @
|
||||
* @
|
||||
* @
|
||||
/ @@
|
||||
**** @
|
||||
*/// *@
|
||||
/* /*@
|
||||
/ **** @
|
||||
*/// *@
|
||||
/* /*@
|
||||
/ **** @
|
||||
//// @@
|
||||
**** @
|
||||
*/// *@
|
||||
/* /*@
|
||||
/ **** @
|
||||
///* @
|
||||
* @
|
||||
* @
|
||||
/ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
**@
|
||||
// @
|
||||
**@
|
||||
// @@
|
||||
@
|
||||
@
|
||||
@
|
||||
**@
|
||||
// @
|
||||
**@
|
||||
//*@
|
||||
/ @@
|
||||
**@
|
||||
**/ @
|
||||
**/ @
|
||||
**/ @
|
||||
// ** @
|
||||
// ** @
|
||||
// **@
|
||||
// @@
|
||||
@
|
||||
@
|
||||
******@
|
||||
////// @
|
||||
******@
|
||||
////// @
|
||||
@
|
||||
@@
|
||||
** @
|
||||
// ** @
|
||||
// ** @
|
||||
// **@
|
||||
**/ @
|
||||
**/ @
|
||||
**/ @
|
||||
// @@
|
||||
**** @
|
||||
**//**@
|
||||
/** /**@
|
||||
// ** @
|
||||
** @
|
||||
// @
|
||||
** @
|
||||
// @@
|
||||
**** @
|
||||
*/// *@
|
||||
/* **/*@
|
||||
/*/* /*@
|
||||
/*/ ** @
|
||||
/* // @
|
||||
/ *****@
|
||||
///// @@
|
||||
** @
|
||||
**** @
|
||||
**//** @
|
||||
** //** @
|
||||
**********@
|
||||
/**//////**@
|
||||
/** /**@
|
||||
// // @@
|
||||
****** @
|
||||
/*////** @
|
||||
/* /** @
|
||||
/****** @
|
||||
/*//// **@
|
||||
/* /**@
|
||||
/******* @
|
||||
/////// @@
|
||||
****** @
|
||||
**////**@
|
||||
** // @
|
||||
/** @
|
||||
/** @
|
||||
//** **@
|
||||
//****** @
|
||||
////// @@
|
||||
******* @
|
||||
/**////** @
|
||||
/** /**@
|
||||
/** /**@
|
||||
/** /**@
|
||||
/** ** @
|
||||
/******* @
|
||||
/////// @@
|
||||
********@
|
||||
/**///// @
|
||||
/** @
|
||||
/******* @
|
||||
/**//// @
|
||||
/** @
|
||||
/********@
|
||||
//////// @@
|
||||
********@
|
||||
/**///// @
|
||||
/** @
|
||||
/******* @
|
||||
/**//// @
|
||||
/** @
|
||||
/** @
|
||||
// @@
|
||||
******** @
|
||||
**//////**@
|
||||
** // @
|
||||
/** @
|
||||
/** *****@
|
||||
//** ////**@
|
||||
//******** @
|
||||
//////// @@
|
||||
** **@
|
||||
/** /**@
|
||||
/** /**@
|
||||
/**********@
|
||||
/**//////**@
|
||||
/** /**@
|
||||
/** /**@
|
||||
// // @@
|
||||
**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
// @@
|
||||
**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
** /**@
|
||||
//***** @
|
||||
///// @@
|
||||
** **@
|
||||
/** ** @
|
||||
/** ** @
|
||||
/**** @
|
||||
/**/** @
|
||||
/**//** @
|
||||
/** //**@
|
||||
// // @@
|
||||
** @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
/********@
|
||||
//////// @@
|
||||
**** ****@
|
||||
/**/** **/**@
|
||||
/**//** ** /**@
|
||||
/** //*** /**@
|
||||
/** //* /**@
|
||||
/** / /**@
|
||||
/** /**@
|
||||
// // @@
|
||||
**** **@
|
||||
/**/** /**@
|
||||
/**//** /**@
|
||||
/** //** /**@
|
||||
/** //**/**@
|
||||
/** //****@
|
||||
/** //***@
|
||||
// /// @@
|
||||
******* @
|
||||
**/////** @
|
||||
** //**@
|
||||
/** /**@
|
||||
/** /**@
|
||||
//** ** @
|
||||
//******* @
|
||||
/////// @@
|
||||
******* @
|
||||
/**////**@
|
||||
/** /**@
|
||||
/******* @
|
||||
/**//// @
|
||||
/** @
|
||||
/** @
|
||||
// @@
|
||||
******* @
|
||||
**/////** @
|
||||
** //** @
|
||||
/** /** @
|
||||
/** **/** @
|
||||
//** // ** @
|
||||
//******* **@
|
||||
/////// // @@
|
||||
******* @
|
||||
/**////** @
|
||||
/** /** @
|
||||
/******* @
|
||||
/**///** @
|
||||
/** //** @
|
||||
/** //**@
|
||||
// // @@
|
||||
********@
|
||||
**////// @
|
||||
/** @
|
||||
/*********@
|
||||
////////**@
|
||||
/**@
|
||||
******** @
|
||||
//////// @@
|
||||
**********@
|
||||
/////**/// @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
// @@
|
||||
** **@
|
||||
/** /**@
|
||||
/** /**@
|
||||
/** /**@
|
||||
/** /**@
|
||||
/** /**@
|
||||
//******* @
|
||||
/////// @@
|
||||
** **@
|
||||
/** /**@
|
||||
/** /**@
|
||||
//** ** @
|
||||
//** ** @
|
||||
//**** @
|
||||
//** @
|
||||
// @@
|
||||
** **@
|
||||
/** /**@
|
||||
/** * /**@
|
||||
/** *** /**@
|
||||
/** **/**/**@
|
||||
/**** //****@
|
||||
/**/ ///**@
|
||||
// // @@
|
||||
** **@
|
||||
//** ** @
|
||||
//** ** @
|
||||
//*** @
|
||||
**/** @
|
||||
** //** @
|
||||
** //**@
|
||||
// // @@
|
||||
** **@
|
||||
//** ** @
|
||||
//**** @
|
||||
//** @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
// @@
|
||||
********@
|
||||
//////** @
|
||||
** @
|
||||
** @
|
||||
** @
|
||||
** @
|
||||
********@
|
||||
//////// @@
|
||||
*****@
|
||||
/**// @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
/*****@
|
||||
///// @@
|
||||
** @
|
||||
//** @
|
||||
//** @
|
||||
//** @
|
||||
//** @
|
||||
//** @
|
||||
//**@
|
||||
// @@
|
||||
*****@
|
||||
////**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
*****@
|
||||
///// @@
|
||||
** @
|
||||
**/ ** @
|
||||
** // **@
|
||||
// // @
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
*****@
|
||||
///// @@
|
||||
**@
|
||||
/* @
|
||||
/ @
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
****** @
|
||||
//////** @
|
||||
******* @
|
||||
**////** @
|
||||
//********@
|
||||
//////// @@
|
||||
** @
|
||||
/** @
|
||||
/** @
|
||||
/****** @
|
||||
/**///**@
|
||||
/** /**@
|
||||
/****** @
|
||||
///// @@
|
||||
@
|
||||
@
|
||||
***** @
|
||||
**///**@
|
||||
/** // @
|
||||
/** **@
|
||||
//***** @
|
||||
///// @@
|
||||
**@
|
||||
/**@
|
||||
/**@
|
||||
******@
|
||||
**///**@
|
||||
/** /**@
|
||||
//******@
|
||||
////// @@
|
||||
@
|
||||
@
|
||||
***** @
|
||||
**///**@
|
||||
/*******@
|
||||
/**//// @
|
||||
//******@
|
||||
////// @@
|
||||
****@
|
||||
/**/ @
|
||||
******@
|
||||
///**/ @
|
||||
/** @
|
||||
/** @
|
||||
/** @
|
||||
// @@
|
||||
@
|
||||
***** @
|
||||
**///**@
|
||||
/** /**@
|
||||
//******@
|
||||
/////**@
|
||||
***** @
|
||||
///// @@
|
||||
** @
|
||||
/** @
|
||||
/** @
|
||||
/****** @
|
||||
/**///**@
|
||||
/** /**@
|
||||
/** /**@
|
||||
// // @@
|
||||
**@
|
||||
// @
|
||||
**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
// @@
|
||||
**@
|
||||
// @
|
||||
**@
|
||||
/**@
|
||||
/**@
|
||||
**/**@
|
||||
//*** @
|
||||
/// @@
|
||||
** @
|
||||
/** @
|
||||
/** **@
|
||||
/** ** @
|
||||
/**** @
|
||||
/**/** @
|
||||
/**//**@
|
||||
// // @@
|
||||
**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
/**@
|
||||
***@
|
||||
/// @@
|
||||
@
|
||||
@
|
||||
********** @
|
||||
//**//**//**@
|
||||
/** /** /**@
|
||||
/** /** /**@
|
||||
*** /** /**@
|
||||
/// // // @@
|
||||
@
|
||||
@
|
||||
******* @
|
||||
//**///**@
|
||||
/** /**@
|
||||
/** /**@
|
||||
*** /**@
|
||||
/// // @@
|
||||
@
|
||||
@
|
||||
****** @
|
||||
**////**@
|
||||
/** /**@
|
||||
/** /**@
|
||||
//****** @
|
||||
////// @@
|
||||
@
|
||||
****** @
|
||||
/**///**@
|
||||
/** /**@
|
||||
/****** @
|
||||
/**/// @
|
||||
/** @
|
||||
// @@
|
||||
@
|
||||
**** @
|
||||
**//** @
|
||||
/** /** @
|
||||
//***** @
|
||||
////** @
|
||||
/***@
|
||||
/// @@
|
||||
@
|
||||
@
|
||||
******@
|
||||
//**//*@
|
||||
/** / @
|
||||
/** @
|
||||
/*** @
|
||||
/// @@
|
||||
@
|
||||
@
|
||||
******@
|
||||
**//// @
|
||||
//***** @
|
||||
/////**@
|
||||
****** @
|
||||
////// @@
|
||||
** @
|
||||
/** @
|
||||
******@
|
||||
///**/ @
|
||||
/** @
|
||||
/** @
|
||||
//** @
|
||||
// @@
|
||||
@
|
||||
@
|
||||
** **@
|
||||
/** /**@
|
||||
/** /**@
|
||||
/** /**@
|
||||
//******@
|
||||
////// @@
|
||||
@
|
||||
@
|
||||
** **@
|
||||
/** /**@
|
||||
//** /** @
|
||||
//**** @
|
||||
//** @
|
||||
// @@
|
||||
@
|
||||
@
|
||||
*** **@
|
||||
//** * /**@
|
||||
/** ***/**@
|
||||
/****/****@
|
||||
***/ ///**@
|
||||
/// /// @@
|
||||
@
|
||||
@
|
||||
** **@
|
||||
//** ** @
|
||||
//*** @
|
||||
**/** @
|
||||
** //**@
|
||||
// // @@
|
||||
@
|
||||
** **@
|
||||
//** ** @
|
||||
//*** @
|
||||
/** @
|
||||
** @
|
||||
** @
|
||||
// @@
|
||||
@
|
||||
@
|
||||
******@
|
||||
////** @
|
||||
** @
|
||||
** @
|
||||
******@
|
||||
////// @@
|
||||
***@
|
||||
**/ @
|
||||
/** @
|
||||
*** @
|
||||
///** @
|
||||
/** @
|
||||
//***@
|
||||
/// @@
|
||||
*@
|
||||
/*@
|
||||
/*@
|
||||
/ @
|
||||
*@
|
||||
/*@
|
||||
/*@
|
||||
/ @@
|
||||
*** @
|
||||
///** @
|
||||
/** @
|
||||
//***@
|
||||
**/ @
|
||||
/** @
|
||||
*** @
|
||||
/// @@
|
||||
** *** @
|
||||
//***//**@
|
||||
/// // @
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
1670
public/fonts/figlet-fonts/3D Diagonal.flf
Normal file
1670
public/fonts/figlet-fonts/3D Diagonal.flf
Normal file
File diff suppressed because it is too large
Load Diff
1028
public/fonts/figlet-fonts/3D-ASCII.flf
Normal file
1028
public/fonts/figlet-fonts/3D-ASCII.flf
Normal file
File diff suppressed because it is too large
Load Diff
818
public/fonts/figlet-fonts/3d.flf
Normal file
818
public/fonts/figlet-fonts/3d.flf
Normal file
@@ -0,0 +1,818 @@
|
||||
flf2a$ 8 8 20 -1 1
|
||||
3d font created by xero <x@xero.nu>
|
||||
$$@
|
||||
$$@
|
||||
$$@
|
||||
$$@
|
||||
$$@
|
||||
$$@
|
||||
$$@
|
||||
$$@@
|
||||
██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░░ @
|
||||
██@
|
||||
░░ @@
|
||||
█ █@
|
||||
░█ ░█@
|
||||
░ ░ @
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
██ ██ @
|
||||
████████████@
|
||||
░░░██░░░░██░ @
|
||||
░██ ░██ @
|
||||
████████████@
|
||||
░░░██░░░░██░ @
|
||||
░░ ░░ @@
|
||||
█ @
|
||||
█████@
|
||||
░█░█░ @
|
||||
░█████@
|
||||
░░░█░█@
|
||||
█████@
|
||||
░░░█░ @
|
||||
░ @@
|
||||
@
|
||||
██ ██ @
|
||||
░░ ██ @
|
||||
██ @
|
||||
██ @
|
||||
██ @
|
||||
██ ██ @
|
||||
░░ ░░ @@
|
||||
██ @
|
||||
█░ █ @
|
||||
░ ██ @
|
||||
█░ █ █@
|
||||
█ ░ █ @
|
||||
░█ ░█ @
|
||||
░ ████ █@
|
||||
░░░░ ░ @@
|
||||
██@
|
||||
░░█@
|
||||
░ @
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
██@
|
||||
██ @
|
||||
██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░░██ @
|
||||
░░██@
|
||||
░░ @@
|
||||
██ @
|
||||
░░██ @
|
||||
░░██@
|
||||
░██@
|
||||
░██@
|
||||
██ @
|
||||
██ @
|
||||
░░ @@
|
||||
██ @
|
||||
██ ░██ ██ @
|
||||
░░██ ░██ ██ @
|
||||
██████████████@
|
||||
░░░██░░██░░██░ @
|
||||
██ ░██ ░░██ @
|
||||
░░ ░██ ░░ @
|
||||
░░ @@
|
||||
@
|
||||
█ @
|
||||
░█ @
|
||||
█████████@
|
||||
░░░░░█░░░ @
|
||||
░█ @
|
||||
░ @
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
██@
|
||||
░░█@
|
||||
░ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
█████@
|
||||
░░░░░ @
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
██@
|
||||
░██@
|
||||
░░ @@
|
||||
██@
|
||||
██ @
|
||||
██ @
|
||||
██ @
|
||||
██ @
|
||||
██ @
|
||||
██ @
|
||||
░░ @@
|
||||
████ @
|
||||
█░░░██@
|
||||
░█ █░█@
|
||||
░█ █ ░█@
|
||||
░██ ░█@
|
||||
░█ ░█@
|
||||
░ ████ @
|
||||
░░░░ @@
|
||||
██ @
|
||||
███ @
|
||||
░░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
████@
|
||||
░░░░ @@
|
||||
████ @
|
||||
█░░░ █@
|
||||
░ ░█@
|
||||
███ @
|
||||
█░░ @
|
||||
█ @
|
||||
░██████@
|
||||
░░░░░░ @@
|
||||
████ @
|
||||
█░░░ █@
|
||||
░ ░█@
|
||||
███ @
|
||||
░░░ █@
|
||||
█ ░█@
|
||||
░ ████ @
|
||||
░░░░ @@
|
||||
██ @
|
||||
█░█ @
|
||||
█ ░█ @
|
||||
██████@
|
||||
░░░░░█ @
|
||||
░█ @
|
||||
░█ @
|
||||
░ @@
|
||||
██████@
|
||||
░█░░░░ @
|
||||
░█████ @
|
||||
░░░░░ █@
|
||||
░█@
|
||||
█ ░█@
|
||||
░ ████ @
|
||||
░░░░ @@
|
||||
████ @
|
||||
█░░░ █@
|
||||
░█ ░ @
|
||||
░█████ @
|
||||
░█░░░ █@
|
||||
░█ ░█@
|
||||
░ ████ @
|
||||
░░░░ @@
|
||||
██████@
|
||||
░░░░░░█@
|
||||
░█@
|
||||
█ @
|
||||
█ @
|
||||
█ @
|
||||
█ @
|
||||
░ @@
|
||||
████ @
|
||||
█░░░ █@
|
||||
░█ ░█@
|
||||
░ ████ @
|
||||
█░░░ █@
|
||||
░█ ░█@
|
||||
░ ████ @
|
||||
░░░░ @@
|
||||
████ @
|
||||
█░░░ █@
|
||||
░█ ░█@
|
||||
░ ████ @
|
||||
░░░█ @
|
||||
█ @
|
||||
█ @
|
||||
░ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
██@
|
||||
░░ @
|
||||
██@
|
||||
░░ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
██@
|
||||
░░ @
|
||||
██@
|
||||
░░█@
|
||||
░ @@
|
||||
██@
|
||||
██░ @
|
||||
██░ @
|
||||
██░ @
|
||||
░░ ██ @
|
||||
░░ ██ @
|
||||
░░ ██@
|
||||
░░ @@
|
||||
@
|
||||
@
|
||||
██████@
|
||||
░░░░░░ @
|
||||
██████@
|
||||
░░░░░░ @
|
||||
@
|
||||
@@
|
||||
██ @
|
||||
░░ ██ @
|
||||
░░ ██ @
|
||||
░░ ██@
|
||||
██░ @
|
||||
██░ @
|
||||
██░ @
|
||||
░░ @@
|
||||
████ @
|
||||
██░░██@
|
||||
░██ ░██@
|
||||
░░ ██ @
|
||||
██ @
|
||||
░░ @
|
||||
██ @
|
||||
░░ @@
|
||||
████ @
|
||||
█░░░ █@
|
||||
░█ ██░█@
|
||||
░█░█ ░█@
|
||||
░█░ ██ @
|
||||
░█ ░░ @
|
||||
░ █████@
|
||||
░░░░░ @@
|
||||
██ @
|
||||
████ @
|
||||
██░░██ @
|
||||
██ ░░██ @
|
||||
██████████@
|
||||
░██░░░░░░██@
|
||||
░██ ░██@
|
||||
░░ ░░ @@
|
||||
██████ @
|
||||
░█░░░░██ @
|
||||
░█ ░██ @
|
||||
░██████ @
|
||||
░█░░░░ ██@
|
||||
░█ ░██@
|
||||
░███████ @
|
||||
░░░░░░░ @@
|
||||
██████ @
|
||||
██░░░░██@
|
||||
██ ░░ @
|
||||
░██ @
|
||||
░██ @
|
||||
░░██ ██@
|
||||
░░██████ @
|
||||
░░░░░░ @@
|
||||
███████ @
|
||||
░██░░░░██ @
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░██ ██ @
|
||||
░███████ @
|
||||
░░░░░░░ @@
|
||||
████████@
|
||||
░██░░░░░ @
|
||||
░██ @
|
||||
░███████ @
|
||||
░██░░░░ @
|
||||
░██ @
|
||||
░████████@
|
||||
░░░░░░░░ @@
|
||||
████████@
|
||||
░██░░░░░ @
|
||||
░██ @
|
||||
░███████ @
|
||||
░██░░░░ @
|
||||
░██ @
|
||||
░██ @
|
||||
░░ @@
|
||||
████████ @
|
||||
██░░░░░░██@
|
||||
██ ░░ @
|
||||
░██ @
|
||||
░██ █████@
|
||||
░░██ ░░░░██@
|
||||
░░████████ @
|
||||
░░░░░░░░ @@
|
||||
██ ██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░██████████@
|
||||
░██░░░░░░██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░░ ░░ @@
|
||||
██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░░ @@
|
||||
██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
██ ░██@
|
||||
░░█████ @
|
||||
░░░░░ @@
|
||||
██ ██@
|
||||
░██ ██ @
|
||||
░██ ██ @
|
||||
░████ @
|
||||
░██░██ @
|
||||
░██░░██ @
|
||||
░██ ░░██@
|
||||
░░ ░░ @@
|
||||
██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░████████@
|
||||
░░░░░░░░ @@
|
||||
████ ████@
|
||||
░██░██ ██░██@
|
||||
░██░░██ ██ ░██@
|
||||
░██ ░░███ ░██@
|
||||
░██ ░░█ ░██@
|
||||
░██ ░ ░██@
|
||||
░██ ░██@
|
||||
░░ ░░ @@
|
||||
████ ██@
|
||||
░██░██ ░██@
|
||||
░██░░██ ░██@
|
||||
░██ ░░██ ░██@
|
||||
░██ ░░██░██@
|
||||
░██ ░░████@
|
||||
░██ ░░███@
|
||||
░░ ░░░ @@
|
||||
███████ @
|
||||
██░░░░░██ @
|
||||
██ ░░██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░░██ ██ @
|
||||
░░███████ @
|
||||
░░░░░░░ @@
|
||||
███████ @
|
||||
░██░░░░██@
|
||||
░██ ░██@
|
||||
░███████ @
|
||||
░██░░░░ @
|
||||
░██ @
|
||||
░██ @
|
||||
░░ @@
|
||||
███████ @
|
||||
██░░░░░██ @
|
||||
██ ░░██ @
|
||||
░██ ░██ @
|
||||
░██ ██░██ @
|
||||
░░██ ░░ ██ @
|
||||
░░███████ ██@
|
||||
░░░░░░░ ░░ @@
|
||||
███████ @
|
||||
░██░░░░██ @
|
||||
░██ ░██ @
|
||||
░███████ @
|
||||
░██░░░██ @
|
||||
░██ ░░██ @
|
||||
░██ ░░██@
|
||||
░░ ░░ @@
|
||||
████████@
|
||||
██░░░░░░ @
|
||||
░██ @
|
||||
░█████████@
|
||||
░░░░░░░░██@
|
||||
░██@
|
||||
████████ @
|
||||
░░░░░░░░ @@
|
||||
██████████@
|
||||
░░░░░██░░░ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░░ @@
|
||||
██ ██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░░███████ @
|
||||
░░░░░░░ @@
|
||||
██ ██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░░██ ██ @
|
||||
░░██ ██ @
|
||||
░░████ @
|
||||
░░██ @
|
||||
░░ @@
|
||||
██ ██@
|
||||
░██ ░██@
|
||||
░██ █ ░██@
|
||||
░██ ███ ░██@
|
||||
░██ ██░██░██@
|
||||
░████ ░░████@
|
||||
░██░ ░░░██@
|
||||
░░ ░░ @@
|
||||
██ ██@
|
||||
░░██ ██ @
|
||||
░░██ ██ @
|
||||
░░███ @
|
||||
██░██ @
|
||||
██ ░░██ @
|
||||
██ ░░██@
|
||||
░░ ░░ @@
|
||||
██ ██@
|
||||
░░██ ██ @
|
||||
░░████ @
|
||||
░░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░░ @@
|
||||
████████@
|
||||
░░░░░░██ @
|
||||
██ @
|
||||
██ @
|
||||
██ @
|
||||
██ @
|
||||
████████@
|
||||
░░░░░░░░ @@
|
||||
█████@
|
||||
░██░░ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░█████@
|
||||
░░░░░ @@
|
||||
██ @
|
||||
░░██ @
|
||||
░░██ @
|
||||
░░██ @
|
||||
░░██ @
|
||||
░░██ @
|
||||
░░██@
|
||||
░░ @@
|
||||
█████@
|
||||
░░░░██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
█████@
|
||||
░░░░░ @@
|
||||
██ @
|
||||
██░ ██ @
|
||||
██ ░░ ██@
|
||||
░░ ░░ @
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
█████@
|
||||
░░░░░ @@
|
||||
██@
|
||||
░█ @
|
||||
░ @
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
██████ @
|
||||
░░░░░░██ @
|
||||
███████ @
|
||||
██░░░░██ @
|
||||
░░████████@
|
||||
░░░░░░░░ @@
|
||||
██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██████ @
|
||||
░██░░░██@
|
||||
░██ ░██@
|
||||
░██████ @
|
||||
░░░░░ @@
|
||||
@
|
||||
@
|
||||
█████ @
|
||||
██░░░██@
|
||||
░██ ░░ @
|
||||
░██ ██@
|
||||
░░█████ @
|
||||
░░░░░ @@
|
||||
██@
|
||||
░██@
|
||||
░██@
|
||||
██████@
|
||||
██░░░██@
|
||||
░██ ░██@
|
||||
░░██████@
|
||||
░░░░░░ @@
|
||||
@
|
||||
@
|
||||
█████ @
|
||||
██░░░██@
|
||||
░███████@
|
||||
░██░░░░ @
|
||||
░░██████@
|
||||
░░░░░░ @@
|
||||
████@
|
||||
░██░ @
|
||||
██████@
|
||||
░░░██░ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░░ @@
|
||||
@
|
||||
█████ @
|
||||
██░░░██@
|
||||
░██ ░██@
|
||||
░░██████@
|
||||
░░░░░██@
|
||||
█████ @
|
||||
░░░░░ @@
|
||||
██ @
|
||||
░██ @
|
||||
░██ @
|
||||
░██████ @
|
||||
░██░░░██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░░ ░░ @@
|
||||
██@
|
||||
░░ @
|
||||
██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░░ @@
|
||||
██@
|
||||
░░ @
|
||||
██@
|
||||
░██@
|
||||
░██@
|
||||
██░██@
|
||||
░░███ @
|
||||
░░░ @@
|
||||
██ @
|
||||
░██ @
|
||||
░██ ██@
|
||||
░██ ██ @
|
||||
░████ @
|
||||
░██░██ @
|
||||
░██░░██@
|
||||
░░ ░░ @@
|
||||
██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
░██@
|
||||
███@
|
||||
░░░ @@
|
||||
@
|
||||
@
|
||||
██████████ @
|
||||
░░██░░██░░██@
|
||||
░██ ░██ ░██@
|
||||
░██ ░██ ░██@
|
||||
███ ░██ ░██@
|
||||
░░░ ░░ ░░ @@
|
||||
@
|
||||
@
|
||||
███████ @
|
||||
░░██░░░██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
███ ░██@
|
||||
░░░ ░░ @@
|
||||
@
|
||||
@
|
||||
██████ @
|
||||
██░░░░██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░░██████ @
|
||||
░░░░░░ @@
|
||||
@
|
||||
██████ @
|
||||
░██░░░██@
|
||||
░██ ░██@
|
||||
░██████ @
|
||||
░██░░░ @
|
||||
░██ @
|
||||
░░ @@
|
||||
@
|
||||
████ @
|
||||
██░░██ @
|
||||
░██ ░██ @
|
||||
░░█████ @
|
||||
░░░░██ @
|
||||
░███@
|
||||
░░░ @@
|
||||
@
|
||||
@
|
||||
██████@
|
||||
░░██░░█@
|
||||
░██ ░ @
|
||||
░██ @
|
||||
░███ @
|
||||
░░░ @@
|
||||
@
|
||||
@
|
||||
██████@
|
||||
██░░░░ @
|
||||
░░█████ @
|
||||
░░░░░██@
|
||||
██████ @
|
||||
░░░░░░ @@
|
||||
██ @
|
||||
░██ @
|
||||
██████@
|
||||
░░░██░ @
|
||||
░██ @
|
||||
░██ @
|
||||
░░██ @
|
||||
░░ @@
|
||||
@
|
||||
@
|
||||
██ ██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░██ ░██@
|
||||
░░██████@
|
||||
░░░░░░ @@
|
||||
@
|
||||
@
|
||||
██ ██@
|
||||
░██ ░██@
|
||||
░░██ ░██ @
|
||||
░░████ @
|
||||
░░██ @
|
||||
░░ @@
|
||||
@
|
||||
@
|
||||
███ ██@
|
||||
░░██ █ ░██@
|
||||
░██ ███░██@
|
||||
░████░████@
|
||||
███░ ░░░██@
|
||||
░░░ ░░░ @@
|
||||
@
|
||||
@
|
||||
██ ██@
|
||||
░░██ ██ @
|
||||
░░███ @
|
||||
██░██ @
|
||||
██ ░░██@
|
||||
░░ ░░ @@
|
||||
@
|
||||
██ ██@
|
||||
░░██ ██ @
|
||||
░░███ @
|
||||
░██ @
|
||||
██ @
|
||||
██ @
|
||||
░░ @@
|
||||
@
|
||||
@
|
||||
██████@
|
||||
░░░░██ @
|
||||
██ @
|
||||
██ @
|
||||
██████@
|
||||
░░░░░░ @@
|
||||
███@
|
||||
██░ @
|
||||
░██ @
|
||||
███ @
|
||||
░░░██ @
|
||||
░██ @
|
||||
░░███@
|
||||
░░░ @@
|
||||
█@
|
||||
░█@
|
||||
░█@
|
||||
░ @
|
||||
█@
|
||||
░█@
|
||||
░█@
|
||||
░ @@
|
||||
███ @
|
||||
░░░██ @
|
||||
░██ @
|
||||
░░███@
|
||||
██░ @
|
||||
░██ @
|
||||
███ @
|
||||
░░░ @@
|
||||
██ ███ @
|
||||
░░███░░██@
|
||||
░░░ ░░ @
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
1644
public/fonts/figlet-fonts/3d_diagonal.flf
Normal file
1644
public/fonts/figlet-fonts/3d_diagonal.flf
Normal file
File diff suppressed because it is too large
Load Diff
617
public/fonts/figlet-fonts/3x5.flf
Normal file
617
public/fonts/figlet-fonts/3x5.flf
Normal file
@@ -0,0 +1,617 @@
|
||||
flf2a$ 6 4 6 -1 4
|
||||
3x5 font by Richard Kirk (rak@crosfield.co.uk).
|
||||
Ported to figlet, and slightly changed (without permission :-})
|
||||
by Daniel Cabeza Gras (bardo@dia.fi.upm.es)
|
||||
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
@
|
||||
# @@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
# # @
|
||||
### @
|
||||
# # @
|
||||
### @
|
||||
# # @@
|
||||
@
|
||||
## @
|
||||
## @
|
||||
### @
|
||||
## @
|
||||
## @@
|
||||
@
|
||||
# # @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# # @@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
## @
|
||||
# # @
|
||||
### @@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
@
|
||||
@@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
# @
|
||||
### @
|
||||
# @
|
||||
### @
|
||||
# @@
|
||||
@
|
||||
@
|
||||
# @
|
||||
### @
|
||||
# @
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
@
|
||||
@
|
||||
### @
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
# @@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
### @
|
||||
# # @
|
||||
# # @
|
||||
# # @
|
||||
### @@
|
||||
@
|
||||
# @
|
||||
## @
|
||||
# @
|
||||
# @
|
||||
### @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
### @
|
||||
# @
|
||||
### @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
## @
|
||||
# @
|
||||
### @@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
### @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
### @
|
||||
# @
|
||||
### @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
### @
|
||||
# # @
|
||||
### @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
### @
|
||||
# # @
|
||||
### @
|
||||
# # @
|
||||
### @@
|
||||
@
|
||||
### @
|
||||
# # @
|
||||
### @
|
||||
# @
|
||||
### @@
|
||||
@
|
||||
@
|
||||
# @
|
||||
@
|
||||
# @
|
||||
@@
|
||||
@
|
||||
@
|
||||
# @
|
||||
@
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
@
|
||||
### @
|
||||
@
|
||||
### @
|
||||
@@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
## @
|
||||
@
|
||||
# @@
|
||||
@
|
||||
### @
|
||||
# # @
|
||||
# @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
# @
|
||||
# # @
|
||||
### @
|
||||
# # @
|
||||
# # @@
|
||||
@
|
||||
## @
|
||||
# # @
|
||||
## @
|
||||
# # @
|
||||
## @@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
## @@
|
||||
@
|
||||
## @
|
||||
# # @
|
||||
# # @
|
||||
# # @
|
||||
## @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
## @
|
||||
# @
|
||||
### @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
## @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
# # @
|
||||
# # @
|
||||
## @@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
### @
|
||||
# # @
|
||||
# # @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
### @@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
# @
|
||||
# # @
|
||||
# @@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
## @
|
||||
# # @
|
||||
# # @@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
### @@
|
||||
@
|
||||
# # @
|
||||
### @
|
||||
### @
|
||||
# # @
|
||||
# # @@
|
||||
@
|
||||
### @
|
||||
# # @
|
||||
# # @
|
||||
# # @
|
||||
# # @@
|
||||
@
|
||||
# @
|
||||
# # @
|
||||
# # @
|
||||
# # @
|
||||
# @@
|
||||
@
|
||||
## @
|
||||
# # @
|
||||
## @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
# @
|
||||
# # @
|
||||
# # @
|
||||
## @
|
||||
# @@
|
||||
@
|
||||
## @
|
||||
# # @
|
||||
## @
|
||||
# # @
|
||||
# # @@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
## @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
# # @
|
||||
# # @
|
||||
### @@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
# # @
|
||||
# # @
|
||||
# @@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
### @
|
||||
### @
|
||||
# # @@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
# @
|
||||
# # @
|
||||
# # @@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
### @@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
## @@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
## @@
|
||||
@
|
||||
# @
|
||||
# # @
|
||||
@
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
@
|
||||
### @@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
@
|
||||
@@
|
||||
@
|
||||
@
|
||||
## @
|
||||
# # @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
# @
|
||||
### @
|
||||
# # @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
# @
|
||||
### @
|
||||
# # @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
@
|
||||
### @
|
||||
## @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
### @
|
||||
# @
|
||||
## @@
|
||||
@
|
||||
@
|
||||
### @
|
||||
# # @
|
||||
## @
|
||||
### @@
|
||||
@
|
||||
# @
|
||||
### @
|
||||
# # @
|
||||
# # @
|
||||
@@
|
||||
@
|
||||
# @
|
||||
@
|
||||
# @
|
||||
## @
|
||||
@@
|
||||
@
|
||||
# @
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
# @
|
||||
# # @
|
||||
## @
|
||||
# # @
|
||||
@@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
## @
|
||||
@@
|
||||
@
|
||||
@
|
||||
### @
|
||||
### @
|
||||
# # @
|
||||
@@
|
||||
@
|
||||
@
|
||||
## @
|
||||
# # @
|
||||
# # @
|
||||
@@
|
||||
@
|
||||
@
|
||||
### @
|
||||
# # @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
@
|
||||
### @
|
||||
# # @
|
||||
### @
|
||||
# @@
|
||||
@
|
||||
@
|
||||
### @
|
||||
# # @
|
||||
### @
|
||||
# @@
|
||||
@
|
||||
@
|
||||
### @
|
||||
# @
|
||||
# @
|
||||
@@
|
||||
@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
## @
|
||||
@@
|
||||
@
|
||||
# @
|
||||
### @
|
||||
# @
|
||||
## @
|
||||
@@
|
||||
@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
# @
|
||||
@@
|
||||
@
|
||||
@
|
||||
# # @
|
||||
### @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
@
|
||||
# # @
|
||||
# @
|
||||
# # @
|
||||
@@
|
||||
@
|
||||
@
|
||||
# # @
|
||||
### @
|
||||
# @
|
||||
### @@
|
||||
@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
## @
|
||||
@@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
## @
|
||||
# @
|
||||
## @@
|
||||
@
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @
|
||||
# @@
|
||||
@
|
||||
## @
|
||||
# @
|
||||
## @
|
||||
# @
|
||||
## @@
|
||||
@
|
||||
# @
|
||||
### @
|
||||
# @
|
||||
@
|
||||
@@
|
||||
@
|
||||
# # @
|
||||
# @
|
||||
# # @
|
||||
### @
|
||||
# # @@
|
||||
@
|
||||
# # @
|
||||
### @
|
||||
# # @
|
||||
# # @
|
||||
### @@
|
||||
@
|
||||
# # @
|
||||
@
|
||||
# # @
|
||||
# # @
|
||||
### @@
|
||||
@
|
||||
# # @
|
||||
## @
|
||||
# # @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
# # @
|
||||
### @
|
||||
# # @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
# # @
|
||||
@
|
||||
# # @
|
||||
### @
|
||||
@@
|
||||
@
|
||||
### @
|
||||
## @
|
||||
# # @
|
||||
## @
|
||||
# @@
|
||||
411
public/fonts/figlet-fonts/4Max.flf
Normal file
411
public/fonts/figlet-fonts/4Max.flf
Normal file
@@ -0,0 +1,411 @@
|
||||
flf2a$ 4 4 18 16 2
|
||||
4max.flf by Philip Menke (philippe@dds.nl)
|
||||
April 1995
|
||||
$ $#
|
||||
$ $#
|
||||
$ $#
|
||||
$ $##
|
||||
d8b$#
|
||||
Y8P$#
|
||||
`"'$#
|
||||
(8)$##
|
||||
o8o o8o$#
|
||||
`"' `"'$#
|
||||
$#
|
||||
$##
|
||||
__88_88__$#
|
||||
""88"88""$#
|
||||
__88_88__$#
|
||||
""88"88""$##
|
||||
.dPIIY8$#
|
||||
`YbII "$#
|
||||
o.`II8b$#
|
||||
8boIIP'$##
|
||||
.o. dP $#
|
||||
`"'dP $#
|
||||
dP.o.$#
|
||||
dP `"'$##
|
||||
d888 $#
|
||||
dP_______$#
|
||||
Yb"""88""$#
|
||||
`Ybo 88 $##
|
||||
.o.$#
|
||||
,dP'$#
|
||||
$#
|
||||
$##
|
||||
dP$#
|
||||
dP $#
|
||||
Yb $#
|
||||
Yb$##
|
||||
Yb $#
|
||||
Yb$#
|
||||
dP$#
|
||||
dP $##
|
||||
o $#
|
||||
`8.8.8'$#
|
||||
.8.8.8.$#
|
||||
" $##
|
||||
oo $#
|
||||
___88___$#
|
||||
"""88"""$#
|
||||
"" $##
|
||||
$#
|
||||
$#
|
||||
.o.$#
|
||||
,dP'$##
|
||||
$#
|
||||
________$#
|
||||
""""""""$#
|
||||
$##
|
||||
$#
|
||||
$#
|
||||
.o.$#
|
||||
`"'$##
|
||||
dP$#
|
||||
dP $#
|
||||
dP $#
|
||||
dP $##
|
||||
dP"Yb $#
|
||||
dP Yb$#
|
||||
Yb dP$#
|
||||
YbodP $##
|
||||
.d$#
|
||||
.d88$#
|
||||
88$#
|
||||
88$##
|
||||
oP"Yb.$#
|
||||
"' dP'$#
|
||||
dP' $#
|
||||
.d8888$##
|
||||
88888$#
|
||||
.dP$#
|
||||
o `Yb$#
|
||||
YbodP$##
|
||||
dP88 $#
|
||||
dP 88 $#
|
||||
d888888$#
|
||||
88 $##
|
||||
888888$#
|
||||
88oo."$#
|
||||
`8b$#
|
||||
8888P'$##
|
||||
dP' $#
|
||||
.d8' $#
|
||||
8P"""Yb$#
|
||||
`YboodP$##
|
||||
888888P$#
|
||||
dP $#
|
||||
dP $#
|
||||
dP $##
|
||||
.dP"o.$#
|
||||
`8b.d'$#
|
||||
d'`Y8b$#
|
||||
`bodP'$##
|
||||
dP""Yb$#
|
||||
Ybood8$#
|
||||
.8P'$#
|
||||
.dP' $##
|
||||
.o.$#
|
||||
`"'$#
|
||||
.o.$#
|
||||
`"'$##
|
||||
.o.$#
|
||||
`"'$#
|
||||
.o.$#
|
||||
,dP'$##
|
||||
.dP'$#
|
||||
.dP' $#
|
||||
`Yb. $#
|
||||
`Yb.$##
|
||||
$#
|
||||
oooooo$#
|
||||
______$#
|
||||
""""""$##
|
||||
`Yb. $#
|
||||
`Yb.$#
|
||||
.dP'$#
|
||||
.dP' $##
|
||||
oP"Yb.$#
|
||||
"'.dP'$#
|
||||
8P $#
|
||||
(8) $##
|
||||
dP""Yb $#
|
||||
dP PY Yb$#
|
||||
Yb boodP$#
|
||||
Ybooo $##
|
||||
db $#
|
||||
dPYb $#
|
||||
dP__Yb $#
|
||||
dP""""Yb$##
|
||||
88""Yb$#
|
||||
88__dP$#
|
||||
88""Yb$#
|
||||
88oodP$##
|
||||
dP""b8$#
|
||||
dP `"$#
|
||||
Yb $#
|
||||
YboodP$##
|
||||
8888b. $#
|
||||
8I Yb$#
|
||||
8I dY$#
|
||||
8888Y" $##
|
||||
888888$#
|
||||
88__ $#
|
||||
88"" $#
|
||||
888888$##
|
||||
888888$#
|
||||
88__ $#
|
||||
88"" $#
|
||||
88 $##
|
||||
dP""b8$#
|
||||
dP `"$#
|
||||
Yb "88$#
|
||||
YboodP$##
|
||||
88 88$#
|
||||
88 88$#
|
||||
888888$#
|
||||
88 88$##
|
||||
88$#
|
||||
88$#
|
||||
88$#
|
||||
88$##
|
||||
88888$#
|
||||
88$#
|
||||
o. 88$#
|
||||
"bodP'$##
|
||||
88 dP$#
|
||||
88odP $#
|
||||
88"Yb $#
|
||||
88 Yb$##
|
||||
88 $#
|
||||
88 $#
|
||||
88 .o$#
|
||||
88ood8$##
|
||||
8b d8$#
|
||||
88b d88$#
|
||||
88YbdP88$#
|
||||
88 YY 88$##
|
||||
88b 88$#
|
||||
88Yb88$#
|
||||
88 Y88$#
|
||||
88 Y8$##
|
||||
dP"Yb $#
|
||||
dP Yb$#
|
||||
Yb dP$#
|
||||
YbodP $##
|
||||
88""Yb$#
|
||||
88__dP$#
|
||||
88""" $#
|
||||
88 $##
|
||||
dP"Yb $#
|
||||
dP Yb$#
|
||||
Yb b dP$#
|
||||
`"YoYo$##
|
||||
88""Yb$#
|
||||
88__dP$#
|
||||
88"Yb $#
|
||||
88 Yb$##
|
||||
.dP"Y8$#
|
||||
`Ybo."$#
|
||||
o.`Y8b$#
|
||||
8bodP'$##
|
||||
888888$#
|
||||
88 $#
|
||||
88 $#
|
||||
88 $##
|
||||
88 88$#
|
||||
88 88$#
|
||||
Y8 8P$#
|
||||
`YbodP'$##
|
||||
Yb dP$#
|
||||
Yb dP $#
|
||||
YbdP $#
|
||||
YP $##
|
||||
Yb dP$#
|
||||
Yb db dP $#
|
||||
YbdPYbdP $#
|
||||
YP YP $##
|
||||
Yb dP$#
|
||||
YbdP $#
|
||||
dPYb $#
|
||||
dP Yb$##
|
||||
Yb dP$#
|
||||
YbdP $#
|
||||
8P $#
|
||||
dP $##
|
||||
8888P$#
|
||||
dP $#
|
||||
dP $#
|
||||
d8888$##
|
||||
88888$#
|
||||
88 $#
|
||||
88 $#
|
||||
88888$##
|
||||
Yb $#
|
||||
Yb $#
|
||||
Yb $#
|
||||
Yb$##
|
||||
88888$#
|
||||
88$#
|
||||
88$#
|
||||
88888$##
|
||||
.db. $#
|
||||
.dP'`Yb.$#
|
||||
$#
|
||||
$##
|
||||
$#
|
||||
$#
|
||||
$#
|
||||
oooooooooo$##
|
||||
.o. $#
|
||||
`Yb.$#
|
||||
$#
|
||||
$##
|
||||
db $#
|
||||
dPYb $#
|
||||
dP__Yb $#
|
||||
dP""""Yb$##
|
||||
88""Yb$#
|
||||
88__dP$#
|
||||
88""Yb$#
|
||||
88oodP$##
|
||||
dP""b8$#
|
||||
dP `"$#
|
||||
Yb $#
|
||||
YboodP$##
|
||||
8888b. $#
|
||||
8I Yb$#
|
||||
8I dY$#
|
||||
8888Y" $##
|
||||
888888$#
|
||||
88__ $#
|
||||
88"" $#
|
||||
888888$##
|
||||
888888$#
|
||||
88__ $#
|
||||
88"" $#
|
||||
88 $##
|
||||
dP""b8$#
|
||||
dP `"$#
|
||||
Yb "88$#
|
||||
YboodP$##
|
||||
88 88$#
|
||||
88 88$#
|
||||
888888$#
|
||||
88 88$##
|
||||
88$#
|
||||
88$#
|
||||
88$#
|
||||
88$##
|
||||
88888$#
|
||||
88$#
|
||||
o. 88$#
|
||||
"bodP'$##
|
||||
88 dP$#
|
||||
88odP $#
|
||||
88"Yb $#
|
||||
88 Yb$##
|
||||
88 $#
|
||||
88 $#
|
||||
88 .o$#
|
||||
88ood8$##
|
||||
8b d8$#
|
||||
88b d88$#
|
||||
88YbdP88$#
|
||||
88 YY 88$##
|
||||
88b 88$#
|
||||
88Yb88$#
|
||||
88 Y88$#
|
||||
88 Y8$##
|
||||
dP"Yb $#
|
||||
dP Yb$#
|
||||
Yb dP$#
|
||||
YbodP $##
|
||||
88""Yb$#
|
||||
88__dP$#
|
||||
88""" $#
|
||||
88 $##
|
||||
dP"Yb $#
|
||||
dP Yb$#
|
||||
Yb b dP$#
|
||||
`"YoYo$##
|
||||
88""Yb$#
|
||||
88__dP$#
|
||||
88"Yb $#
|
||||
88 Yb$##
|
||||
.dP"Y8$#
|
||||
`Ybo."$#
|
||||
o.`Y8b$#
|
||||
8bodP'$##
|
||||
888888$#
|
||||
88 $#
|
||||
88 $#
|
||||
88 $##
|
||||
88 88$#
|
||||
88 88$#
|
||||
Y8 8P$#
|
||||
`YbodP'$##
|
||||
Yb dP$#
|
||||
Yb dP $#
|
||||
YbdP $#
|
||||
YP $##
|
||||
Yb dP$#
|
||||
Yb db dP $#
|
||||
YbdPYbdP $#
|
||||
YP YP $##
|
||||
Yb dP$#
|
||||
YbdP $#
|
||||
dPYb $#
|
||||
dP Yb$##
|
||||
Yb dP$#
|
||||
YbdP $#
|
||||
8P $#
|
||||
dP $##
|
||||
8888P$#
|
||||
dP $#
|
||||
dP $#
|
||||
d8888$##
|
||||
d888$#
|
||||
.dP $#
|
||||
`Yb $#
|
||||
Y888$##
|
||||
II$#
|
||||
II$#
|
||||
II$#
|
||||
II$##
|
||||
888b $#
|
||||
Yb.$#
|
||||
dP'$#
|
||||
888P $##
|
||||
dP"Yb dP$#
|
||||
dP `YbdP $#
|
||||
$#
|
||||
$##
|
||||
db db db$#
|
||||
""dPYb""$#
|
||||
dP__Yb $#
|
||||
dP""""Yb$##
|
||||
db db $#
|
||||
".oo." $#
|
||||
dP Yb $#
|
||||
YboodP $##
|
||||
db db$#
|
||||
"" ""$#
|
||||
Yb dP$#
|
||||
YbodP $##
|
||||
db db db$#
|
||||
""dPYb""$#
|
||||
dP__Yb $#
|
||||
dP""""Yb$##
|
||||
db db $#
|
||||
".oo." $#
|
||||
dP Yb $#
|
||||
YboodP $##
|
||||
db db$#
|
||||
"" ""$#
|
||||
Y8 8P$#
|
||||
YbodP $##
|
||||
dP"o.$#
|
||||
88.d'$#
|
||||
88`8b$#
|
||||
d8P P'$##
|
||||
617
public/fonts/figlet-fonts/5 Line Oblique.flf
Normal file
617
public/fonts/figlet-fonts/5 Line Oblique.flf
Normal file
@@ -0,0 +1,617 @@
|
||||
flf2a$ 6 6 20 15 4
|
||||
5lineobl.flf 11/94 pk6811s@acad.drake.edu, updated 1/95 syb3@ABER.AC.UK
|
||||
Definitely a 5-line font.
|
||||
Changes: 6/2001 Markus Gebhard markus@jave.de
|
||||
Removed topmost line. It IS a 6 line font! Baseline is 6.
|
||||
$$@
|
||||
$$@
|
||||
$$@
|
||||
$$@
|
||||
$$@
|
||||
$$@@
|
||||
$ @
|
||||
$//$@
|
||||
$//$ @
|
||||
$//$ @
|
||||
$ $ @
|
||||
//$ @@
|
||||
@
|
||||
$| |$@
|
||||
$$$ @
|
||||
$$$ @
|
||||
$$$ @
|
||||
$$$ @@
|
||||
@
|
||||
$ __/__/_$@
|
||||
$__/__/_$ @
|
||||
$ / / $ @
|
||||
@
|
||||
@@
|
||||
@
|
||||
__//_ @
|
||||
( // )$@
|
||||
\\ @
|
||||
(__//_)$ @
|
||||
// @@
|
||||
@
|
||||
() //$@
|
||||
// @
|
||||
// @
|
||||
// ()$@
|
||||
@@
|
||||
@
|
||||
(( ))$@
|
||||
\\ // @
|
||||
$/\\/ $ @
|
||||
// \\ @
|
||||
((___\\$ @@
|
||||
$$ @
|
||||
$//$@
|
||||
$$ @
|
||||
$ @
|
||||
$ @
|
||||
@@
|
||||
@
|
||||
_ $@
|
||||
// $ @
|
||||
// $ @
|
||||
// $ @
|
||||
(( $ @@
|
||||
@
|
||||
))$@
|
||||
//$ @
|
||||
//$ @
|
||||
//$ @
|
||||
//$ @@
|
||||
@
|
||||
$ @
|
||||
@
|
||||
$_\\/_$@
|
||||
$ //\$ @
|
||||
$ @@
|
||||
@
|
||||
@
|
||||
$ $@
|
||||
$_||_$@
|
||||
$ || $@
|
||||
$ $@@
|
||||
@
|
||||
$$ @
|
||||
@
|
||||
@
|
||||
$$ @
|
||||
$//$@@
|
||||
$$$$ @
|
||||
$$$$ @
|
||||
$$$$ @
|
||||
____ $@
|
||||
$$$$ @
|
||||
$$$$ @@
|
||||
@
|
||||
$ @
|
||||
$ @
|
||||
@
|
||||
$$ @
|
||||
() @@
|
||||
@
|
||||
@
|
||||
//$@
|
||||
// @
|
||||
// @
|
||||
//$ @@
|
||||
@
|
||||
___ @
|
||||
// ) )$@
|
||||
// / / @
|
||||
// / / @
|
||||
((___/ /$ @@
|
||||
@
|
||||
@
|
||||
/_ /$ @
|
||||
/ / @
|
||||
/ / @
|
||||
/ /$ @@
|
||||
@
|
||||
___ @
|
||||
// ) )$@
|
||||
___/ / @
|
||||
/ ____/ @
|
||||
/ /____$ @@
|
||||
@
|
||||
___ @
|
||||
// ) )$@
|
||||
__ / / @
|
||||
) ) @
|
||||
((___/ /$ @@
|
||||
@
|
||||
$@
|
||||
//___/ / @
|
||||
/____ / @
|
||||
/ / @
|
||||
/ /$ @@
|
||||
@
|
||||
____ $@
|
||||
// @
|
||||
//__ @
|
||||
) ) @
|
||||
((___/ /$ @@
|
||||
@
|
||||
____$ @
|
||||
// @
|
||||
//__ @
|
||||
// ) ) @
|
||||
((___/ /$ @@
|
||||
@
|
||||
___ $ @
|
||||
// / / @
|
||||
/ / @
|
||||
/ / @
|
||||
/ /$ @@
|
||||
@
|
||||
__ @
|
||||
// ) )$@
|
||||
((_ / / @
|
||||
// ) ) @
|
||||
((__/ /$ @@
|
||||
@
|
||||
___ @
|
||||
// / /$ @
|
||||
((___/ / @
|
||||
/ / @
|
||||
/ /$ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
()$@
|
||||
()$ @
|
||||
@@
|
||||
@
|
||||
@
|
||||
@
|
||||
()$@
|
||||
@
|
||||
//$ @@
|
||||
@
|
||||
$$ @
|
||||
//$@
|
||||
<< @
|
||||
\\$@
|
||||
$$ @@
|
||||
$ $@
|
||||
$ $@
|
||||
$ ___$@
|
||||
$/__/$@
|
||||
$/__/$@
|
||||
$ $@@
|
||||
@
|
||||
$$ @
|
||||
\\ $@
|
||||
>>$@
|
||||
// $@
|
||||
$$ @@
|
||||
@
|
||||
__ @
|
||||
(( ) )$@
|
||||
/ / @
|
||||
( / @
|
||||
()$ @@
|
||||
@
|
||||
__ $ @
|
||||
// ) )$ @
|
||||
// / / $ @
|
||||
\\ () ) )$@
|
||||
\\__/ /$ @@
|
||||
@
|
||||
// | |$@
|
||||
//__| | @
|
||||
/ ___ | @
|
||||
// | | @
|
||||
// | |$@@
|
||||
@
|
||||
// ) )$@
|
||||
//___/ / @
|
||||
/ __ ( @
|
||||
// ) ) @
|
||||
//____/ /$ @@
|
||||
@
|
||||
// ) )$@
|
||||
// @
|
||||
// @
|
||||
// @
|
||||
((____/ /$ @@
|
||||
@
|
||||
// ) )$@
|
||||
// / / @
|
||||
// / / @
|
||||
// / / @
|
||||
//____/ /$ @@
|
||||
@
|
||||
// / /$@
|
||||
//____ @
|
||||
/ ____ @
|
||||
// @
|
||||
//____/ /$ @@
|
||||
@
|
||||
// / /$@
|
||||
//___$ @
|
||||
/ ___ $ @
|
||||
// @
|
||||
// @@
|
||||
@
|
||||
// ) )$@
|
||||
// @
|
||||
// ____$ @
|
||||
// / / @
|
||||
((____/ /$ @@
|
||||
@
|
||||
// / /$@
|
||||
//___ / / @
|
||||
/ ___ / @
|
||||
// / / @
|
||||
// / /$ @@
|
||||
___ ___$@
|
||||
/ / @
|
||||
/ / @
|
||||
/ / @
|
||||
/ / @
|
||||
__/ /___$ @@
|
||||
@
|
||||
/ /$@
|
||||
/ / @
|
||||
/ / @
|
||||
/ / @
|
||||
$((___/ /$ @@
|
||||
@
|
||||
// / /$@
|
||||
//__ / / @
|
||||
//__ /$ @
|
||||
// \ \ @
|
||||
// \ \$ @@
|
||||
@
|
||||
/ / $ @
|
||||
/ / @
|
||||
/ / @
|
||||
/ / @
|
||||
/ /____/ /$@@
|
||||
@
|
||||
/| //| |$@
|
||||
//| // | | @
|
||||
// | // | | @
|
||||
// | // | | @
|
||||
// |// | |$@@
|
||||
@
|
||||
/| / /$@
|
||||
//| / / @
|
||||
// | / / @
|
||||
// | / / @
|
||||
// |/ /$ @@
|
||||
@
|
||||
// ) )$@
|
||||
// / / @
|
||||
// / / @
|
||||
// / / @
|
||||
((___/ /$ @@
|
||||
@
|
||||
// ) )$@
|
||||
//___/ / @
|
||||
/ ____ /$ @
|
||||
// @
|
||||
// @@
|
||||
@
|
||||
// ) )$@
|
||||
// / / @
|
||||
// / / @
|
||||
// \ \ / @
|
||||
((____\ \$ @@
|
||||
@
|
||||
// ) )$@
|
||||
//___/ / @
|
||||
/ ___ ( $ @
|
||||
// | | @
|
||||
// | |$ @@
|
||||
@
|
||||
// ) )$@
|
||||
(( @
|
||||
\\ @
|
||||
) )$ @
|
||||
((___ / / @@
|
||||
@
|
||||
/__ ___/$@
|
||||
/ / @
|
||||
/ / @
|
||||
/ / @
|
||||
/ / $ @@
|
||||
@
|
||||
// / /$@
|
||||
// / / @
|
||||
// / / @
|
||||
// / / @
|
||||
((___/ /$ @@
|
||||
@
|
||||
|| / /$@
|
||||
|| / / @
|
||||
|| / / @
|
||||
||/ / @
|
||||
| /$ @@
|
||||
@
|
||||
|| / | / /$@
|
||||
|| / | / / @
|
||||
|| / /||/ / @
|
||||
||/ / | / @
|
||||
| / | /$ @@
|
||||
@
|
||||
\\ / /$@
|
||||
\ / @
|
||||
/ / @
|
||||
/ /\\ @
|
||||
/ / \\$@@
|
||||
@
|
||||
\\ / /$@
|
||||
\\ / / @
|
||||
\\/ / @
|
||||
/ / @
|
||||
/ /$ @@
|
||||
$___ $@
|
||||
$ / /$@
|
||||
/ / @
|
||||
/ / @
|
||||
$/ / @
|
||||
/ /___$ @@
|
||||
@
|
||||
__$@
|
||||
/ / @
|
||||
/ / @
|
||||
/ / @
|
||||
/ /__$ @@
|
||||
@
|
||||
$ @
|
||||
\\ $ @
|
||||
\\ $ @
|
||||
\\$ @
|
||||
\\$@@
|
||||
@
|
||||
$___ $@
|
||||
/ /$@
|
||||
$/ /$ @
|
||||
/ /$ @
|
||||
__/ /$ @@
|
||||
$@
|
||||
/ |$@
|
||||
//| |$@
|
||||
$@
|
||||
@
|
||||
$ @@
|
||||
$$$$$ @
|
||||
$$$$$ @
|
||||
$$$$$ @
|
||||
$$$$$ @
|
||||
$$$$$ @
|
||||
_____$@@
|
||||
$$ @
|
||||
$@
|
||||
\\$@
|
||||
$@
|
||||
@
|
||||
$$ @@
|
||||
@
|
||||
@
|
||||
___ @
|
||||
// ) )$@
|
||||
// / / @
|
||||
((___( ($ @@
|
||||
@
|
||||
@
|
||||
/ __ @
|
||||
// ) )$@
|
||||
// / / @
|
||||
((___/ /$ @@
|
||||
@
|
||||
@
|
||||
___ @
|
||||
// ) )$@
|
||||
// @
|
||||
((____$ @@
|
||||
@
|
||||
$@
|
||||
___ /$ @
|
||||
// ) /$ @
|
||||
// / /$ @
|
||||
((___/ /$ @@
|
||||
@
|
||||
@
|
||||
___ @
|
||||
//___) )$@
|
||||
// @
|
||||
((____$ @@
|
||||
@
|
||||
// ) )$@
|
||||
__//__ $ @
|
||||
// $ @
|
||||
// $ @
|
||||
// $ @@
|
||||
@
|
||||
@
|
||||
___ @
|
||||
// ) )$@
|
||||
((___/ / @
|
||||
//__ $ @@
|
||||
@
|
||||
@
|
||||
/ __ @
|
||||
// ) )$@
|
||||
// / / @
|
||||
// / /$ @@
|
||||
@
|
||||
@
|
||||
( )$ @
|
||||
/ /$ @
|
||||
/ / @
|
||||
/ /$ @@
|
||||
@
|
||||
@
|
||||
( )$ @
|
||||
/ /$ @
|
||||
/ / @
|
||||
(( / /$ @@
|
||||
@
|
||||
@
|
||||
/ ___$ @
|
||||
//\ \ @
|
||||
// \ \ @
|
||||
// \ \$@@
|
||||
@
|
||||
$@
|
||||
//$ @
|
||||
//$ @
|
||||
//$ @
|
||||
//$ @@
|
||||
@
|
||||
@
|
||||
_ __ @
|
||||
// ) ) ) )$@
|
||||
// / / / / @
|
||||
// / / / /$ @@
|
||||
@
|
||||
@
|
||||
__ @
|
||||
// ) )$@
|
||||
// / / @
|
||||
// / /$ @@
|
||||
@
|
||||
@
|
||||
___ @
|
||||
// ) )$@
|
||||
// / / @
|
||||
((___/ /$ @@
|
||||
@
|
||||
@
|
||||
___ @
|
||||
// ) )$@
|
||||
//___/ / @
|
||||
// $ @@
|
||||
@
|
||||
@
|
||||
___ @
|
||||
// ) )$@
|
||||
((___/ / @
|
||||
( ($ @@
|
||||
@
|
||||
@
|
||||
__ @
|
||||
// ) )$@
|
||||
// @
|
||||
// $ @@
|
||||
@
|
||||
@
|
||||
___ @
|
||||
(( ) )$@
|
||||
\ \ @
|
||||
// ) )$ @@
|
||||
@
|
||||
@
|
||||
__ ___$@
|
||||
$/ / $ @
|
||||
/ / @
|
||||
/ / $ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
// / /$@
|
||||
// / / @
|
||||
((___( ($ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
|| / /$@
|
||||
|| / / @
|
||||
||/ /$ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
// / / / /$@
|
||||
// / / / / @
|
||||
((__( (__/ /$ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
\\ / /$@
|
||||
\/ / @
|
||||
/ /\$ @@
|
||||
@
|
||||
@
|
||||
@
|
||||
// / /$@
|
||||
((___/ / @
|
||||
/ /$ @@
|
||||
@
|
||||
@
|
||||
$___ @
|
||||
$ / /$ @
|
||||
/ / @
|
||||
/ /__$ @@
|
||||
@
|
||||
_$@
|
||||
// $@
|
||||
<< $ @
|
||||
// $ @
|
||||
((_$ @@
|
||||
$@
|
||||
$@
|
||||
||$@
|
||||
||$@
|
||||
||$@
|
||||
||$@@
|
||||
@
|
||||
_ $@
|
||||
))$@
|
||||
//$ @
|
||||
>>$ @
|
||||
// $ @@
|
||||
@
|
||||
_ _$@
|
||||
// \ \_// $@
|
||||
$$$ @
|
||||
$$$ @
|
||||
@@
|
||||
_ _ @
|
||||
@
|
||||
// | |$@
|
||||
//__| | @
|
||||
/ ___ | @
|
||||
// | |$@@
|
||||
_ _ @
|
||||
___ @
|
||||
// ) )$@
|
||||
// / / @
|
||||
// / / @
|
||||
((___/ /$ @@
|
||||
_ _ @
|
||||
@
|
||||
// / /$@
|
||||
// / / @
|
||||
// / / @
|
||||
((___/ /$ @@
|
||||
@
|
||||
_ _ @
|
||||
___ @
|
||||
// ) )$@
|
||||
// / / @
|
||||
((___( ($ @@
|
||||
@
|
||||
_ _ @
|
||||
___ @
|
||||
// ) )$@
|
||||
// / / @
|
||||
((___/ /$ @@
|
||||
@
|
||||
_ _ @
|
||||
@
|
||||
// / /$@
|
||||
// / / @
|
||||
((___/ /$ @@
|
||||
@
|
||||
// ) )$@
|
||||
//__ / /$ @
|
||||
/ __ ( @
|
||||
//___ ) )$ @
|
||||
// $ @@
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user