feat: implement Figlet, Pastel, and Unit tools with a unified layout

- Add Figlet text converter with font selection and history
- Add Pastel color palette generator and manipulation suite
- Add comprehensive Units converter with category-based logic
- Introduce AppShell with Sidebar and Header for navigation
- Modernize theme system with CSS variables and new animations
- Update project configuration and dependencies
This commit is contained in:
2026-02-22 21:35:53 +01:00
parent ff6bb873eb
commit 2000623c67
540 changed files with 338653 additions and 809 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
'use client';
import { cn } from '@/lib/utils/cn';
interface ColorDisplayProps {
color: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
showBorder?: boolean;
}
export function ColorDisplay({
color,
size = 'lg',
className,
showBorder = true,
}: ColorDisplayProps) {
const sizeClasses = {
sm: 'h-16 w-16',
md: 'h-32 w-32',
lg: 'h-48 w-48',
xl: 'h-64 w-64',
};
return (
<div
className={cn(
'rounded-lg transition-all',
showBorder && 'ring-2 ring-border',
sizeClasses[size],
className
)}
style={{ backgroundColor: color }}
role="img"
aria-label={`Color swatch: ${color}`}
/>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import { ColorInfo as ColorInfoType } from '@/lib/pastel/api/types';
import { Button } from '@/components/ui/Button';
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn';
interface ColorInfoProps {
info: ColorInfoType;
className?: string;
}
export function ColorInfo({ info, className }: ColorInfoProps) {
const copyToClipboard = (value: string, label: string) => {
navigator.clipboard.writeText(value);
toast.success(`Copied ${label} to clipboard`);
};
const formatRgb = (rgb: { r: number; g: number; b: number; a?: number }) => {
if (rgb.a !== undefined && rgb.a < 1) {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})`;
}
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
};
const formatHsl = (hsl: { h: number; s: number; l: number; a?: number }) => {
if (hsl.a !== undefined && hsl.a < 1) {
return `hsla(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%, ${hsl.a})`;
}
return `hsl(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%)`;
};
const formatLab = (lab: { l: number; a: number; b: number }) => {
return `lab(${lab.l.toFixed(1)}, ${lab.a.toFixed(1)}, ${lab.b.toFixed(1)})`;
};
const formats = [
{ label: 'Hex', value: info.hex },
{ label: 'RGB', value: formatRgb(info.rgb) },
{ label: 'HSL', value: formatHsl(info.hsl) },
{ label: 'Lab', value: formatLab(info.lab) },
{ label: 'OkLab', value: formatLab(info.oklab) },
];
return (
<div className={cn('space-y-4', className)}>
<div className="grid grid-cols-1 gap-3">
{formats.map((format) => (
<div
key={format.label}
className="flex items-center justify-between p-3 bg-muted rounded-lg"
>
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">{format.label}</div>
<div className="font-mono text-sm">{format.value}</div>
</div>
<Button
size="icon"
variant="ghost"
onClick={() => copyToClipboard(format.value, format.label)}
aria-label={`Copy ${format.label} value`}
>
<Copy className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Brightness</div>
<div className="text-sm font-medium">{(info.brightness * 100).toFixed(1)}%</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Luminance</div>
<div className="text-sm font-medium">{(info.luminance * 100).toFixed(1)}%</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Type</div>
<div className="text-sm font-medium">{info.is_light ? 'Light' : 'Dark'}</div>
</div>
{info.name && typeof info.name === 'string' && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Named</div>
<div className="text-sm font-medium">{info.name}</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { HexColorPicker } from 'react-colorful';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/utils/cn';
interface ColorPickerProps {
color: string;
onChange: (color: string) => void;
className?: string;
}
export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Allow partial input while typing
onChange(value);
};
return (
<div className={cn('space-y-4', className)}>
<HexColorPicker color={color} onChange={onChange} className="w-full" />
<div className="space-y-2">
<label htmlFor="color-input" className="text-sm font-medium">
Color Value
</label>
<Input
id="color-input"
type="text"
value={color}
onChange={handleInputChange}
placeholder="#ff0099 or rgb(255, 0, 153)"
className="font-mono"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { cn } from '@/lib/utils/cn';
import { Check, Copy } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
interface ColorSwatchProps {
color: string;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
onClick?: () => void;
className?: string;
}
export function ColorSwatch({
color,
size = 'md',
showLabel = true,
onClick,
className,
}: ColorSwatchProps) {
const [copied, setCopied] = useState(false);
const sizeClasses = {
sm: 'h-12 w-12',
md: 'h-16 w-16',
lg: 'h-24 w-24',
};
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(color);
setCopied(true);
toast.success(`Copied ${color}`);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className={cn('flex flex-col items-center gap-2', className)}>
<button
className={cn(
'relative rounded-lg ring-2 ring-border transition-all duration-200',
'hover:scale-110 hover:ring-primary hover:shadow-lg',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'group active:scale-95',
sizeClasses[size]
)}
style={{ backgroundColor: color }}
onClick={onClick || handleCopy}
aria-label={`Color ${color}`}
>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 bg-black/30 rounded-lg backdrop-blur-sm">
{copied ? (
<Check className="h-5 w-5 text-white animate-scale-in" />
) : (
<Copy className="h-5 w-5 text-white" />
)}
</div>
</button>
{showLabel && (
<span className="text-xs font-mono text-muted-foreground">{color}</span>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { ColorSwatch } from './ColorSwatch';
import { cn } from '@/lib/utils/cn';
interface PaletteGridProps {
colors: string[];
onColorClick?: (color: string) => void;
className?: string;
}
export function PaletteGrid({ colors, onColorClick, className }: PaletteGridProps) {
if (colors.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
No colors in palette yet
</div>
);
}
return (
<div
className={cn(
'grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4',
className
)}
>
{colors.map((color, index) => (
<ColorSwatch
key={`${color}-${index}`}
color={color}
onClick={onColorClick ? () => onColorClick(color) : undefined}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,203 @@
'use client';
import Link from 'next/link';
import { Github, Heart, ExternalLink } from 'lucide-react';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="border-t bg-card/50 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* About */}
<div className="space-y-3">
<h3 className="font-semibold text-lg">Pastel UI</h3>
<p className="text-sm text-muted-foreground">
A modern, interactive web application for color manipulation, palette generation,
and accessibility analysis
</p>
</div>
{/* Resources */}
<div className="space-y-3">
<h3 className="font-semibold">Resources</h3>
<ul className="space-y-2 text-sm">
<li>
<Link href="/pastel/playground" className="text-muted-foreground hover:text-foreground transition-colors">
Color Playground
</Link>
</li>
<li>
<Link href="/pastel/palettes" className="text-muted-foreground hover:text-foreground transition-colors">
Palette Generator
</Link>
</li>
<li>
<Link href="/pastel/accessibility" className="text-muted-foreground hover:text-foreground transition-colors">
Accessibility Tools
</Link>
</li>
<li>
<Link href="/pastel/batch" className="text-muted-foreground hover:text-foreground transition-colors">
Batch Operations
</Link>
</li>
</ul>
</div>
{/* Documentation */}
<div className="space-y-3">
<h3 className="font-semibold">Documentation</h3>
<ul className="space-y-2 text-sm">
<li>
<a
href="https://github.com/valknarness/pastel-ui#readme"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
Getting Started
<ExternalLink className="h-3 w-3" />
</a>
</li>
<li>
<a
href="https://github.com/valknarness/pastel-api#readme"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
API Documentation
<ExternalLink className="h-3 w-3" />
</a>
</li>
<li>
<a
href="https://github.com/sharkdp/pastel"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
Pastel CLI
<ExternalLink className="h-3 w-3" />
</a>
</li>
</ul>
</div>
{/* Community */}
<div className="space-y-3">
<h3 className="font-semibold">Community</h3>
<ul className="space-y-2 text-sm">
<li>
<a
href="https://github.com/valknarness/pastel-ui"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
>
<Github className="h-4 w-4" />
Pastel UI
</a>
</li>
<li>
<a
href="https://github.com/valknarness/pastel-api"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
>
<Github className="h-4 w-4" />
Pastel API
</a>
</li>
<li>
<a
href="https://github.com/valknarness/pastel-ui/issues"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Report an Issue
</a>
</li>
<li>
<a
href="https://github.com/valknarness/pastel-ui/discussions"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Discussions
</a>
</li>
</ul>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-12 pt-8 border-t">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="text-sm text-muted-foreground">
<p className="inline-flex items-center gap-1">
© {currentYear} Pastel UI. Built with
<Heart className="h-4 w-4 text-red-500 fill-red-500" />
using
<a
href="https://nextjs.org"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors underline"
>
Next.js
</a>
and
<a
href="https://tailwindcss.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors underline"
>
Tailwind CSS
</a>
</p>
</div>
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<p>
Based on{' '}
<a
href="https://github.com/sharkdp/pastel"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors underline"
>
Pastel
</a>
{' '}by{' '}
<a
href="https://github.com/sharkdp"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors underline"
>
David Peter
</a>
</p>
<span></span>
<a
href="https://github.com/valknarness/pastel-ui/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
MIT License
</a>
</div>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ThemeToggle } from './ThemeToggle';
import { cn } from '@/lib/utils/cn';
import { Palette } from 'lucide-react';
const navigation = [
{ name: 'Home', href: '/pastel' },
{ name: 'Playground', href: '/pastel/playground' },
{ name: 'Palettes', href: '/pastel/palettes' },
{ name: 'Accessibility', href: '/pastel/accessibility' },
{ name: 'Named Colors', href: '/pastel/names' },
{ name: 'Batch', href: '/pastel/batch' },
];
export function Navbar() {
const pathname = usePathname();
return (
<nav className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="max-w-7xl mx-auto px-8">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2 font-bold text-xl">
<Palette className="h-6 w-6 text-primary" />
<span className="bg-gradient-to-r from-pink-500 via-purple-500 to-blue-500 bg-clip-text text-transparent">
Pastel UI
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-1">
{navigation.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
pathname === item.href
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
{item.name}
</Link>
))}
</div>
{/* Right side */}
<div className="flex items-center space-x-2">
<ThemeToggle />
</div>
</div>
{/* Mobile Navigation */}
<div className="md:hidden pb-3 space-y-1">
{navigation.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'block px-3 py-2 rounded-md text-sm font-medium transition-colors',
pathname === item.href
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
{item.name}
</Link>
))}
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '@/components/providers/ThemeProvider';
import { Button } from '@/components/ui/Button';
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const toggleTheme = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
>
{resolvedTheme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
);
}

View File

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

View File

@@ -0,0 +1,78 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
// Load theme from localStorage
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) {
setTheme(stored);
}
}, []);
useEffect(() => {
const root = window.document.documentElement;
// Remove previous theme classes
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
setResolvedTheme(systemTheme);
} else {
root.classList.add(theme);
setResolvedTheme(theme);
}
// Save to localStorage
localStorage.setItem('theme', theme);
}, [theme]);
// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
setResolvedTheme(systemTheme);
window.document.documentElement.classList.remove('light', 'dark');
window.document.documentElement.classList.add(systemTheme);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@@ -0,0 +1,125 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { useState } from 'react';
import { Download, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import {
exportAsCSS,
exportAsSCSS,
exportAsTailwind,
exportAsJSON,
exportAsJavaScript,
downloadAsFile,
type ExportColor,
} from '@/lib/pastel/utils/export';
interface ExportMenuProps {
colors: string[];
className?: string;
}
type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript';
export function ExportMenu({ colors, className }: ExportMenuProps) {
const [format, setFormat] = useState<ExportFormat>('css');
const [copied, setCopied] = useState(false);
const exportColors: ExportColor[] = colors.map((hex) => ({ hex }));
const getExportContent = (): string => {
switch (format) {
case 'css':
return exportAsCSS(exportColors);
case 'scss':
return exportAsSCSS(exportColors);
case 'tailwind':
return exportAsTailwind(exportColors);
case 'json':
return exportAsJSON(exportColors);
case 'javascript':
return exportAsJavaScript(exportColors);
}
};
const getFileExtension = (): string => {
switch (format) {
case 'css':
return 'css';
case 'scss':
return 'scss';
case 'tailwind':
return 'js';
case 'json':
return 'json';
case 'javascript':
return 'js';
}
};
const handleCopy = () => {
const content = getExportContent();
navigator.clipboard.writeText(content);
setCopied(true);
toast.success('Copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
};
const handleDownload = () => {
const content = getExportContent();
const extension = getFileExtension();
downloadAsFile(content, `palette.${extension}`, 'text/plain');
toast.success('Downloaded!');
};
if (colors.length === 0) {
return null;
}
return (
<div className={className}>
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium mb-2">Export Palette</h3>
<Select
value={format}
onChange={(e) => setFormat(e.target.value as ExportFormat)}
>
<option value="css">CSS Variables</option>
<option value="scss">SCSS Variables</option>
<option value="tailwind">Tailwind Config</option>
<option value="json">JSON</option>
<option value="javascript">JavaScript Array</option>
</Select>
</div>
<div className="p-4 bg-muted rounded-lg">
<pre className="text-xs overflow-x-auto">
<code>{getExportContent()}</code>
</pre>
</div>
<div className="flex gap-2">
<Button onClick={handleCopy} variant="outline" className="flex-1">
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy
</>
)}
</Button>
<Button onClick={handleDownload} variant="default" className="flex-1">
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
'use client';
import { useState } from 'react';
import { Slider } from '@/components/ui/Slider';
import { Button } from '@/components/ui/Button';
import {
useLighten,
useDarken,
useSaturate,
useDesaturate,
useRotate,
useComplement
} from '@/lib/pastel/api/queries';
import { toast } from 'sonner';
interface ManipulationPanelProps {
color: string;
onColorChange: (color: string) => void;
}
export function ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) {
const [lightenAmount, setLightenAmount] = useState(0.2);
const [darkenAmount, setDarkenAmount] = useState(0.2);
const [saturateAmount, setSaturateAmount] = useState(0.2);
const [desaturateAmount, setDesaturateAmount] = useState(0.2);
const [rotateAmount, setRotateAmount] = useState(30);
const lightenMutation = useLighten();
const darkenMutation = useDarken();
const saturateMutation = useSaturate();
const desaturateMutation = useDesaturate();
const rotateMutation = useRotate();
const complementMutation = useComplement();
const handleLighten = async () => {
try {
const result = await lightenMutation.mutateAsync({
colors: [color],
amount: lightenAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(`Lightened by ${(lightenAmount * 100).toFixed(0)}%`);
}
} catch (error) {
toast.error('Failed to lighten color');
}
};
const handleDarken = async () => {
try {
const result = await darkenMutation.mutateAsync({
colors: [color],
amount: darkenAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(`Darkened by ${(darkenAmount * 100).toFixed(0)}%`);
}
} catch (error) {
toast.error('Failed to darken color');
}
};
const handleSaturate = async () => {
try {
const result = await saturateMutation.mutateAsync({
colors: [color],
amount: saturateAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(`Saturated by ${(saturateAmount * 100).toFixed(0)}%`);
}
} catch (error) {
toast.error('Failed to saturate color');
}
};
const handleDesaturate = async () => {
try {
const result = await desaturateMutation.mutateAsync({
colors: [color],
amount: desaturateAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(`Desaturated by ${(desaturateAmount * 100).toFixed(0)}%`);
}
} catch (error) {
toast.error('Failed to desaturate color');
}
};
const handleRotate = async () => {
try {
const result = await rotateMutation.mutateAsync({
colors: [color],
amount: rotateAmount,
});
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(`Rotated hue by ${rotateAmount}°`);
}
} catch (error) {
toast.error('Failed to rotate hue');
}
};
const handleComplement = async () => {
try {
const result = await complementMutation.mutateAsync([color]);
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success('Generated complementary color');
}
} catch (error) {
toast.error('Failed to generate complement');
}
};
const isLoading =
lightenMutation.isPending ||
darkenMutation.isPending ||
saturateMutation.isPending ||
desaturateMutation.isPending ||
rotateMutation.isPending ||
complementMutation.isPending;
return (
<div className="space-y-6">
{/* Lighten */}
<div className="space-y-3">
<Slider
label="Lighten"
min={0}
max={1}
step={0.05}
value={lightenAmount}
onChange={(e) => setLightenAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
<Button onClick={handleLighten} disabled={isLoading} className="w-full">
Apply Lighten
</Button>
</div>
{/* Darken */}
<div className="space-y-3">
<Slider
label="Darken"
min={0}
max={1}
step={0.05}
value={darkenAmount}
onChange={(e) => setDarkenAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
<Button onClick={handleDarken} disabled={isLoading} className="w-full">
Apply Darken
</Button>
</div>
{/* Saturate */}
<div className="space-y-3">
<Slider
label="Saturate"
min={0}
max={1}
step={0.05}
value={saturateAmount}
onChange={(e) => setSaturateAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
<Button onClick={handleSaturate} disabled={isLoading} className="w-full">
Apply Saturate
</Button>
</div>
{/* Desaturate */}
<div className="space-y-3">
<Slider
label="Desaturate"
min={0}
max={1}
step={0.05}
value={desaturateAmount}
onChange={(e) => setDesaturateAmount(parseFloat(e.target.value))}
suffix="%"
showValue
/>
<Button onClick={handleDesaturate} disabled={isLoading} className="w-full">
Apply Desaturate
</Button>
</div>
{/* Rotate Hue */}
<div className="space-y-3">
<Slider
label="Rotate Hue"
min={-180}
max={180}
step={5}
value={rotateAmount}
onChange={(e) => setRotateAmount(parseInt(e.target.value))}
suffix="°"
showValue
/>
<Button onClick={handleRotate} disabled={isLoading} className="w-full">
Apply Rotation
</Button>
</div>
{/* Quick Actions */}
<div className="pt-4 border-t space-y-2">
<h3 className="text-sm font-medium mb-3">Quick Actions</h3>
<Button
onClick={handleComplement}
disabled={isLoading}
variant="outline"
className="w-full"
>
Get Complementary Color
</Button>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,78 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('dark');
useEffect(() => {
// Load theme from localStorage
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) {
setTheme(stored);
}
}, []);
useEffect(() => {
const root = window.document.documentElement;
// Remove previous theme classes
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
setResolvedTheme(systemTheme);
} else {
root.classList.add(theme);
setResolvedTheme(theme);
}
// Save to localStorage
localStorage.setItem('theme', theme);
}, [theme]);
// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
setResolvedTheme(systemTheme);
window.document.documentElement.classList.remove('light', 'dark');
window.document.documentElement.classList.add(systemTheme);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

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

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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