diff --git a/app/globals.css b/app/globals.css index 06e7907..ef2c0e0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -116,6 +116,42 @@ .slide-in-from-right-full { animation: slideInFromRight 0.3s ease-out; } + + .slide-down { + animation: slideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .slide-up { + animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .scale-in { + animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1); + } + + .pulse-subtle { + animation: pulseSubtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + + .shimmer { + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.1), + transparent + ); + background-size: 200% 100%; + animation: shimmer 2s infinite; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes slideInFromRight { @@ -128,3 +164,54 @@ opacity: 1; } } + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes pulseSubtle { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} diff --git a/components/converter/ComparisonMode.tsx b/components/converter/ComparisonMode.tsx new file mode 100644 index 0000000..ced89c9 --- /dev/null +++ b/components/converter/ComparisonMode.tsx @@ -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; + 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 ( +
+
+

Font Comparison

+ + {selectedFonts.length} font{selectedFonts.length !== 1 ? 's' : ''} selected + +
+ + {selectedFonts.length === 0 ? ( + + + + ) : ( +
+ {selectedFonts.map((fontName, index) => ( + +
+ {/* Font Header */} +
+
+ + {fontName} + +
+
+ + + +
+
+ + {/* ASCII Art Preview */} +
+
+                    
+                      {fontResults[fontName] || 'Loading...'}
+                    
+                  
+
+ + {/* Stats */} + {fontResults[fontName] && ( +
+ + {fontResults[fontName].split('\n').length} lines + + + {Math.max( + ...fontResults[fontName].split('\n').map((line) => line.length) + )} chars wide + +
+ )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/components/converter/FigletConverter.tsx b/components/converter/FigletConverter.tsx index a272576..6647b53 100644 --- a/components/converter/FigletConverter.tsx +++ b/components/converter/FigletConverter.tsx @@ -4,12 +4,20 @@ 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() { @@ -18,6 +26,9 @@ export function FigletConverter() { const [asciiArt, setAsciiArt] = React.useState(''); const [fonts, setFonts] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); + const [isComparisonMode, setIsComparisonMode] = React.useState(false); + const [comparisonFonts, setComparisonFonts] = React.useState([]); + const [comparisonResults, setComparisonResults] = React.useState>({}); const { addToast } = useToast(); // Load fonts and check URL params on mount @@ -72,6 +83,7 @@ export function FigletConverter() { try { await navigator.clipboard.writeText(asciiArt); + addToHistory(text, selectedFont, asciiArt); addToast('Copied to clipboard!', 'success'); } catch (error) { console.error('Failed to copy:', error); @@ -115,24 +127,148 @@ export function FigletConverter() { 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 = {}; + 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 (
{/* Left Column - Input and Preview */}
+ {/* Comparison Mode Toggle */} + +
+
+ + Comparison Mode + {isComparisonMode && comparisonFonts.length > 0 && ( + + {comparisonFonts.length} {comparisonFonts.length === 1 ? 'font' : 'fonts'} + + )} +
+ +
+
+ + + + + - + {isComparisonMode ? ( + + ) : ( + + )}
{/* Right Column - Font Selector */} @@ -142,6 +278,9 @@ export function FigletConverter() { selectedFont={selectedFont} onSelectFont={setSelectedFont} onRandomFont={handleRandomFont} + isComparisonMode={isComparisonMode} + comparisonFonts={comparisonFonts} + onAddToComparison={handleAddToComparison} />
diff --git a/components/converter/FontPreview.tsx b/components/converter/FontPreview.tsx index dee80bf..a4b6ad0 100644 --- a/components/converter/FontPreview.tsx +++ b/components/converter/FontPreview.tsx @@ -4,7 +4,9 @@ import * as React from 'react'; import { toPng } from 'html-to-image'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; -import { Copy, Download, Share2, Image as ImageIcon, AlignLeft, AlignCenter, AlignRight } from 'lucide-react'; +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'; @@ -171,12 +173,17 @@ export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare )} > {isLoading ? ( -
-
Generating...
+
+ + + + + +
) : text ? (
           ) : (
-            
-
Your ASCII art will appear here
-
+ )}
diff --git a/components/converter/FontSelector.tsx b/components/converter/FontSelector.tsx index ff0c723..859fdf9 100644 --- a/components/converter/FontSelector.tsx +++ b/components/converter/FontSelector.tsx @@ -4,7 +4,8 @@ import * as React from 'react'; import Fuse from 'fuse.js'; import { Input } from '@/components/ui/Input'; import { Card } from '@/components/ui/Card'; -import { Search, X, Heart, Clock, List, Shuffle } from 'lucide-react'; +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'; @@ -15,12 +16,24 @@ export interface FontSelectorProps { 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, className }: FontSelectorProps) { +export function FontSelector({ + fonts, + selectedFont, + onSelectFont, + onRandomFont, + isComparisonMode = false, + comparisonFonts = [], + onAddToComparison, + className +}: FontSelectorProps) { const [searchQuery, setSearchQuery] = React.useState(''); const [filter, setFilter] = React.useState('all'); const [favorites, setFavorites] = React.useState([]); @@ -175,44 +188,82 @@ export function FontSelector({ fonts, selectedFont, onSelectFont, onRandomFont, {/* Font List */}
{filteredFonts.length === 0 ? ( -
- {filter === 'favorites' && 'No favorite fonts yet'} - {filter === 'recent' && 'No recent fonts'} - {filter === 'all' && 'No fonts found'} -
+ ) : ( - filteredFonts.map((font) => ( -
- - + {isComparisonMode && onAddToComparison && ( + + )} + -
- )) + aria-label={isFavorite(font.name) ? 'Remove from favorites' : 'Add to favorites'} + > + + +
+ ); + }) )} diff --git a/components/converter/HistoryPanel.tsx b/components/converter/HistoryPanel.tsx new file mode 100644 index 0000000..b9ad1da --- /dev/null +++ b/components/converter/HistoryPanel.tsx @@ -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([]); + + 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 ( + +
+ + + {isExpanded && ( +
+ {history.length === 0 ? ( + + ) : ( + <> +
+ +
+ +
+ {history.map((item) => ( +
onSelectHistory(item)} + className="group relative p-3 bg-muted/50 hover:bg-accent hover:scale-[1.02] rounded-md cursor-pointer transition-all" + > +
+
+
+ + {item.font} + + + {formatTime(item.timestamp)} + +
+

{item.text}

+
+ +
+
+ ))} +
+ + )} +
+ )} +
+
+ ); +} diff --git a/components/converter/TextTemplates.tsx b/components/converter/TextTemplates.tsx new file mode 100644 index 0000000..632c583 --- /dev/null +++ b/components/converter/TextTemplates.tsx @@ -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/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('all'); + + const filteredTemplates = React.useMemo(() => { + if (selectedCategory === 'all') return TEXT_TEMPLATES; + return TEXT_TEMPLATES.filter(t => t.category === selectedCategory); + }, [selectedCategory]); + + return ( + +
+ + + {isExpanded && ( +
+ {/* Category Filter */} +
+ + {TEMPLATE_CATEGORIES.map((cat) => ( + + ))} +
+ + {/* Templates Grid */} +
+ {filteredTemplates.map((template) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/components/ui/EmptyState.tsx b/components/ui/EmptyState.tsx new file mode 100644 index 0000000..f8e4bfc --- /dev/null +++ b/components/ui/EmptyState.tsx @@ -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 ( +
+ {Icon && ( +
+ +
+ )} +

{title}

+ {description && ( +

+ {description} +

+ )} + {action} +
+ ); +} diff --git a/components/ui/Skeleton.tsx b/components/ui/Skeleton.tsx new file mode 100644 index 0000000..4ebebd8 --- /dev/null +++ b/components/ui/Skeleton.tsx @@ -0,0 +1,22 @@ +import { cn } from '@/lib/utils/cn'; + +export interface SkeletonProps extends React.HTMLAttributes {} + +export function Skeleton({ className, ...props }: SkeletonProps) { + return ( +
+ ); +} + +export function SkeletonText({ className, ...props }: SkeletonProps) { + return ( +
+ + + +
+ ); +} diff --git a/lib/constants/templates.ts b/lib/constants/templates.ts new file mode 100644 index 0000000..a44bd31 --- /dev/null +++ b/lib/constants/templates.ts @@ -0,0 +1,38 @@ +export interface TextTemplate { + id: string; + label: string; + text: string; + category: 'greeting' | 'tech' | 'fun' | 'seasonal'; +} + +export const TEXT_TEMPLATES: TextTemplate[] = [ + // Greetings + { id: 'hello', label: 'Hello', text: 'Hello!', category: 'greeting' }, + { id: 'welcome', label: 'Welcome', text: 'Welcome', category: 'greeting' }, + { id: 'hello-world', label: 'Hello World', text: 'Hello World', category: 'greeting' }, + + // Tech + { id: 'code', label: 'Code', text: 'CODE', category: 'tech' }, + { id: 'dev', label: 'Developer', text: 'DEV', category: 'tech' }, + { id: 'hack', label: 'Hack', text: 'HACK', category: 'tech' }, + { id: 'terminal', label: 'Terminal', text: 'Terminal', category: 'tech' }, + { id: 'git', label: 'Git', text: 'Git', category: 'tech' }, + + // Fun + { id: 'awesome', label: 'Awesome', text: 'AWESOME', category: 'fun' }, + { id: 'cool', label: 'Cool', text: 'COOL', category: 'fun' }, + { id: 'epic', label: 'Epic', text: 'EPIC', category: 'fun' }, + { id: 'wow', label: 'Wow', text: 'WOW!', category: 'fun' }, + + // Seasonal + { id: 'happy-birthday', label: 'Happy Birthday', text: 'Happy Birthday!', category: 'seasonal' }, + { id: 'congrats', label: 'Congrats', text: 'Congrats!', category: 'seasonal' }, + { id: 'thanks', label: 'Thanks', text: 'Thanks!', category: 'seasonal' }, +]; + +export const TEMPLATE_CATEGORIES = [ + { id: 'greeting', label: 'Greetings', icon: '👋' }, + { id: 'tech', label: 'Tech', icon: '💻' }, + { id: 'fun', label: 'Fun', icon: '🎉' }, + { id: 'seasonal', label: 'Seasonal', icon: '🎊' }, +] as const; diff --git a/lib/storage/history.ts b/lib/storage/history.ts new file mode 100644 index 0000000..2761ec1 --- /dev/null +++ b/lib/storage/history.ts @@ -0,0 +1,53 @@ +'use client'; + +export interface HistoryItem { + id: string; + text: string; + font: string; + result: string; + timestamp: number; +} + +const HISTORY_KEY = 'figlet-ui-history'; +const MAX_HISTORY = 10; + +export function getHistory(): HistoryItem[] { + if (typeof window === 'undefined') return []; + + try { + const stored = localStorage.getItem(HISTORY_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +export function addToHistory(text: string, font: string, result: string): void { + let history = getHistory(); + + const newItem: HistoryItem = { + id: `${Date.now()}-${Math.random()}`, + text, + font, + result, + timestamp: Date.now(), + }; + + // Add to beginning + history.unshift(newItem); + + // Keep only MAX_HISTORY items + history = history.slice(0, MAX_HISTORY); + + localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); +} + +export function clearHistory(): void { + localStorage.removeItem(HISTORY_KEY); +} + +export function removeHistoryItem(id: string): void { + const history = getHistory(); + const filtered = history.filter(item => item.id !== id); + localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered)); +} diff --git a/lib/utils/animations.ts b/lib/utils/animations.ts new file mode 100644 index 0000000..071496c --- /dev/null +++ b/lib/utils/animations.ts @@ -0,0 +1,38 @@ +// Animation utility classes and keyframes + +export const fadeIn = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, +}; + +export const slideUp = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, +}; + +export const slideDown = { + initial: { opacity: 0, y: -20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 20 }, +}; + +export const scaleIn = { + initial: { opacity: 0, scale: 0.95 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.95 }, +}; + +export const staggerChildren = { + animate: { + transition: { + staggerChildren: 0.05, + }, + }, +}; + +export const staggerItem = { + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0 }, +};