feat: implement Figlet, Pastel, and Unit tools with a unified layout
- Add Figlet text converter with font selection and history - Add Pastel color palette generator and manipulation suite - Add comprehensive Units converter with category-based logic - Introduce AppShell with Sidebar and Header for navigation - Modernize theme system with CSS variables and new animations - Update project configuration and dependencies
This commit is contained in:
13
app/(app)/figlet/page.tsx
Normal file
13
app/(app)/figlet/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { FigletConverter } from '@/components/figlet/FigletConverter';
|
||||||
|
|
||||||
|
export default function FigletPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-8 max-w-7xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Figlet UI</h1>
|
||||||
|
<p className="text-muted-foreground italic">ASCII Art Text Generator with 373 Fonts</p>
|
||||||
|
</div>
|
||||||
|
<FigletConverter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(app)/layout.tsx
Normal file
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 rounded-lg bg-background font-mono text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{parseColors(inputColors).length} valid colors found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 border rounded-lg bg-card">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Operation</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Select
|
||||||
|
label="Operation"
|
||||||
|
value={operation}
|
||||||
|
onChange={(e) => setOperation(e.target.value as Operation)}
|
||||||
|
>
|
||||||
|
<option value="lighten">Lighten</option>
|
||||||
|
<option value="darken">Darken</option>
|
||||||
|
<option value="saturate">Saturate</option>
|
||||||
|
<option value="desaturate">Desaturate</option>
|
||||||
|
<option value="rotate">Rotate Hue</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">
|
||||||
|
Amount: {operation === 'rotate' ? (amount * 360).toFixed(0) + '°' : (amount * 100).toFixed(0) + '%'}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(parseFloat(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleProcess}
|
||||||
|
disabled={isPending || parseColors(inputColors).length === 0}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Processing..
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Process Colors
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{outputColors.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="p-6 border rounded-lg bg-card">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">
|
||||||
|
Output Colors ({outputColors.length})
|
||||||
|
</h2>
|
||||||
|
<PaletteGrid colors={outputColors} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 border rounded-lg bg-card">
|
||||||
|
<ExportMenu colors={outputColors} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 border rounded-lg bg-card text-center text-muted-foreground">
|
||||||
|
<Download className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>Enter colors and click Process to see results</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 (
|
||||||
|
<div className="p-4 sm:p-8 max-w-7xl mx-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
app/(app)/units/page.tsx
Normal file
13
app/(app)/units/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import MainConverter from '@/components/units/converter/MainConverter';
|
||||||
|
|
||||||
|
export default function UnitsPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-8 max-w-7xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Units Converter</h1>
|
||||||
|
<p className="text-muted-foreground italic">Smart unit converter with 187 units across 23 categories</p>
|
||||||
|
</div>
|
||||||
|
<MainConverter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/api/fonts/route.ts
Normal file
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
150
app/globals.css
150
app/globals.css
@@ -5,16 +5,63 @@
|
|||||||
@source "*.{js,ts,jsx,tsx}";
|
@source "*.{js,ts,jsx,tsx}";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: #0a0a0f;
|
--color-background: var(--background);
|
||||||
--color-foreground: #ffffff;
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
|
||||||
|
/* Category colors for 23 unit types */
|
||||||
|
--color-category-angle: oklch(69.2% 0.154 237.7);
|
||||||
|
--color-category-apparent-power: oklch(64.8% 0.190 293.6);
|
||||||
|
--color-category-area: oklch(75.8% 0.159 70.5);
|
||||||
|
--color-category-current: oklch(75.8% 0.159 70.5);
|
||||||
|
--color-category-digital: oklch(72.3% 0.134 216.8);
|
||||||
|
--color-category-each: oklch(52.5% 0.033 257.5);
|
||||||
|
--color-category-energy: oklch(80.3% 0.162 97.3);
|
||||||
|
--color-category-frequency: oklch(66.8% 0.238 301.6);
|
||||||
|
--color-category-illuminance: oklch(78.3% 0.184 128.6);
|
||||||
|
--color-category-length: oklch(62.1% 0.214 255.5);
|
||||||
|
--color-category-mass: oklch(72.4% 0.159 165.1);
|
||||||
|
--color-category-pace: oklch(71.5% 0.145 192.2);
|
||||||
|
--color-category-parts-per: oklch(69.4% 0.224 350.3);
|
||||||
|
--color-category-power: oklch(62.8% 0.230 16.6);
|
||||||
|
--color-category-pressure: oklch(61.3% 0.218 281.3);
|
||||||
|
--color-category-reactive-energy: oklch(67.5% 0.276 320.6);
|
||||||
|
--color-category-reactive-power: oklch(74.5% 0.233 316.8);
|
||||||
|
--color-category-speed: oklch(72.4% 0.159 165.1);
|
||||||
|
--color-category-temperature: oklch(62.8% 0.257 29.2);
|
||||||
|
--color-category-tempo: oklch(70% 0.18 30);
|
||||||
|
--color-category-time: oklch(58.5% 0.238 293.1);
|
||||||
|
--color-category-voltage: oklch(75.5% 0.159 55.3);
|
||||||
|
--color-category-volume: oklch(64.8% 0.190 293.6);
|
||||||
|
--color-category-volume-flow-rate: oklch(77.9% 0.162 208.8);
|
||||||
|
|
||||||
/* Custom animations */
|
/* Custom animations */
|
||||||
--animate-gradient: gradient 8s linear infinite;
|
--animate-gradient: gradient 8s linear infinite;
|
||||||
--animate-float: float 3s ease-in-out infinite;
|
--animate-float: float 3s ease-in-out infinite;
|
||||||
--animate-glow: glow 2s ease-in-out infinite alternate;
|
--animate-glow: glow 2s ease-in-out infinite alternate;
|
||||||
|
--animate-fade-in: fadeIn 0.3s ease-in-out;
|
||||||
|
--animate-slide-up: slideUp 0.4s ease-out;
|
||||||
|
--animate-slide-down: slideDown 0.4s ease-out;
|
||||||
|
--animate-scale-in: scaleIn 0.2s ease-out;
|
||||||
|
|
||||||
@keyframes gradient {
|
@keyframes gradient {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
@@ -40,20 +87,91 @@
|
|||||||
box-shadow: 0 0 30px rgba(139, 92, 246, 0.6), 0 0 40px rgba(139, 92, 246, 0.3);
|
box-shadow: 0 0 30px rgba(139, 92, 246, 0.6), 0 0 40px rgba(139, 92, 246, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from { transform: translateY(-20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { transform: scale(0.95); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* CORPORATE DARK THEME (The Standard) */
|
||||||
|
--background: #0a0a0f;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--card: rgba(255, 255, 255, 0.03);
|
||||||
|
--card-foreground: #ffffff;
|
||||||
|
--popover: #0f0f15;
|
||||||
|
--popover-foreground: #ffffff;
|
||||||
|
--primary: #8b5cf6;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: rgba(255, 255, 255, 0.05);
|
||||||
|
--secondary-foreground: #ffffff;
|
||||||
|
--muted: rgba(255, 255, 255, 0.05);
|
||||||
|
--muted-foreground: #a1a1aa;
|
||||||
|
--accent: rgba(255, 255, 255, 0.08);
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--input: rgba(255, 255, 255, 0.05);
|
||||||
|
--ring: rgba(139, 92, 246, 0.5);
|
||||||
|
--radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
/* LIGHT ADAPTATION (Keeping the "Glass" look) */
|
||||||
|
--background: oklch(98% 0.005 255);
|
||||||
|
--foreground: oklch(20% 0.04 255);
|
||||||
|
--card: rgba(255, 255, 255, 0.4);
|
||||||
|
--card-foreground: oklch(20% 0.04 255);
|
||||||
|
--popover: oklch(100% 0 0);
|
||||||
|
--popover-foreground: oklch(20% 0.04 255);
|
||||||
|
--primary: oklch(55% 0.22 270);
|
||||||
|
--primary-foreground: oklch(100% 0 0);
|
||||||
|
--secondary: rgba(0, 0, 0, 0.02);
|
||||||
|
--secondary-foreground: oklch(20% 0.04 255);
|
||||||
|
--muted: rgba(0, 0, 0, 0.02);
|
||||||
|
--muted-foreground: oklch(45% 0.04 255);
|
||||||
|
--accent: rgba(0, 0, 0, 0.03);
|
||||||
|
--accent-foreground: oklch(15% 0.05 255);
|
||||||
|
--destructive: oklch(60% 0.2 25);
|
||||||
|
--destructive-foreground: oklch(100% 0 0);
|
||||||
|
--border: rgba(0, 0, 0, 0.06);
|
||||||
|
--input: rgba(0, 0, 0, 0.01);
|
||||||
|
--ring: rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
color: var(--color-foreground);
|
|
||||||
background: var(--color-background);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html {
|
html {
|
||||||
scroll-behavior: auto;
|
scroll-behavior: auto;
|
||||||
@@ -73,10 +191,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@utility glass {
|
@utility glass {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--card);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility gradient-purple-blue {
|
@utility gradient-purple-blue {
|
||||||
@@ -94,3 +212,7 @@ body {
|
|||||||
@utility gradient-yellow-amber {
|
@utility gradient-yellow-amber {
|
||||||
background: linear-gradient(135deg, #eab308 0%, #f59e0b 100%);
|
background: linear-gradient(135deg, #eab308 0%, #f59e0b 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility gradient-brand {
|
||||||
|
background: linear-gradient(to right, #a78bfa, #f472b6, #22d3ee);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import { Providers } from '@/components/providers/Providers';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Kit - Your Creative Toolkit',
|
title: 'Kit - Your Creative Toolkit',
|
||||||
@@ -50,7 +51,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="preconnect" href="https://kit.pivoine.art" />
|
<link rel="preconnect" href="https://kit.pivoine.art" />
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
export default function AnimatedBackground() {
|
export default function AnimatedBackground() {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 -z-10 overflow-hidden">
|
<div className="fixed inset-0 -z-10 overflow-hidden bg-background transition-colors duration-500">
|
||||||
{/* Animated gradient background */}
|
{/* Animated gradient background */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-50"
|
className="absolute inset-0 opacity-[0.08] dark:opacity-50"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #667eea 100%)',
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #667eea 100%)',
|
||||||
backgroundSize: '400% 400%',
|
backgroundSize: '400% 400%',
|
||||||
@@ -13,9 +13,9 @@ export default function AnimatedBackground() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Grid pattern overlay */}
|
{/* Signature Grid pattern overlay - Original landing page specification */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-10"
|
className="absolute inset-0 opacity-[0.05] dark:opacity-10"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `
|
backgroundImage: `
|
||||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||||
@@ -26,9 +26,9 @@ export default function AnimatedBackground() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Floating orbs */}
|
{/* Floating orbs */}
|
||||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-float" />
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark:opacity-20 animate-float" />
|
||||||
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-float" style={{ animationDelay: '2s' }} />
|
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark:opacity-20 animate-float" style={{ animationDelay: '2s' }} />
|
||||||
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-float" style={{ animationDelay: '4s' }} />
|
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark:opacity-20 animate-float" style={{ animationDelay: '4s' }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default function Footer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative py-12 px-4">
|
<footer className="relative py-12 px-4">
|
||||||
<div className="max-w-6xl mx-auto border-t border-gray-600 pt-12">
|
<div className="max-w-6xl mx-auto border-t border-border pt-12">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex flex-col md:flex-row items-center justify-between gap-6"
|
className="flex flex-col md:flex-row items-center justify-between gap-6"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -16,16 +16,16 @@ export default function Footer() {
|
|||||||
transition={{ duration: 0.6 }}
|
transition={{ duration: 0.6 }}
|
||||||
>
|
>
|
||||||
{/* Brand Section */}
|
{/* Brand Section */}
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-purple-400">
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-primary/50 bg-primary/5">
|
||||||
<span className="text-base font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">Kit</span>
|
<span className="text-base font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">Kit</span>
|
||||||
<span className="text-base text-gray-600">•</span>
|
<span className="text-base text-muted-foreground/30">•</span>
|
||||||
<span className="text-base text-purple-400">Open Source</span>
|
<span className="text-base text-primary font-medium">Open Source</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Copyright - centered */}
|
{/* Copyright - centered */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-base text-gray-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
© {currentYear} Kit. Built with Next.js 16 & Tailwind CSS 4.
|
© {currentYear} Kit. Built with Next.js 16 & Tailwind CSS 4
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,15 +34,15 @@ export default function Footer() {
|
|||||||
href="https://dev.pivoine.art/valknar/kit-ui"
|
href="https://dev.pivoine.art/valknar/kit-ui"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group flex items-center gap-3 px-4 py-2 rounded-full border border-gray-700 hover:border-purple-400 transition-all duration-300"
|
className="group flex items-center gap-3 px-4 py-2 rounded-full border border-border hover:border-primary transition-all duration-300 bg-card/50"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-gray-400 group-hover:text-purple-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
<line x1="6" y1="3" x2="6" y2="15" strokeLinecap="round" />
|
<line x1="6" y1="3" x2="6" y2="15" strokeLinecap="round" />
|
||||||
<circle cx="18" cy="6" r="3" />
|
<circle cx="18" cy="6" r="3" />
|
||||||
<circle cx="6" cy="18" r="3" />
|
<circle cx="6" cy="18" r="3" />
|
||||||
<path d="M18 9a9 9 0 01-9 9" strokeLinecap="round" />
|
<path d="M18 9a9 9 0 01-9 9" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-base text-gray-300 group-hover:text-purple-400 transition-colors font-medium">
|
<span className="text-sm text-muted-foreground group-hover:text-primary transition-colors font-medium">
|
||||||
View on Dev
|
View on Dev
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import Logo from './Logo';
|
import Logo from './Logo';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const MotionLink = motion.create(Link);
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +32,7 @@ export default function Hero() {
|
|||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle */}
|
||||||
<motion.p
|
<motion.p
|
||||||
className="text-xl md:text-2xl text-gray-300 mb-4 max-w-2xl mx-auto"
|
className="text-xl md:text-2xl text-muted-foreground mb-4 max-w-2xl mx-auto"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
transition={{ duration: 0.8, delay: 0.4 }}
|
||||||
@@ -39,13 +42,13 @@ export default function Hero() {
|
|||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<motion.p
|
<motion.p
|
||||||
className="text-base md:text-lg text-gray-400 mb-12 max-w-xl mx-auto"
|
className="text-base md:text-lg text-muted-foreground/80 mb-12 max-w-xl mx-auto"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.6 }}
|
transition={{ duration: 0.8, delay: 0.6 }}
|
||||||
>
|
>
|
||||||
A curated collection of creative and utility tools for developers and creators.
|
A curated collection of creative and utility tools for developers and creators
|
||||||
Simple, powerful, and always at your fingertips.
|
Simple, powerful, and always at your fingertips
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
{/* CTA Buttons */}
|
{/* CTA Buttons */}
|
||||||
@@ -55,7 +58,7 @@ export default function Hero() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.8 }}
|
transition={{ duration: 0.8, delay: 0.8 }}
|
||||||
>
|
>
|
||||||
<motion.a
|
<MotionLink
|
||||||
href="#tools"
|
href="#tools"
|
||||||
className="group relative px-8 py-4 rounded-full bg-gradient-to-r from-purple-500 to-cyan-500 text-white font-semibold shadow-lg overflow-hidden"
|
className="group relative px-8 py-4 rounded-full bg-gradient-to-r from-purple-500 to-cyan-500 text-white font-semibold shadow-lg overflow-hidden"
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
@@ -68,7 +71,7 @@ export default function Hero() {
|
|||||||
whileHover={{ x: 0 }}
|
whileHover={{ x: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
/>
|
/>
|
||||||
</motion.a>
|
</MotionLink>
|
||||||
|
|
||||||
<motion.a
|
<motion.a
|
||||||
href="https://dev.pivoine.art/valknar/kit-ui"
|
href="https://dev.pivoine.art/valknar/kit-ui"
|
||||||
@@ -89,7 +92,7 @@ export default function Hero() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Scroll indicator */}
|
{/* Scroll indicator */}
|
||||||
<motion.a
|
<MotionLink
|
||||||
href="#tools"
|
href="#tools"
|
||||||
className="flex flex-col items-center gap-2 cursor-pointer group"
|
className="flex flex-col items-center gap-2 cursor-pointer group"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -104,7 +107,7 @@ export default function Hero() {
|
|||||||
>
|
>
|
||||||
<div className="w-1 h-2 bg-gradient-to-b from-purple-400 to-cyan-400 rounded-full mx-auto" />
|
<div className="w-1 h-2 bg-gradient-to-b from-purple-400 to-cyan-400 rounded-full mx-auto" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.a>
|
</MotionLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function Stats() {
|
|||||||
whileHover={{ y: -5 }}
|
whileHover={{ y: -5 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="inline-flex items-center justify-center w-12 h-12 mb-4 rounded-xl bg-gradient-to-br from-purple-500/20 to-cyan-500/20 text-purple-400"
|
className="inline-flex items-center justify-center w-12 h-12 mb-4 rounded-xl bg-primary/10 text-primary"
|
||||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||||
transition={{ type: 'spring', stiffness: 300 }}
|
transition={{ type: 'spring', stiffness: 300 }}
|
||||||
>
|
>
|
||||||
@@ -57,7 +57,7 @@ export default function Stats() {
|
|||||||
<div className="text-4xl font-bold mb-2 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
|
<div className="text-4xl font-bold mb-2 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
|
||||||
{stat.number}
|
{stat.number}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-400 text-base font-medium">
|
<div className="text-muted-foreground text-base font-medium">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const MotionLink = motion.create(Link);
|
||||||
|
|
||||||
interface ToolCardProps {
|
interface ToolCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -16,10 +19,8 @@ interface ToolCardProps {
|
|||||||
|
|
||||||
export default function ToolCard({ title, description, icon, url, gradient, accentColor, index, badges }: ToolCardProps) {
|
export default function ToolCard({ title, description, icon, url, gradient, accentColor, index, badges }: ToolCardProps) {
|
||||||
return (
|
return (
|
||||||
<motion.a
|
<MotionLink
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="group relative block"
|
className="group relative block"
|
||||||
initial={{ opacity: 0, y: 50 }}
|
initial={{ opacity: 0, y: 50 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
@@ -27,15 +28,15 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
|||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
whileHover={{ y: -10 }}
|
whileHover={{ y: -10 }}
|
||||||
>
|
>
|
||||||
<div className="glass relative overflow-hidden rounded-2xl p-8 h-full transition-all duration-300 group-hover:shadow-2xl">
|
<div className="glass relative overflow-hidden rounded-2xl p-8 h-full transition-all duration-300 group-hover:shadow-2xl group-hover:bg-card/80">
|
||||||
{/* Gradient overlay on hover */}
|
{/* Gradient overlay on hover */}
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 opacity-0 group-hover:opacity-15 transition-opacity duration-300 ${gradient}`}
|
className={`absolute inset-0 opacity-0 group-hover:opacity-10 dark:group-hover:opacity-15 transition-opacity duration-300 ${gradient}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Glow effect */}
|
{/* Glow effect */}
|
||||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl -z-10">
|
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl -z-10">
|
||||||
<div className={`w-full h-full ${gradient} opacity-30`} />
|
<div className={`w-full h-full ${gradient} opacity-20 dark:opacity-30`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
@@ -44,23 +45,14 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
|||||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||||
transition={{ type: 'spring', stiffness: 300 }}
|
transition={{ type: 'spring', stiffness: 300 }}
|
||||||
>
|
>
|
||||||
<div className={`p-4 rounded-xl ${gradient}`}>
|
<div className={`p-4 rounded-xl ${gradient} shadow-lg shadow-black/10`}>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3
|
<h3
|
||||||
className="text-2xl font-bold mb-3 text-white transition-all duration-300"
|
className="text-2xl font-bold mb-3 text-foreground transition-all duration-300 group-hover:text-primary"
|
||||||
style={{
|
|
||||||
color: 'white',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.color = accentColor;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = 'white';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -71,7 +63,7 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
|||||||
{badges.map((badge) => (
|
{badges.map((badge) => (
|
||||||
<span
|
<span
|
||||||
key={badge}
|
key={badge}
|
||||||
className="text-xs px-2 py-1 rounded-full bg-white/5 border border-white/10 text-gray-400"
|
className="text-xs px-2 py-1 rounded-full bg-primary/5 border border-primary/10 text-muted-foreground font-medium"
|
||||||
>
|
>
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
@@ -80,13 +72,13 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-gray-400 group-hover:text-gray-300 transition-colors duration-300">
|
<p className="text-muted-foreground group-hover:text-foreground/80 transition-colors duration-300">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Arrow icon */}
|
{/* Arrow icon */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute bottom-8 right-8 text-gray-400 group-hover:text-gray-200 transition-colors duration-300"
|
className="absolute bottom-8 right-8 text-muted-foreground group-hover:text-primary transition-colors duration-300"
|
||||||
initial={{ x: 0 }}
|
initial={{ x: 0 }}
|
||||||
whileHover={{ x: 5 }}
|
whileHover={{ x: 5 }}
|
||||||
>
|
>
|
||||||
@@ -105,6 +97,6 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
|||||||
</svg>
|
</svg>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.a>
|
</MotionLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const tools = [
|
|||||||
{
|
{
|
||||||
title: 'Pastel',
|
title: 'Pastel',
|
||||||
description: 'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.',
|
description: 'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.',
|
||||||
url: 'https://pastel.kit.pivoine.art',
|
url: '/pastel',
|
||||||
gradient: 'gradient-indigo-purple',
|
gradient: 'gradient-indigo-purple',
|
||||||
accentColor: '#a855f7',
|
accentColor: '#a855f7',
|
||||||
badges: ['Open Source', 'WCAG', 'Free'],
|
badges: ['Open Source', 'WCAG', 'Free'],
|
||||||
@@ -24,7 +24,7 @@ const tools = [
|
|||||||
{
|
{
|
||||||
title: 'Units',
|
title: 'Units',
|
||||||
description: 'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search and conversion history.',
|
description: 'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search and conversion history.',
|
||||||
url: 'https://units.kit.pivoine.art',
|
url: '/units',
|
||||||
gradient: 'gradient-cyan-purple',
|
gradient: 'gradient-cyan-purple',
|
||||||
accentColor: '#2dd4bf',
|
accentColor: '#2dd4bf',
|
||||||
badges: ['Open Source', 'Real-time', 'Free'],
|
badges: ['Open Source', 'Real-time', 'Free'],
|
||||||
@@ -37,7 +37,7 @@ const tools = [
|
|||||||
{
|
{
|
||||||
title: 'Figlet',
|
title: 'Figlet',
|
||||||
description: 'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.',
|
description: 'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.',
|
||||||
url: 'https://figlet.kit.pivoine.art',
|
url: '/figlet',
|
||||||
gradient: 'gradient-yellow-amber',
|
gradient: 'gradient-yellow-amber',
|
||||||
accentColor: '#eab308',
|
accentColor: '#eab308',
|
||||||
badges: ['Open Source', 'ASCII Art', 'Free'],
|
badges: ['Open Source', 'ASCII Art', 'Free'],
|
||||||
@@ -67,8 +67,8 @@ export default function ToolsGrid() {
|
|||||||
<h2 className="text-4xl md:text-5xl font-bold mb-4 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
|
<h2 className="text-4xl md:text-5xl font-bold mb-4 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
|
||||||
Available Tools
|
Available Tools
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
|
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||||
Explore our collection of carefully crafted tools designed to boost your productivity and creativity.
|
Explore our collection of carefully crafted tools designed to boost your productivity and creativity
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|||||||
124
components/figlet/ComparisonMode.tsx
Normal file
124
components/figlet/ComparisonMode.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { Copy, X, Download, GitCompare } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import type { FigletFont } from '@/types/figlet';
|
||||||
|
|
||||||
|
export interface ComparisonModeProps {
|
||||||
|
text: string;
|
||||||
|
selectedFonts: string[];
|
||||||
|
fontResults: Record<string, string>;
|
||||||
|
onRemoveFont: (fontName: string) => void;
|
||||||
|
onCopyFont: (fontName: string, result: string) => void;
|
||||||
|
onDownloadFont: (fontName: string, result: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComparisonMode({
|
||||||
|
text,
|
||||||
|
selectedFonts,
|
||||||
|
fontResults,
|
||||||
|
onRemoveFont,
|
||||||
|
onCopyFont,
|
||||||
|
onDownloadFont,
|
||||||
|
className,
|
||||||
|
}: ComparisonModeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Font Comparison</h2>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selectedFonts.length} font{selectedFonts.length !== 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFonts.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
icon={GitCompare}
|
||||||
|
title="No fonts selected for comparison"
|
||||||
|
description="Click the + icon next to any font in the font selector to add it to the comparison"
|
||||||
|
className="py-12"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{selectedFonts.map((fontName, index) => (
|
||||||
|
<Card
|
||||||
|
key={fontName}
|
||||||
|
className="relative scale-in"
|
||||||
|
style={{ animationDelay: `${index * 50}ms` }}
|
||||||
|
>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{/* Font Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-mono font-semibold px-2 py-1 bg-primary/10 text-primary rounded">
|
||||||
|
{fontName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onCopyFont(fontName, fontResults[fontName] || '')}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDownloadFont(fontName, fontResults[fontName] || '')}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onRemoveFont(fontName)}
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||||
|
title="Remove from comparison"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ASCII Art Preview */}
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="p-4 bg-muted rounded-md overflow-x-auto">
|
||||||
|
<code className="text-xs font-mono whitespace-pre">
|
||||||
|
{fontResults[fontName] || 'Loading...'}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{fontResults[fontName] && (
|
||||||
|
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{fontResults[fontName].split('\n').length} lines
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{Math.max(
|
||||||
|
...fontResults[fontName].split('\n').map((line) => line.length)
|
||||||
|
)} chars wide
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
components/figlet/FigletConverter.tsx
Normal file
288
components/figlet/FigletConverter.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { TextInput } from './TextInput';
|
||||||
|
import { FontPreview } from './FontPreview';
|
||||||
|
import { FontSelector } from './FontSelector';
|
||||||
|
import { TextTemplates } from './TextTemplates';
|
||||||
|
import { HistoryPanel } from './HistoryPanel';
|
||||||
|
import { ComparisonMode } from './ComparisonMode';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { GitCompare } from 'lucide-react';
|
||||||
|
import { textToAscii } from '@/lib/figlet/figletService';
|
||||||
|
import { getFontList } from '@/lib/figlet/fontLoader';
|
||||||
|
import { debounce } from '@/lib/utils/debounce';
|
||||||
|
import { addRecentFont } from '@/lib/storage/favorites';
|
||||||
|
import { addToHistory, type HistoryItem } from '@/lib/storage/history';
|
||||||
|
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import type { FigletFont } from '@/types/figlet';
|
||||||
|
|
||||||
|
export function FigletConverter() {
|
||||||
|
const [text, setText] = React.useState('Figlet UI');
|
||||||
|
const [selectedFont, setSelectedFont] = React.useState('Standard');
|
||||||
|
const [asciiArt, setAsciiArt] = React.useState('');
|
||||||
|
const [fonts, setFonts] = React.useState<FigletFont[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [isComparisonMode, setIsComparisonMode] = React.useState(false);
|
||||||
|
const [comparisonFonts, setComparisonFonts] = React.useState<string[]>([]);
|
||||||
|
const [comparisonResults, setComparisonResults] = React.useState<Record<string, string>>({});
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
// Load fonts and check URL params on mount
|
||||||
|
React.useEffect(() => {
|
||||||
|
getFontList().then(setFonts);
|
||||||
|
|
||||||
|
// Check for URL parameters
|
||||||
|
const urlState = decodeFromUrl();
|
||||||
|
if (urlState) {
|
||||||
|
if (urlState.text) setText(urlState.text);
|
||||||
|
if (urlState.font) setSelectedFont(urlState.font);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Generate ASCII art
|
||||||
|
const generateAsciiArt = React.useCallback(
|
||||||
|
debounce(async (inputText: string, fontName: string) => {
|
||||||
|
if (!inputText) {
|
||||||
|
setAsciiArt('');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await textToAscii(inputText, fontName);
|
||||||
|
setAsciiArt(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating ASCII art:', error);
|
||||||
|
setAsciiArt('Error generating ASCII art. Please try a different font.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, 300),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger generation when text or font changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
generateAsciiArt(text, selectedFont);
|
||||||
|
// Track recent fonts
|
||||||
|
if (selectedFont) {
|
||||||
|
addRecentFont(selectedFont);
|
||||||
|
}
|
||||||
|
// Update URL
|
||||||
|
updateUrl(text, selectedFont);
|
||||||
|
}, [text, selectedFont, generateAsciiArt]);
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!asciiArt) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(asciiArt);
|
||||||
|
addToHistory(text, selectedFont, asciiArt);
|
||||||
|
addToast('Copied to clipboard!', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error);
|
||||||
|
addToast('Failed to copy', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download as text file
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!asciiArt) return;
|
||||||
|
|
||||||
|
const blob = new Blob([asciiArt], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `figlet-${selectedFont}-${Date.now()}.txt`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Share (copy URL to clipboard)
|
||||||
|
const handleShare = async () => {
|
||||||
|
const shareUrl = getShareableUrl(text, selectedFont);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
addToast('Shareable URL copied!', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy URL:', error);
|
||||||
|
addToast('Failed to copy URL', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Random font
|
||||||
|
const handleRandomFont = () => {
|
||||||
|
if (fonts.length === 0) return;
|
||||||
|
const randomIndex = Math.floor(Math.random() * fonts.length);
|
||||||
|
setSelectedFont(fonts[randomIndex].name);
|
||||||
|
addToast(`Random font: ${fonts[randomIndex].name}`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectTemplate = (templateText: string) => {
|
||||||
|
setText(templateText);
|
||||||
|
addToast(`Template applied: ${templateText}`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectHistory = (item: HistoryItem) => {
|
||||||
|
setText(item.text);
|
||||||
|
setSelectedFont(item.font);
|
||||||
|
addToast(`Restored from history`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Comparison mode handlers
|
||||||
|
const handleToggleComparisonMode = () => {
|
||||||
|
const newMode = !isComparisonMode;
|
||||||
|
setIsComparisonMode(newMode);
|
||||||
|
if (newMode && comparisonFonts.length === 0) {
|
||||||
|
// Initialize with current font
|
||||||
|
setComparisonFonts([selectedFont]);
|
||||||
|
}
|
||||||
|
addToast(newMode ? 'Comparison mode enabled' : 'Comparison mode disabled', 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToComparison = (fontName: string) => {
|
||||||
|
if (comparisonFonts.includes(fontName)) {
|
||||||
|
addToast('Font already in comparison', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (comparisonFonts.length >= 6) {
|
||||||
|
addToast('Maximum 6 fonts for comparison', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setComparisonFonts([...comparisonFonts, fontName]);
|
||||||
|
addToast(`Added ${fontName} to comparison`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFromComparison = (fontName: string) => {
|
||||||
|
setComparisonFonts(comparisonFonts.filter((f) => f !== fontName));
|
||||||
|
addToast(`Removed ${fontName} from comparison`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyComparisonFont = async (fontName: string, result: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(result);
|
||||||
|
addToHistory(text, fontName, result);
|
||||||
|
addToast(`Copied ${fontName} to clipboard!`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error);
|
||||||
|
addToast('Failed to copy', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadComparisonFont = (fontName: string, result: string) => {
|
||||||
|
const blob = new Blob([result], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `figlet-${fontName}-${Date.now()}.txt`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate comparison results
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isComparisonMode || comparisonFonts.length === 0 || !text) return;
|
||||||
|
|
||||||
|
const generateComparisons = async () => {
|
||||||
|
const results: Record<string, string> = {};
|
||||||
|
for (const fontName of comparisonFonts) {
|
||||||
|
try {
|
||||||
|
results[fontName] = await textToAscii(text, fontName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error generating ASCII art for ${fontName}:`, error);
|
||||||
|
results[fontName] = 'Error generating ASCII art';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setComparisonResults(results);
|
||||||
|
};
|
||||||
|
|
||||||
|
generateComparisons();
|
||||||
|
}, [isComparisonMode, comparisonFonts, text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column - Input and Preview */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Comparison Mode Toggle */}
|
||||||
|
<Card className="scale-in">
|
||||||
|
<div className="p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GitCompare className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
isComparisonMode ? "text-primary" : "text-muted-foreground"
|
||||||
|
)} />
|
||||||
|
<span className="text-sm font-medium">Comparison Mode</span>
|
||||||
|
{isComparisonMode && comparisonFonts.length > 0 && (
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium slide-down">
|
||||||
|
{comparisonFonts.length} {comparisonFonts.length === 1 ? 'font' : 'fonts'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={isComparisonMode ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleToggleComparisonMode}
|
||||||
|
className={cn(isComparisonMode && "shadow-lg")}
|
||||||
|
>
|
||||||
|
{isComparisonMode ? 'Disable' : 'Enable'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<TextTemplates onSelectTemplate={handleSelectTemplate} />
|
||||||
|
|
||||||
|
<HistoryPanel onSelectHistory={handleSelectHistory} />
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={text}
|
||||||
|
onChange={setText}
|
||||||
|
placeholder="Type your text here..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isComparisonMode ? (
|
||||||
|
<ComparisonMode
|
||||||
|
text={text}
|
||||||
|
selectedFonts={comparisonFonts}
|
||||||
|
fontResults={comparisonResults}
|
||||||
|
onRemoveFont={handleRemoveFromComparison}
|
||||||
|
onCopyFont={handleCopyComparisonFont}
|
||||||
|
onDownloadFont={handleDownloadComparisonFont}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FontPreview
|
||||||
|
text={asciiArt}
|
||||||
|
font={selectedFont}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCopy={handleCopy}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
onShare={handleShare}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Font Selector */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<FontSelector
|
||||||
|
fonts={fonts}
|
||||||
|
selectedFont={selectedFont}
|
||||||
|
onSelectFont={setSelectedFont}
|
||||||
|
onRandomFont={handleRandomFont}
|
||||||
|
isComparisonMode={isComparisonMode}
|
||||||
|
comparisonFonts={comparisonFonts}
|
||||||
|
onAddToComparison={handleAddToComparison}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
components/figlet/FontPreview.tsx
Normal file
205
components/figlet/FontPreview.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { toPng } from 'html-to-image';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { Copy, Download, Share2, Image as ImageIcon, AlignLeft, AlignCenter, AlignRight, Type } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
|
export interface FontPreviewProps {
|
||||||
|
text: string;
|
||||||
|
font?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onCopy?: () => void;
|
||||||
|
onDownload?: () => void;
|
||||||
|
onShare?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextAlign = 'left' | 'center' | 'right';
|
||||||
|
|
||||||
|
export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare, className }: FontPreviewProps) {
|
||||||
|
const lineCount = text ? text.split('\n').length : 0;
|
||||||
|
const charCount = text ? text.length : 0;
|
||||||
|
const previewRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [textAlign, setTextAlign] = React.useState<TextAlign>('left');
|
||||||
|
const [fontSize, setFontSize] = React.useState<'xs' | 'sm' | 'base'>('sm');
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
const handleExportPNG = async () => {
|
||||||
|
if (!previewRef.current || !text) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataUrl = await toPng(previewRef.current, {
|
||||||
|
backgroundColor: getComputedStyle(previewRef.current).backgroundColor,
|
||||||
|
pixelRatio: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `figlet-${font || 'export'}-${Date.now()}.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
addToast('Exported as PNG!', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export PNG:', error);
|
||||||
|
addToast('Failed to export PNG', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card className={cn('relative', className)}>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium">Preview</h3>
|
||||||
|
{font && (
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-md font-mono">
|
||||||
|
{font}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{onCopy && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onCopy}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onShare && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onShare} title="Copy shareable URL">
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportPNG} title="Export as PNG">
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
PNG
|
||||||
|
</Button>
|
||||||
|
{onDownload && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onDownload}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
TXT
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<div className="flex items-center gap-1 border rounded-md p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setTextAlign('left')}
|
||||||
|
className={cn(
|
||||||
|
'p-1.5 rounded transition-colors',
|
||||||
|
textAlign === 'left' ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
title="Align left"
|
||||||
|
>
|
||||||
|
<AlignLeft className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTextAlign('center')}
|
||||||
|
className={cn(
|
||||||
|
'p-1.5 rounded transition-colors',
|
||||||
|
textAlign === 'center' ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
title="Align center"
|
||||||
|
>
|
||||||
|
<AlignCenter className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTextAlign('right')}
|
||||||
|
className={cn(
|
||||||
|
'p-1.5 rounded transition-colors',
|
||||||
|
textAlign === 'right' ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
title="Align right"
|
||||||
|
>
|
||||||
|
<AlignRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 border rounded-md p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setFontSize('xs')}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 text-xs rounded transition-colors',
|
||||||
|
fontSize === 'xs' ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
XS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFontSize('sm')}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 text-xs rounded transition-colors',
|
||||||
|
fontSize === 'sm' ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
SM
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFontSize('base')}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 text-xs rounded transition-colors',
|
||||||
|
fontSize === 'base' ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
MD
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLoading && text && (
|
||||||
|
<div className="flex gap-4 mb-2 text-xs text-muted-foreground">
|
||||||
|
<span>{lineCount} lines</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{charCount} chars</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={previewRef}
|
||||||
|
className={cn(
|
||||||
|
'relative min-h-[200px] bg-muted/50 rounded-lg p-4 overflow-x-auto',
|
||||||
|
textAlign === 'center' && 'text-center',
|
||||||
|
textAlign === 'right' && 'text-right'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-6 w-3/4" />
|
||||||
|
<Skeleton className="h-6 w-full" />
|
||||||
|
<Skeleton className="h-6 w-5/6" />
|
||||||
|
<Skeleton className="h-6 w-2/3" />
|
||||||
|
<Skeleton className="h-6 w-full" />
|
||||||
|
<Skeleton className="h-6 w-4/5" />
|
||||||
|
</div>
|
||||||
|
) : text ? (
|
||||||
|
<pre className={cn(
|
||||||
|
'font-mono whitespace-pre overflow-x-auto animate-in',
|
||||||
|
fontSize === 'xs' && 'text-[10px]',
|
||||||
|
fontSize === 'sm' && 'text-xs sm:text-sm',
|
||||||
|
fontSize === 'base' && 'text-sm sm:text-base'
|
||||||
|
)}>
|
||||||
|
{text}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Type}
|
||||||
|
title="Start typing to see your ASCII art"
|
||||||
|
description="Enter text in the input field above to generate ASCII art with the selected font"
|
||||||
|
className="py-8"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
components/figlet/FontSelector.tsx
Normal file
279
components/figlet/FontSelector.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import Fuse from 'fuse.js';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { Search, X, Heart, Clock, List, Shuffle, Plus, Check } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { FigletFont } from '@/types/figlet';
|
||||||
|
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
|
||||||
|
|
||||||
|
export interface FontSelectorProps {
|
||||||
|
fonts: FigletFont[];
|
||||||
|
selectedFont: string;
|
||||||
|
onSelectFont: (fontName: string) => void;
|
||||||
|
onRandomFont?: () => void;
|
||||||
|
isComparisonMode?: boolean;
|
||||||
|
comparisonFonts?: string[];
|
||||||
|
onAddToComparison?: (fontName: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'favorites' | 'recent';
|
||||||
|
|
||||||
|
export function FontSelector({
|
||||||
|
fonts,
|
||||||
|
selectedFont,
|
||||||
|
onSelectFont,
|
||||||
|
onRandomFont,
|
||||||
|
isComparisonMode = false,
|
||||||
|
comparisonFonts = [],
|
||||||
|
onAddToComparison,
|
||||||
|
className
|
||||||
|
}: FontSelectorProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
|
const [filter, setFilter] = React.useState<FilterType>('all');
|
||||||
|
const [favorites, setFavorites] = React.useState<string[]>([]);
|
||||||
|
const [recentFonts, setRecentFonts] = React.useState<string[]>([]);
|
||||||
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Load favorites and recent fonts
|
||||||
|
React.useEffect(() => {
|
||||||
|
setFavorites(getFavorites());
|
||||||
|
setRecentFonts(getRecentFonts());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// "/" to focus search
|
||||||
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
// "Esc" to clear search
|
||||||
|
if (e.key === 'Escape' && searchQuery) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearchQuery('');
|
||||||
|
searchInputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Initialize Fuse.js for fuzzy search
|
||||||
|
const fuse = React.useMemo(() => {
|
||||||
|
return new Fuse(fonts, {
|
||||||
|
keys: ['name', 'fileName'],
|
||||||
|
threshold: 0.3,
|
||||||
|
includeScore: true,
|
||||||
|
});
|
||||||
|
}, [fonts]);
|
||||||
|
|
||||||
|
const filteredFonts = React.useMemo(() => {
|
||||||
|
let fontsToFilter = fonts;
|
||||||
|
|
||||||
|
// Apply category filter
|
||||||
|
if (filter === 'favorites') {
|
||||||
|
fontsToFilter = fonts.filter(f => favorites.includes(f.name));
|
||||||
|
} else if (filter === 'recent') {
|
||||||
|
fontsToFilter = fonts.filter(f => recentFonts.includes(f.name));
|
||||||
|
// Sort by recent order
|
||||||
|
fontsToFilter.sort((a, b) => {
|
||||||
|
return recentFonts.indexOf(a.name) - recentFonts.indexOf(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search query
|
||||||
|
if (!searchQuery) return fontsToFilter;
|
||||||
|
|
||||||
|
const results = fuse.search(searchQuery);
|
||||||
|
const searchResults = results.map(result => result.item);
|
||||||
|
|
||||||
|
// Filter search results by category
|
||||||
|
if (filter === 'favorites') {
|
||||||
|
return searchResults.filter(f => favorites.includes(f.name));
|
||||||
|
} else if (filter === 'recent') {
|
||||||
|
return searchResults.filter(f => recentFonts.includes(f.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResults;
|
||||||
|
}, [fonts, searchQuery, fuse, filter, favorites, recentFonts]);
|
||||||
|
|
||||||
|
const handleToggleFavorite = (fontName: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFavorite(fontName);
|
||||||
|
setFavorites(getFavorites());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium">Select Font</h3>
|
||||||
|
{onRandomFont && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRandomFont}
|
||||||
|
title="Random font"
|
||||||
|
>
|
||||||
|
<Shuffle className="h-4 w-4" />
|
||||||
|
Random
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Tabs */}
|
||||||
|
<div className="flex gap-1 mb-4 p-1 bg-muted rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
|
filter === 'all' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<List className="inline-block h-3 w-3 mr-1" />
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('favorites')}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
|
filter === 'favorites' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Heart className="inline-block h-3 w-3 mr-1" />
|
||||||
|
Favorites
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('recent')}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||||
|
filter === 'recent' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Clock className="inline-block h-3 w-3 mr-1" />
|
||||||
|
Recent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search fonts... (Press / to focus)"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 pr-9"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font List */}
|
||||||
|
<div className="max-h-[400px] overflow-y-auto space-y-1 pr-2">
|
||||||
|
{filteredFonts.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={filter === 'favorites' ? Heart : (filter === 'recent' ? Clock : Search)}
|
||||||
|
title={
|
||||||
|
filter === 'favorites'
|
||||||
|
? 'No favorite fonts yet'
|
||||||
|
: filter === 'recent'
|
||||||
|
? 'No recent fonts'
|
||||||
|
: 'No fonts found'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
filter === 'favorites'
|
||||||
|
? 'Click the heart icon on any font to add it to your favorites'
|
||||||
|
: filter === 'recent'
|
||||||
|
? 'Fonts you use will appear here'
|
||||||
|
: searchQuery
|
||||||
|
? 'Try a different search term'
|
||||||
|
: 'Loading fonts...'
|
||||||
|
}
|
||||||
|
className="py-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
filteredFonts.map((font) => {
|
||||||
|
const isInComparison = comparisonFonts.includes(font.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={font.name}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
selectedFont === font.name && 'bg-accent text-accent-foreground font-medium'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectFont(font.name)}
|
||||||
|
className="flex-1 text-left"
|
||||||
|
>
|
||||||
|
{font.name}
|
||||||
|
</button>
|
||||||
|
{isComparisonMode && onAddToComparison && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddToComparison(font.name);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded',
|
||||||
|
isInComparison && 'opacity-100 bg-primary/10'
|
||||||
|
)}
|
||||||
|
aria-label={isInComparison ? 'In comparison' : 'Add to comparison'}
|
||||||
|
disabled={isInComparison}
|
||||||
|
>
|
||||||
|
{isInComparison ? (
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-4 w-4 text-muted-foreground hover:text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleToggleFavorite(font.name, e)}
|
||||||
|
className={cn(
|
||||||
|
'opacity-0 group-hover:opacity-100 transition-opacity',
|
||||||
|
isFavorite(font.name) && 'opacity-100'
|
||||||
|
)}
|
||||||
|
aria-label={isFavorite(font.name) ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 transition-colors',
|
||||||
|
isFavorite(font.name) ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-red-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mt-4 pt-4 border-t text-xs text-muted-foreground">
|
||||||
|
{filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''}
|
||||||
|
{filter === 'favorites' && ` • ${favorites.length} total favorites`}
|
||||||
|
{filter === 'recent' && ` • ${recentFonts.length} recent`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
components/figlet/HistoryPanel.tsx
Normal file
133
components/figlet/HistoryPanel.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { History, X, Trash2, ChevronDown, ChevronUp, Clock } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { getHistory, clearHistory, removeHistoryItem, type HistoryItem } from '@/lib/storage/history';
|
||||||
|
|
||||||
|
export interface HistoryPanelProps {
|
||||||
|
onSelectHistory: (item: HistoryItem) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryPanel({ onSelectHistory, className }: HistoryPanelProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
const [history, setHistory] = React.useState<HistoryItem[]>([]);
|
||||||
|
|
||||||
|
const loadHistory = React.useCallback(() => {
|
||||||
|
setHistory(getHistory());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadHistory();
|
||||||
|
// Refresh history every 2 seconds when expanded
|
||||||
|
if (isExpanded) {
|
||||||
|
const interval = setInterval(loadHistory, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [isExpanded, loadHistory]);
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
clearHistory();
|
||||||
|
loadHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (id: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeHistoryItem(id);
|
||||||
|
loadHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
|
||||||
|
if (minutes < 1) return 'Just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<div className="p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center justify-between text-sm font-medium hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
<span>Copy History</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({history.length})</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-4 space-y-2 slide-down">
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Clock}
|
||||||
|
title="No copy history yet"
|
||||||
|
description="Your recently copied ASCII art will appear here"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 mr-1" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{history.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelectHistory(item)}
|
||||||
|
className="group relative p-3 bg-muted/50 hover:bg-accent hover:scale-[1.02] rounded-md cursor-pointer transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-mono px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||||
|
{item.font}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatTime(item.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs truncate">{item.text}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleRemove(item.id, e)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-destructive/10 rounded"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-destructive" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
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-input rounded-lg bg-background resize-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 placeholder:text-muted-foreground"
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 right-2 text-xs text-muted-foreground">
|
||||||
|
{value.length}/100
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
components/figlet/TextTemplates.tsx
Normal file
92
components/figlet/TextTemplates.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { TEXT_TEMPLATES, TEMPLATE_CATEGORIES } from '@/lib/figlet/constants/templates';
|
||||||
|
|
||||||
|
export interface TextTemplatesProps {
|
||||||
|
onSelectTemplate: (text: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextTemplates({ onSelectTemplate, className }: TextTemplatesProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
|
||||||
|
|
||||||
|
const filteredTemplates = React.useMemo(() => {
|
||||||
|
if (selectedCategory === 'all') return TEXT_TEMPLATES;
|
||||||
|
return TEXT_TEMPLATES.filter(t => t.category === selectedCategory);
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<div className="p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center justify-between text-sm font-medium hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
<span>Text Templates</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({TEXT_TEMPLATES.length})</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-4 space-y-3 slide-down">
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCategory('all')}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 text-xs rounded-md transition-colors',
|
||||||
|
selectedCategory === 'all'
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted hover:bg-muted/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{TEMPLATE_CATEGORIES.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => setSelectedCategory(cat.id)}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 text-xs rounded-md transition-colors',
|
||||||
|
selectedCategory === cat.id
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted hover:bg-muted/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cat.icon} {cat.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates Grid */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{filteredTemplates.map((template) => (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
onClick={() => onSelectTemplate(template.text)}
|
||||||
|
className="px-3 py-2 text-xs bg-muted hover:bg-accent hover:scale-105 rounded-md transition-all text-left truncate"
|
||||||
|
title={template.text}
|
||||||
|
>
|
||||||
|
{template.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
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-white/5 bg-background/10 backdrop-blur-xl sticky top-0 z-40 flex items-center justify-between px-4 lg:px-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="lg:hidden text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<nav className="hidden sm:flex items-center text-sm font-medium text-muted-foreground">
|
||||||
|
<Link href="/" className="hover:text-foreground transition-colors flex items-center gap-2">
|
||||||
|
<span>Kit</span>
|
||||||
|
</Link>
|
||||||
|
{pathSegments.map((segment, index) => {
|
||||||
|
const href = `/${pathSegments.slice(0, index + 1).join('/')}`;
|
||||||
|
const isLast = index === pathSegments.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={href}>
|
||||||
|
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground/30" />
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"capitalize transition-colors",
|
||||||
|
isLast ? "text-foreground font-semibold" : "hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{segment.replace(/-/g, ' ')}
|
||||||
|
</Link>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
|
<ThemeToggleComponent />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeToggleComponent() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-white/5"
|
||||||
|
title={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
|
>
|
||||||
|
{resolvedTheme === 'dark' ? (
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 min-h-screen bg-background text-foreground relative">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<AppSidebar />
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 relative z-10">
|
||||||
|
<AppHeader />
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
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-white/5 bg-background/40 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative",
|
||||||
|
isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||||
|
isCollapsed ? "lg:w-20" : "w-64"
|
||||||
|
)}>
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div className="flex h-16 items-center justify-between px-6 shrink-0 border-b border-white/5">
|
||||||
|
<Link href="/" className="flex items-center gap-3 group overflow-hidden">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Logo size={isCollapsed ? 32 : 32} />
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="font-bold text-xl bg-clip-text text-transparent bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400 group-hover:opacity-80 transition-opacity whitespace-nowrap">
|
||||||
|
Kit
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="lg:hidden text-muted-foreground"
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 overflow-y-auto px-4 py-2 space-y-8 mt-4 scrollbar-hide">
|
||||||
|
{navigation.map((group) => (
|
||||||
|
<div key={group.label} className="space-y-2">
|
||||||
|
{!isCollapsed && (
|
||||||
|
<h4 className="px-3 text-xs font-semibold text-muted-foreground/50 uppercase tracking-wider">
|
||||||
|
{group.label}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.href} className="space-y-1">
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => { if (window.innerWidth < 1024) close(); }}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-3 py-2 rounded-xl text-sm font-medium transition-all duration-300 relative group/item",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary shadow-[0_0_15px_rgba(139,92,246,0.15)] ring-1 ring-primary/20"
|
||||||
|
: "text-muted-foreground hover:bg-white/5 hover:text-foreground",
|
||||||
|
isCollapsed ? "justify-center" : "justify-between"
|
||||||
|
)}
|
||||||
|
title={isCollapsed ? item.title : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={cn(
|
||||||
|
"transition-colors duration-300 shrink-0",
|
||||||
|
isActive ? "text-primary" : "text-muted-foreground group-hover/item:text-foreground"
|
||||||
|
)}>
|
||||||
|
{React.isValidElement(item.icon) ? item.icon : null}
|
||||||
|
</span>
|
||||||
|
{!isCollapsed && <span className="whitespace-nowrap">{item.title}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCollapsed && item.items && (
|
||||||
|
<ChevronRight className={cn(
|
||||||
|
"h-3.5 w-3.5 transition-transform duration-300",
|
||||||
|
pathname.startsWith(item.href) && "rotate-90"
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapsed Active Indicator */}
|
||||||
|
{isCollapsed && isActive && (
|
||||||
|
<div className="absolute left-0 w-1 h-6 bg-primary rounded-r-full" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{item.items && pathname.startsWith(item.href) && !isCollapsed && (
|
||||||
|
<div className="ml-9 space-y-1 border-l border-white/5 pl-2 mt-1">
|
||||||
|
{item.items.map((subItem) => (
|
||||||
|
<Link
|
||||||
|
key={subItem.href}
|
||||||
|
href={subItem.href}
|
||||||
|
onClick={() => { if (window.innerWidth < 1024) close(); }}
|
||||||
|
className={cn(
|
||||||
|
"block px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
|
||||||
|
pathname === subItem.href
|
||||||
|
? "text-primary bg-primary/5 font-semibold"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{subItem.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Sidebar Footer / Desktop Toggle */}
|
||||||
|
<div className="p-4 border-t border-white/5 hidden lg:block">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full flex items-center justify-center gap-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={toggleCollapse}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">Collapse Sidebar</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/providers/Providers.tsx
Normal file
32
components/providers/Providers.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ThemeProvider } from './ThemeProvider';
|
||||||
|
import { ToastProvider } from '@/components/ui/Toast';
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
<Toaster position="top-right" richColors />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
components/providers/ThemeProvider.tsx
Normal file
78
components/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>('dark');
|
||||||
|
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('dark');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load theme from localStorage
|
||||||
|
const stored = localStorage.getItem('theme') as Theme | null;
|
||||||
|
if (stored) {
|
||||||
|
setTheme(stored);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
// Remove previous theme classes
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
root.classList.add(systemTheme);
|
||||||
|
setResolvedTheme(systemTheme);
|
||||||
|
} else {
|
||||||
|
root.classList.add(theme);
|
||||||
|
setResolvedTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
if (theme === 'system') {
|
||||||
|
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
|
||||||
|
setResolvedTheme(systemTheme);
|
||||||
|
window.document.documentElement.classList.remove('light', 'dark');
|
||||||
|
window.document.documentElement.classList.add(systemTheme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
35
components/ui/Badge.tsx
Normal file
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 bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-foreground':
|
||||||
|
variant === 'outline',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-lg hover:shadow-destructive/20':
|
||||||
|
variant === 'destructive',
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80':
|
||||||
|
variant === 'secondary',
|
||||||
|
'h-10 px-5 py-2 text-sm': size === 'default',
|
||||||
|
'h-9 px-4 text-xs': size === 'sm',
|
||||||
|
'h-12 px-8 text-base': size === 'lg',
|
||||||
|
'h-10 w-10': size === 'icon',
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button };
|
||||||
50
components/ui/Card.tsx
Normal file
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('glass rounded-2xl text-card-foreground shadow-xl transition-all duration-300', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
231
components/ui/CommandPalette.tsx
Normal file
231
components/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/units/utils';
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
onSelectMeasure: (measure: Measure) => void;
|
||||||
|
onSelectUnit: (unit: string, measure: Measure) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommandPalette({
|
||||||
|
onSelectMeasure,
|
||||||
|
onSelectUnit,
|
||||||
|
}: CommandPaletteProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
const commands: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: any;
|
||||||
|
action: () => void;
|
||||||
|
keywords: string[];
|
||||||
|
color?: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'theme-light',
|
||||||
|
label: 'Switch to Light Mode',
|
||||||
|
icon: Sun,
|
||||||
|
action: () => setTheme('light'),
|
||||||
|
keywords: ['theme', 'light', 'mode'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-dark',
|
||||||
|
label: 'Switch to Dark Mode',
|
||||||
|
icon: Moon,
|
||||||
|
action: () => setTheme('dark'),
|
||||||
|
keywords: ['theme', 'dark', 'mode'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-system',
|
||||||
|
label: 'Use System Theme',
|
||||||
|
icon: Command,
|
||||||
|
action: () => setTheme('system'),
|
||||||
|
keywords: ['theme', 'system', 'auto'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add measure commands
|
||||||
|
const measures = getAllMeasures();
|
||||||
|
const measureCommands = measures.map(measure => ({
|
||||||
|
id: `measure-${measure}`,
|
||||||
|
label: `Convert ${formatMeasureName(measure)}`,
|
||||||
|
icon: Hash,
|
||||||
|
action: () => onSelectMeasure(measure),
|
||||||
|
keywords: ['convert', measure, formatMeasureName(measure).toLowerCase()],
|
||||||
|
color: getCategoryColorHex(measure),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const allCommands = [...commands, ...measureCommands];
|
||||||
|
|
||||||
|
// Filter commands
|
||||||
|
const filteredCommands = query
|
||||||
|
? allCommands.filter(cmd =>
|
||||||
|
cmd.keywords.some(kw => kw.toLowerCase().includes(query.toLowerCase())) ||
|
||||||
|
cmd.label.toLowerCase().includes(query.toLowerCase())
|
||||||
|
)
|
||||||
|
: allCommands;
|
||||||
|
|
||||||
|
// Keyboard shortcut to open
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(prev => !prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Focus input when opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
setQuery('');
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev =>
|
||||||
|
prev < filteredCommands.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const command = filteredCommands[selectedIndex];
|
||||||
|
if (command) {
|
||||||
|
command.action();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, selectedIndex, filteredCommands]);
|
||||||
|
|
||||||
|
// Reset selected index when query changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Command Palette */}
|
||||||
|
<div className="fixed left-1/2 top-1/4 -translate-x-1/2 w-full max-w-2xl z-50 animate-scale-in">
|
||||||
|
<div className="bg-popover border rounded-lg shadow-2xl overflow-hidden">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="flex items-center border-b px-4">
|
||||||
|
<Command className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Type a command or search..."
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
className="flex-1 bg-transparent py-4 px-4 outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<kbd className="hidden sm:inline-block px-2 py-1 text-xs bg-muted rounded">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commands List */}
|
||||||
|
<div className="max-h-96 overflow-y-auto p-2">
|
||||||
|
{filteredCommands.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
No commands found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredCommands.map((command, index) => {
|
||||||
|
const Icon = command.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={command.id}
|
||||||
|
onClick={() => {
|
||||||
|
command.action();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-4 py-3 rounded-md transition-colors text-left',
|
||||||
|
index === selectedIndex
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{command.color ? (
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: command.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1">{command.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t px-4 py-2 text-xs text-muted-foreground flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-muted rounded">↑</kbd>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-muted rounded">↓</kbd>
|
||||||
|
<span>Navigate</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-muted rounded">Enter</kbd>
|
||||||
|
<span>Select</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-muted rounded">ESC</kbd>
|
||||||
|
<span>Close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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-white/10 bg-white/5 px-4 py-2',
|
||||||
|
'text-sm ring-offset-background file:border-0 file:bg-transparent',
|
||||||
|
'file:text-sm file:font-medium placeholder:text-muted-foreground',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary/50',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
102
components/ui/KeyboardShortcutsHelp.tsx
Normal file
102
components/ui/KeyboardShortcutsHelp.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Card } from './Card';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { X, Keyboard } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export interface Shortcut {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
modifier?: 'ctrl' | 'shift';
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcuts: Shortcut[] = [
|
||||||
|
{ key: '/', description: 'Focus font search' },
|
||||||
|
{ key: 'Esc', description: 'Clear search / Close dialog' },
|
||||||
|
{ key: 'D', description: 'Toggle dark/light mode', modifier: 'ctrl' },
|
||||||
|
{ key: '?', description: 'Show this help dialog', modifier: 'shift' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function KeyboardShortcutsHelp() {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === '?' && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
title="Keyboard shortcuts (Shift + ?)"
|
||||||
|
className="fixed bottom-4 right-4"
|
||||||
|
>
|
||||||
|
<Keyboard className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="max-w-md w-full">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Keyboard className="h-5 w-5" />
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h2>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">Navigation</h3>
|
||||||
|
{shortcuts.map((shortcut, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||||
|
<span className="text-sm text-muted-foreground">{shortcut.description}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{shortcut.modifier && (
|
||||||
|
<kbd className="px-2 py-1 text-xs font-semibold bg-muted rounded">
|
||||||
|
{shortcut.modifier === 'ctrl' ? '⌘/Ctrl' : 'Shift'}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
<kbd className="px-2 py-1 text-xs font-semibold bg-muted rounded">
|
||||||
|
{shortcut.key}
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">Tips</h3>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-1">
|
||||||
|
<li>• Click the Shuffle button for random fonts</li>
|
||||||
|
<li>• Use the heart icon to favorite fonts</li>
|
||||||
|
<li>• Filter by All, Favorites, or Recent</li>
|
||||||
|
<li>• Text alignment and size controls in Preview</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
components/ui/Select.tsx
Normal file
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-white/10 bg-white/5 px-3 py-2',
|
||||||
|
'text-sm ring-offset-background',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary/50',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Select.displayName = 'Select';
|
||||||
|
|
||||||
|
export { Select };
|
||||||
32
components/ui/Separator.tsx
Normal file
32
components/ui/Separator.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
orientation?: 'horizontal' | 'vertical';
|
||||||
|
decorative?: boolean;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role={decorative ? undefined : 'separator'}
|
||||||
|
aria-orientation={decorative ? undefined : orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Separator.displayName = 'Separator';
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
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 };
|
||||||
90
components/ui/Toast.tsx
Normal file
90
components/ui/Toast.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: ToastType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
toasts: Toast[];
|
||||||
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = React.createContext<ToastContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = React.useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const addToast = React.useCallback((message: string, type: ToastType = 'success') => {
|
||||||
|
const id = Math.random().toString(36).substring(7);
|
||||||
|
setToasts((prev) => [...prev, { id, message, type }]);
|
||||||
|
|
||||||
|
// Auto remove after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = React.useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||||
|
const Icon = toast.type === 'success' ? CheckCircle2 : toast.type === 'error' ? AlertCircle : Info;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg pointer-events-auto',
|
||||||
|
'animate-in slide-in-from-right-full duration-300',
|
||||||
|
'min-w-[300px] max-w-[400px]',
|
||||||
|
{
|
||||||
|
'bg-green-50 text-green-900 border border-green-200 dark:bg-green-900/20 dark:text-green-100 dark:border-green-800':
|
||||||
|
toast.type === 'success',
|
||||||
|
'bg-red-50 text-red-900 border border-red-200 dark:bg-red-900/20 dark:text-red-100 dark:border-red-800':
|
||||||
|
toast.type === 'error',
|
||||||
|
'bg-blue-50 text-blue-900 border border-blue-200 dark:bg-blue-900/20 dark:text-blue-100 dark:border-blue-800':
|
||||||
|
toast.type === 'info',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<p className="text-sm font-medium flex-1">{toast.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = React.useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
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/units/utils';
|
||||||
|
import { formatMeasureName } from '@/lib/units/units';
|
||||||
|
|
||||||
|
interface ConversionHistoryProps {
|
||||||
|
onSelectConversion?: (record: ConversionRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversionHistory({
|
||||||
|
onSelectConversion,
|
||||||
|
}: ConversionHistoryProps) {
|
||||||
|
const [history, setHistory] = useState<ConversionRecord[]>([]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory();
|
||||||
|
|
||||||
|
// Listen for storage changes
|
||||||
|
const handleStorageChange = () => {
|
||||||
|
loadHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
// Also listen for custom event from same window
|
||||||
|
window.addEventListener('historyUpdated', handleStorageChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', handleStorageChange);
|
||||||
|
window.removeEventListener('historyUpdated', handleStorageChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadHistory = () => {
|
||||||
|
setHistory(getHistory());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearHistory = () => {
|
||||||
|
if (confirm('Clear all conversion history?')) {
|
||||||
|
clearHistory();
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<History className="h-5 w-5" />
|
||||||
|
Recent Conversions
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{isOpen ? 'Hide' : `Show (${history.length})`}
|
||||||
|
</Button>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleClearHistory}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{history.map((record) => (
|
||||||
|
<button
|
||||||
|
key={record.id}
|
||||||
|
onClick={() => onSelectConversion?.(record)}
|
||||||
|
className="w-full p-3 rounded-lg border hover:bg-accent transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<span className="truncate">
|
||||||
|
{formatNumber(record.from.value)} {record.from.unit}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{formatNumber(record.to.value)} {record.to.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
|
||||||
|
<span>{formatMeasureName(record.measure as any)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{getRelativeTime(record.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
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/ui/CommandPalette';
|
||||||
|
import {
|
||||||
|
getAllMeasures,
|
||||||
|
getUnitsForMeasure,
|
||||||
|
convertToAll,
|
||||||
|
convertUnit,
|
||||||
|
formatMeasureName,
|
||||||
|
getCategoryColor,
|
||||||
|
getCategoryColorHex,
|
||||||
|
type Measure,
|
||||||
|
type ConversionResult,
|
||||||
|
} from '@/lib/units/units';
|
||||||
|
import { parseNumberInput, formatNumber, cn } from '@/lib/units/utils';
|
||||||
|
import { saveToHistory, getFavorites, toggleFavorite } from '@/lib/units/storage';
|
||||||
|
|
||||||
|
export default function MainConverter() {
|
||||||
|
const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length');
|
||||||
|
const [selectedUnit, setSelectedUnit] = useState<string>('m');
|
||||||
|
const [targetUnit, setTargetUnit] = useState<string>('ft');
|
||||||
|
const [inputValue, setInputValue] = useState<string>('1');
|
||||||
|
const [conversions, setConversions] = useState<ConversionResult[]>([]);
|
||||||
|
const [favorites, setFavorites] = useState<string[]>([]);
|
||||||
|
const [copiedUnit, setCopiedUnit] = useState<string | null>(null);
|
||||||
|
const [showVisualComparison, setShowVisualComparison] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const measures = getAllMeasures();
|
||||||
|
const units = getUnitsForMeasure(selectedMeasure);
|
||||||
|
|
||||||
|
// Load favorites
|
||||||
|
useEffect(() => {
|
||||||
|
setFavorites(getFavorites());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update conversions when input changes
|
||||||
|
useEffect(() => {
|
||||||
|
const numValue = parseNumberInput(inputValue);
|
||||||
|
if (numValue !== null && selectedUnit) {
|
||||||
|
const results = convertToAll(numValue, selectedUnit);
|
||||||
|
setConversions(results);
|
||||||
|
} else {
|
||||||
|
setConversions([]);
|
||||||
|
}
|
||||||
|
}, [inputValue, selectedUnit]);
|
||||||
|
|
||||||
|
// Update selected unit when measure changes
|
||||||
|
useEffect(() => {
|
||||||
|
const availableUnits = getUnitsForMeasure(selectedMeasure);
|
||||||
|
if (availableUnits.length > 0) {
|
||||||
|
setSelectedUnit(availableUnits[0]);
|
||||||
|
setTargetUnit(availableUnits[1] || availableUnits[0]);
|
||||||
|
}
|
||||||
|
}, [selectedMeasure]);
|
||||||
|
|
||||||
|
// Swap units
|
||||||
|
const handleSwapUnits = useCallback(() => {
|
||||||
|
const temp = selectedUnit;
|
||||||
|
setSelectedUnit(targetUnit);
|
||||||
|
setTargetUnit(temp);
|
||||||
|
|
||||||
|
// Convert the value
|
||||||
|
const numValue = parseNumberInput(inputValue);
|
||||||
|
if (numValue !== null) {
|
||||||
|
const converted = convertUnit(numValue, selectedUnit, targetUnit);
|
||||||
|
setInputValue(converted.toString());
|
||||||
|
}
|
||||||
|
}, [selectedUnit, targetUnit, inputValue]);
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
const copyToClipboard = useCallback(async (value: number, unit: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`${formatNumber(value)} ${unit}`);
|
||||||
|
setCopiedUnit(unit);
|
||||||
|
setTimeout(() => setCopiedUnit(null), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle favorite
|
||||||
|
const handleToggleFavorite = useCallback((unit: string) => {
|
||||||
|
const isFavorite = toggleFavorite(unit);
|
||||||
|
setFavorites(getFavorites());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save to history when conversion happens (but not during dragging)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) return; // Don't save to history while dragging
|
||||||
|
|
||||||
|
const numValue = parseNumberInput(inputValue);
|
||||||
|
if (numValue !== null && selectedUnit && conversions.length > 0) {
|
||||||
|
// Save first conversion to history
|
||||||
|
const firstConversion = conversions.find(c => c.unit !== selectedUnit);
|
||||||
|
if (firstConversion) {
|
||||||
|
saveToHistory({
|
||||||
|
from: { value: numValue, unit: selectedUnit },
|
||||||
|
to: { value: firstConversion.value, unit: firstConversion.unit },
|
||||||
|
measure: selectedMeasure,
|
||||||
|
});
|
||||||
|
// Dispatch custom event for same-window updates
|
||||||
|
window.dispatchEvent(new Event('historyUpdated'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [inputValue, selectedUnit, conversions, selectedMeasure, isDragging]);
|
||||||
|
|
||||||
|
// Handle search selection
|
||||||
|
const handleSearchSelect = useCallback((unit: string, measure: Measure) => {
|
||||||
|
setSelectedMeasure(measure);
|
||||||
|
setSelectedUnit(unit);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle history selection
|
||||||
|
const handleHistorySelect = useCallback((record: any) => {
|
||||||
|
setInputValue(record.from.value.toString());
|
||||||
|
setSelectedMeasure(record.measure);
|
||||||
|
setSelectedUnit(record.from.unit);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle value change from draggable bars
|
||||||
|
const handleValueChange = useCallback((value: number, unit: string, dragging: boolean) => {
|
||||||
|
setIsDragging(dragging);
|
||||||
|
|
||||||
|
// Convert the dragged unit's value back to the currently selected unit
|
||||||
|
// This keeps the source unit stable while updating the value
|
||||||
|
const convertedValue = convertUnit(value, unit, selectedUnit);
|
||||||
|
setInputValue(convertedValue.toString());
|
||||||
|
// Keep selectedUnit unchanged
|
||||||
|
}, [selectedUnit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
{/* Command Palette */}
|
||||||
|
<CommandPalette
|
||||||
|
onSelectMeasure={setSelectedMeasure}
|
||||||
|
onSelectUnit={handleSearchSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<SearchUnits onSelectUnit={handleSearchSelect} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Selection */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Select Category</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2">
|
||||||
|
{measures.map((measure) => (
|
||||||
|
<Button
|
||||||
|
key={measure}
|
||||||
|
variant={selectedMeasure === measure ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedMeasure(measure)}
|
||||||
|
className="justify-start"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
selectedMeasure === measure
|
||||||
|
? getCategoryColorHex(measure)
|
||||||
|
: undefined,
|
||||||
|
borderColor: selectedMeasure !== measure
|
||||||
|
? getCategoryColorHex(measure)
|
||||||
|
: undefined,
|
||||||
|
color: selectedMeasure === measure ? 'white' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatMeasureName(measure)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Input Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Convert {formatMeasureName(selectedMeasure)}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm font-medium mb-2 block">Value</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
placeholder="Enter value"
|
||||||
|
className="text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<label className="text-sm font-medium mb-2 block">From</label>
|
||||||
|
<select
|
||||||
|
value={selectedUnit}
|
||||||
|
onChange={(e) => setSelectedUnit(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{units.map((unit) => (
|
||||||
|
<option key={unit} value={unit}>
|
||||||
|
{unit}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleSwapUnits}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
title="Swap units"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="w-40">
|
||||||
|
<label className="text-sm font-medium mb-2 block">To</label>
|
||||||
|
<select
|
||||||
|
value={targetUnit}
|
||||||
|
onChange={(e) => setTargetUnit(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{units.map((unit) => (
|
||||||
|
<option key={unit} value={unit}>
|
||||||
|
{unit}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick result */}
|
||||||
|
{parseNumberInput(inputValue) !== null && (
|
||||||
|
<div className="p-4 rounded-lg bg-accent/50 border-l-4" style={{
|
||||||
|
borderLeftColor: getCategoryColorHex(selectedMeasure),
|
||||||
|
}}>
|
||||||
|
<div className="text-sm text-muted-foreground">Result</div>
|
||||||
|
<div className="text-3xl font-bold mt-1" style={{
|
||||||
|
color: getCategoryColorHex(selectedMeasure),
|
||||||
|
}}>
|
||||||
|
{formatNumber(convertUnit(parseNumberInput(inputValue)!, selectedUnit, targetUnit))} {targetUnit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>All Conversions</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowVisualComparison(!showVisualComparison)}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
|
{showVisualComparison ? 'Grid View' : 'Chart View'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{showVisualComparison ? (
|
||||||
|
<VisualComparison
|
||||||
|
conversions={conversions}
|
||||||
|
color={getCategoryColorHex(selectedMeasure)}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{conversions.map((conversion) => {
|
||||||
|
const isFavorite = favorites.includes(conversion.unit);
|
||||||
|
const isCopied = copiedUnit === conversion.unit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={conversion.unit}
|
||||||
|
className="group relative p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||||
|
style={{
|
||||||
|
borderLeftWidth: '4px',
|
||||||
|
borderLeftColor: getCategoryColorHex(selectedMeasure),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Favorite & Copy buttons */}
|
||||||
|
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleToggleFavorite(conversion.unit)}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4',
|
||||||
|
isFavorite && 'fill-yellow-400 text-yellow-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => copyToClipboard(conversion.value, conversion.unit)}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground mb-1">
|
||||||
|
{conversion.unitInfo.plural}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(conversion.value)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">
|
||||||
|
{conversion.unit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Conversion History */}
|
||||||
|
<ConversionHistory onSelectConversion={handleHistorySelect} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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/units/utils';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
unitInfo: UnitInfo;
|
||||||
|
measure: Measure;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchUnitsProps {
|
||||||
|
onSelectUnit: (unit: string, measure: Measure) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchUnits({ onSelectUnit }: SearchUnitsProps) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Build search index
|
||||||
|
const searchIndex = useRef<Fuse<SearchResult> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Build comprehensive search data
|
||||||
|
const allData: SearchResult[] = [];
|
||||||
|
const measures = getAllMeasures();
|
||||||
|
|
||||||
|
for (const measure of measures) {
|
||||||
|
const units = getUnitsForMeasure(measure);
|
||||||
|
|
||||||
|
for (const unit of units) {
|
||||||
|
const unitInfo = getUnitInfo(unit);
|
||||||
|
if (unitInfo) {
|
||||||
|
allData.push({
|
||||||
|
unitInfo,
|
||||||
|
measure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Fuse.js for fuzzy search
|
||||||
|
searchIndex.current = new Fuse(allData, {
|
||||||
|
keys: [
|
||||||
|
{ name: 'unitInfo.abbr', weight: 2 },
|
||||||
|
{ name: 'unitInfo.singular', weight: 1.5 },
|
||||||
|
{ name: 'unitInfo.plural', weight: 1.5 },
|
||||||
|
{ name: 'measure', weight: 1 },
|
||||||
|
],
|
||||||
|
threshold: 0.3,
|
||||||
|
includeScore: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Perform search
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim() || !searchIndex.current) {
|
||||||
|
setResults([]);
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResults = searchIndex.current.search(query);
|
||||||
|
setResults(searchResults.map(r => r.item).slice(0, 10));
|
||||||
|
setIsOpen(true);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// Handle click outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard shortcut: / to focus search
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (
|
||||||
|
activeElement?.tagName !== 'INPUT' &&
|
||||||
|
activeElement?.tagName !== 'TEXTAREA'
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectUnit = (unit: string, measure: Measure) => {
|
||||||
|
onSelectUnit(unit, measure);
|
||||||
|
setQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full max-w-md">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search units (press / to focus)"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onFocus={() => query && setIsOpen(true)}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
||||||
|
onClick={clearSearch}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results dropdown */}
|
||||||
|
{isOpen && results.length > 0 && (
|
||||||
|
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-lg shadow-lg max-h-80 overflow-y-auto">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<button
|
||||||
|
key={`${result.measure}-${result.unitInfo.abbr}`}
|
||||||
|
onClick={() => handleSelectUnit(result.unitInfo.abbr, result.measure)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-4 py-3 text-left hover:bg-accent transition-colors',
|
||||||
|
'flex items-center justify-between gap-4',
|
||||||
|
index !== 0 && 'border-t'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{result.unitInfo.plural}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<span className="truncate">{result.unitInfo.abbr}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="truncate">{formatMeasureName(result.measure)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getCategoryColorHex(result.measure),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && query && results.length === 0 && (
|
||||||
|
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-lg shadow-lg p-4 text-center text-muted-foreground">
|
||||||
|
No units found for "{query}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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/units/utils';
|
||||||
|
|
||||||
|
interface VisualComparisonProps {
|
||||||
|
conversions: ConversionResult[];
|
||||||
|
color: string;
|
||||||
|
onValueChange?: (value: number, unit: string, dragging: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VisualComparison({
|
||||||
|
conversions,
|
||||||
|
color,
|
||||||
|
onValueChange,
|
||||||
|
}: VisualComparisonProps) {
|
||||||
|
const [draggingUnit, setDraggingUnit] = useState<string | null>(null);
|
||||||
|
const [draggedPercentage, setDraggedPercentage] = useState<number | null>(null);
|
||||||
|
const dragStartX = useRef<number>(0);
|
||||||
|
const dragStartWidth = useRef<number>(0);
|
||||||
|
const activeBarRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const lastUpdateTime = useRef<number>(0);
|
||||||
|
const baseConversionsRef = useRef<ConversionResult[]>([]);
|
||||||
|
// Calculate percentages for visual bars using logarithmic scale
|
||||||
|
const withPercentages = useMemo(() => {
|
||||||
|
if (conversions.length === 0) return [];
|
||||||
|
|
||||||
|
// Use base conversions for scale if we're dragging (keeps scale stable)
|
||||||
|
const scaleSource = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
|
||||||
|
|
||||||
|
// Get all values from the SCALE SOURCE (not current conversions)
|
||||||
|
const values = scaleSource.map(c => Math.abs(c.value));
|
||||||
|
const maxValue = Math.max(...values);
|
||||||
|
const minValue = Math.min(...values.filter(v => v > 0));
|
||||||
|
|
||||||
|
if (maxValue === 0 || !isFinite(maxValue)) {
|
||||||
|
return conversions.map(c => ({ ...c, percentage: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use logarithmic scale for better visualization
|
||||||
|
return conversions.map(c => {
|
||||||
|
const absValue = Math.abs(c.value);
|
||||||
|
|
||||||
|
if (absValue === 0 || !isFinite(absValue)) {
|
||||||
|
return { ...c, percentage: 2 }; // Show minimal bar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logarithmic scale
|
||||||
|
const logValue = Math.log10(absValue);
|
||||||
|
const logMax = Math.log10(maxValue);
|
||||||
|
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6; // 6 orders of magnitude range
|
||||||
|
|
||||||
|
const logRange = logMax - logMin;
|
||||||
|
|
||||||
|
let percentage: number;
|
||||||
|
if (logRange === 0) {
|
||||||
|
percentage = 100;
|
||||||
|
} else {
|
||||||
|
percentage = ((logValue - logMin) / logRange) * 100;
|
||||||
|
// Ensure bars are visible - minimum 3%, maximum 100%
|
||||||
|
percentage = Math.max(3, Math.min(100, percentage));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
percentage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [conversions]);
|
||||||
|
|
||||||
|
// Calculate value from percentage (reverse logarithmic scale)
|
||||||
|
const calculateValueFromPercentage = useCallback((
|
||||||
|
percentage: number,
|
||||||
|
minValue: number,
|
||||||
|
maxValue: number
|
||||||
|
): number => {
|
||||||
|
const logMax = Math.log10(maxValue);
|
||||||
|
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
|
||||||
|
const logRange = logMax - logMin;
|
||||||
|
|
||||||
|
// Convert percentage back to log value
|
||||||
|
const logValue = logMin + (percentage / 100) * logRange;
|
||||||
|
// Convert log value back to actual value
|
||||||
|
return Math.pow(10, logValue);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mouse drag handlers
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
|
||||||
|
if (!onValueChange) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
setDraggingUnit(unit);
|
||||||
|
setDraggedPercentage(currentPercentage);
|
||||||
|
dragStartX.current = e.clientX;
|
||||||
|
dragStartWidth.current = currentPercentage;
|
||||||
|
activeBarRef.current = barElement;
|
||||||
|
// Save the current conversions as reference
|
||||||
|
baseConversionsRef.current = [...conversions];
|
||||||
|
}, [onValueChange, conversions]);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
|
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
|
||||||
|
|
||||||
|
// Throttle updates to every 16ms (~60fps)
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastUpdateTime.current < 16) return;
|
||||||
|
lastUpdateTime.current = now;
|
||||||
|
|
||||||
|
const barWidth = activeBarRef.current.offsetWidth;
|
||||||
|
const deltaX = e.clientX - dragStartX.current;
|
||||||
|
const deltaPercentage = (deltaX / barWidth) * 100;
|
||||||
|
|
||||||
|
let newPercentage = dragStartWidth.current + deltaPercentage;
|
||||||
|
newPercentage = Math.max(3, Math.min(100, newPercentage));
|
||||||
|
|
||||||
|
// Update visual percentage immediately
|
||||||
|
setDraggedPercentage(newPercentage);
|
||||||
|
|
||||||
|
// Use the base conversions (from when drag started) for scale calculation
|
||||||
|
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
|
||||||
|
|
||||||
|
// Calculate min/max values for the scale from BASE conversions
|
||||||
|
const values = baseConversions.map(c => Math.abs(c.value));
|
||||||
|
const maxValue = Math.max(...values);
|
||||||
|
const minValue = Math.min(...values.filter(v => v > 0));
|
||||||
|
|
||||||
|
// Calculate new value from percentage
|
||||||
|
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
|
||||||
|
|
||||||
|
onValueChange(newValue, draggingUnit, true); // true = currently dragging
|
||||||
|
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (draggingUnit && onValueChange) {
|
||||||
|
// Find the current value for the dragged unit
|
||||||
|
const conversion = conversions.find(c => c.unit === draggingUnit);
|
||||||
|
if (conversion) {
|
||||||
|
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDraggingUnit(null);
|
||||||
|
// Don't clear draggedPercentage yet - let it clear when conversions update
|
||||||
|
activeBarRef.current = null;
|
||||||
|
// baseConversionsRef cleared after conversions update
|
||||||
|
}, [draggingUnit, conversions, onValueChange]);
|
||||||
|
|
||||||
|
// Touch drag handlers
|
||||||
|
const handleTouchStart = useCallback((e: React.TouchEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
|
||||||
|
if (!onValueChange) return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setDraggingUnit(unit);
|
||||||
|
setDraggedPercentage(currentPercentage);
|
||||||
|
dragStartX.current = touch.clientX;
|
||||||
|
dragStartWidth.current = currentPercentage;
|
||||||
|
activeBarRef.current = barElement;
|
||||||
|
// Save the current conversions as reference
|
||||||
|
baseConversionsRef.current = [...conversions];
|
||||||
|
}, [onValueChange, conversions]);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((e: TouchEvent) => {
|
||||||
|
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
|
||||||
|
|
||||||
|
// Throttle updates to every 16ms (~60fps)
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastUpdateTime.current < 16) return;
|
||||||
|
lastUpdateTime.current = now;
|
||||||
|
|
||||||
|
e.preventDefault(); // Prevent scrolling while dragging
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const barWidth = activeBarRef.current.offsetWidth;
|
||||||
|
const deltaX = touch.clientX - dragStartX.current;
|
||||||
|
const deltaPercentage = (deltaX / barWidth) * 100;
|
||||||
|
|
||||||
|
let newPercentage = dragStartWidth.current + deltaPercentage;
|
||||||
|
newPercentage = Math.max(3, Math.min(100, newPercentage));
|
||||||
|
|
||||||
|
// Update visual percentage immediately
|
||||||
|
setDraggedPercentage(newPercentage);
|
||||||
|
|
||||||
|
// Use the base conversions (from when drag started) for scale calculation
|
||||||
|
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
|
||||||
|
|
||||||
|
const values = baseConversions.map(c => Math.abs(c.value));
|
||||||
|
const maxValue = Math.max(...values);
|
||||||
|
const minValue = Math.min(...values.filter(v => v > 0));
|
||||||
|
|
||||||
|
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
|
||||||
|
|
||||||
|
onValueChange(newValue, draggingUnit, true); // true = currently dragging
|
||||||
|
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
if (draggingUnit && onValueChange) {
|
||||||
|
// Find the current value for the dragged unit
|
||||||
|
const conversion = conversions.find(c => c.unit === draggingUnit);
|
||||||
|
if (conversion) {
|
||||||
|
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDraggingUnit(null);
|
||||||
|
// Don't clear draggedPercentage yet - let it clear when conversions update
|
||||||
|
activeBarRef.current = null;
|
||||||
|
// baseConversionsRef cleared after conversions update
|
||||||
|
}, [draggingUnit, conversions, onValueChange]);
|
||||||
|
|
||||||
|
// Add/remove global event listeners for drag
|
||||||
|
useEffect(() => {
|
||||||
|
if (draggingUnit) {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', handleTouchEnd);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [draggingUnit, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
||||||
|
|
||||||
|
// Clear drag state when conversions update after drag ends
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draggingUnit && draggedPercentage !== null) {
|
||||||
|
// Drag has ended, conversions have updated, now clear visual state
|
||||||
|
setDraggedPercentage(null);
|
||||||
|
baseConversionsRef.current = [];
|
||||||
|
}
|
||||||
|
}, [conversions, draggingUnit, draggedPercentage]);
|
||||||
|
|
||||||
|
if (conversions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Enter a value to see conversions
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{withPercentages.map(item => {
|
||||||
|
const isDragging = draggingUnit === item.unit;
|
||||||
|
const isDraggable = !!onValueChange;
|
||||||
|
// Use draggedPercentage if this bar is being dragged
|
||||||
|
const displayPercentage = isDragging && draggedPercentage !== null ? draggedPercentage : item.percentage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.unit} className="space-y-1.5">
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<span className="text-sm font-medium text-foreground min-w-0 flex-shrink">
|
||||||
|
{item.unitInfo.plural}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold tabular-nums flex-shrink-0">
|
||||||
|
{formatNumber(item.value)}
|
||||||
|
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||||||
|
{item.unit}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full h-8 bg-muted rounded-lg overflow-hidden border border-border relative",
|
||||||
|
"transition-all duration-200",
|
||||||
|
isDraggable && "cursor-grab active:cursor-grabbing",
|
||||||
|
isDragging && "ring-2 ring-ring ring-offset-2 ring-offset-background scale-105"
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (isDraggable && e.currentTarget instanceof HTMLDivElement) {
|
||||||
|
handleMouseDown(e, item.unit, item.percentage, e.currentTarget);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
if (isDraggable && e.currentTarget instanceof HTMLDivElement) {
|
||||||
|
handleTouchStart(e, item.unit, item.percentage, e.currentTarget);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Colored fill */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 left-0",
|
||||||
|
draggingUnit ? "transition-none" : "transition-all duration-500 ease-out"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${displayPercentage}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Percentage label overlay */}
|
||||||
|
<div className="absolute inset-0 flex items-center px-3 text-xs font-bold pointer-events-none">
|
||||||
|
<span className="text-foreground drop-shadow-sm">
|
||||||
|
{Math.round(displayPercentage)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drag hint on hover */}
|
||||||
|
{isDraggable && !isDragging && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-background/10 backdrop-blur-[1px]">
|
||||||
|
<span className="text-xs font-semibold text-foreground drop-shadow-md">
|
||||||
|
Drag to adjust
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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;
|
||||||
|
};
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
lib/figlet/constants/templates.ts
Normal file
38
lib/figlet/constants/templates.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export interface TextTemplate {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
text: string;
|
||||||
|
category: 'greeting' | 'tech' | 'fun' | 'seasonal';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TEXT_TEMPLATES: TextTemplate[] = [
|
||||||
|
// Greetings
|
||||||
|
{ id: 'hello', label: 'Hello', text: 'Hello!', category: 'greeting' },
|
||||||
|
{ id: 'welcome', label: 'Welcome', text: 'Welcome', category: 'greeting' },
|
||||||
|
{ id: 'hello-world', label: 'Hello World', text: 'Hello World', category: 'greeting' },
|
||||||
|
|
||||||
|
// Tech
|
||||||
|
{ id: 'code', label: 'Code', text: 'CODE', category: 'tech' },
|
||||||
|
{ id: 'dev', label: 'Developer', text: 'DEV', category: 'tech' },
|
||||||
|
{ id: 'hack', label: 'Hack', text: 'HACK', category: 'tech' },
|
||||||
|
{ id: 'terminal', label: 'Terminal', text: 'Terminal', category: 'tech' },
|
||||||
|
{ id: 'git', label: 'Git', text: 'Git', category: 'tech' },
|
||||||
|
|
||||||
|
// Fun
|
||||||
|
{ id: 'awesome', label: 'Awesome', text: 'AWESOME', category: 'fun' },
|
||||||
|
{ id: 'cool', label: 'Cool', text: 'COOL', category: 'fun' },
|
||||||
|
{ id: 'epic', label: 'Epic', text: 'EPIC', category: 'fun' },
|
||||||
|
{ id: 'wow', label: 'Wow', text: 'WOW!', category: 'fun' },
|
||||||
|
|
||||||
|
// Seasonal
|
||||||
|
{ id: 'happy-birthday', label: 'Happy Birthday', text: 'Happy Birthday!', category: 'seasonal' },
|
||||||
|
{ id: 'congrats', label: 'Congrats', text: 'Congrats!', category: 'seasonal' },
|
||||||
|
{ id: 'thanks', label: 'Thanks', text: 'Thanks!', category: 'seasonal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TEMPLATE_CATEGORIES = [
|
||||||
|
{ id: 'greeting', label: 'Greetings', icon: '👋' },
|
||||||
|
{ id: 'tech', label: 'Tech', icon: '💻' },
|
||||||
|
{ id: 'fun', label: 'Fun', icon: '🎉' },
|
||||||
|
{ id: 'seasonal', label: 'Seasonal', icon: '🎊' },
|
||||||
|
] as const;
|
||||||
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),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
6
lib/pastel/utils/cn.ts
Normal file
6
lib/pastel/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));
|
||||||
|
}
|
||||||
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));
|
||||||
|
}
|
||||||
53
lib/storage/history.ts
Normal file
53
lib/storage/history.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export interface HistoryItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
font: string;
|
||||||
|
result: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_KEY = 'figlet-ui-history';
|
||||||
|
const MAX_HISTORY = 10;
|
||||||
|
|
||||||
|
export function getHistory(): HistoryItem[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(HISTORY_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToHistory(text: string, font: string, result: string): void {
|
||||||
|
let history = getHistory();
|
||||||
|
|
||||||
|
const newItem: HistoryItem = {
|
||||||
|
id: `${Date.now()}-${Math.random()}`,
|
||||||
|
text,
|
||||||
|
font,
|
||||||
|
result,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to beginning
|
||||||
|
history.unshift(newItem);
|
||||||
|
|
||||||
|
// Keep only MAX_HISTORY items
|
||||||
|
history = history.slice(0, MAX_HISTORY);
|
||||||
|
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHistory(): void {
|
||||||
|
localStorage.removeItem(HISTORY_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeHistoryItem(id: string): void {
|
||||||
|
const history = getHistory();
|
||||||
|
const filtered = history.filter(item => item.id !== id);
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered));
|
||||||
|
}
|
||||||
4
lib/units/index.ts
Normal file
4
lib/units/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './units';
|
||||||
|
export * from './storage';
|
||||||
|
export * from './utils';
|
||||||
|
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;
|
||||||
|
}
|
||||||
106
lib/units/utils.ts
Normal file
106
lib/units/utils.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge Tailwind CSS classes with clsx
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number for display with proper precision
|
||||||
|
*/
|
||||||
|
export function formatNumber(
|
||||||
|
value: number,
|
||||||
|
options: {
|
||||||
|
maxDecimals?: number;
|
||||||
|
minDecimals?: number;
|
||||||
|
notation?: 'standard' | 'scientific' | 'engineering' | 'compact';
|
||||||
|
} = {}
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
maxDecimals = 6,
|
||||||
|
minDecimals = 0,
|
||||||
|
notation = 'standard',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Handle edge cases
|
||||||
|
if (!isFinite(value)) return value.toString();
|
||||||
|
if (value === 0) return '0';
|
||||||
|
|
||||||
|
// Use scientific notation for very large or very small numbers
|
||||||
|
const absValue = Math.abs(value);
|
||||||
|
const useScientific =
|
||||||
|
notation === 'scientific' ||
|
||||||
|
(notation === 'standard' && (absValue >= 1e10 || absValue < 1e-6));
|
||||||
|
|
||||||
|
if (useScientific) {
|
||||||
|
return value.toExponential(maxDecimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format with appropriate decimal places
|
||||||
|
const formatted = new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: minDecimals,
|
||||||
|
maximumFractionDigits: maxDecimals,
|
||||||
|
notation: notation === 'compact' ? 'compact' : 'standard',
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce function for input handling
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
return function executedFunction(...args: Parameters<T>) {
|
||||||
|
const later = () => {
|
||||||
|
timeout = null;
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a number input string
|
||||||
|
*/
|
||||||
|
export function parseNumberInput(input: string): number | null {
|
||||||
|
if (!input || input.trim() === '') return null;
|
||||||
|
|
||||||
|
// Remove spaces and replace comma with dot
|
||||||
|
const cleaned = input.replace(/\s/g, '').replace(',', '.');
|
||||||
|
|
||||||
|
const parsed = parseFloat(cleaned);
|
||||||
|
|
||||||
|
return isNaN(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relative time from timestamp
|
||||||
|
*/
|
||||||
|
export function getRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ago`;
|
||||||
|
if (hours > 0) return `${hours}h ago`;
|
||||||
|
if (minutes > 0) return `${minutes}m ago`;
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
38
lib/utils/animations.ts
Normal file
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
4
lib/utils/index.ts
Normal file
4
lib/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './cn';
|
||||||
|
export * from './debounce';
|
||||||
|
export * from './urlSharing';
|
||||||
|
export * from './animations';
|
||||||
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"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"framer-motion": "^11.11.17",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"next": "^16.0.1",
|
"@valknarthing/pastel-wasm": "^0.1.0",
|
||||||
"react": "^19.0.0",
|
"clsx": "^2.1.1",
|
||||||
"react-dom": "^19.0.0"
|
"cmdk": "^1.1.1",
|
||||||
|
"convert-units": "^2.3.4",
|
||||||
|
"figlet": "^1.10.0",
|
||||||
|
"framer-motion": "^12.34.3",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
|
"lucide-react": "^0.575.0",
|
||||||
|
"next": "^16.1.6",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.2.0",
|
||||||
"@types/node": "^20",
|
"@types/figlet": "^1.7.0",
|
||||||
"@types/react": "^19",
|
"@types/node": "^25.3.0",
|
||||||
"@types/react-dom": "^19",
|
"@types/react": "^19.2.14",
|
||||||
"eslint": "^9.39.1",
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslint-config-next": "^16.0.1",
|
"eslint": "^10.0.1",
|
||||||
"postcss": "^8",
|
"eslint-config-next": "^16.1.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"postcss": "^8.5.6",
|
||||||
"typescript": "^5"
|
"tailwindcss": "^4.2.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2135
pnpm-lock.yaml
generated
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user