feat: add toast notifications, random font, and font info display

Major UX enhancements for better user feedback and discovery:

**Toast Notification System**
- Beautiful toast notifications with 3 types (success/error/info)
- Auto-dismiss after 3 seconds
- Slide-in animation from right
- Color-coded by type (green/red/blue)
- Dark mode support
- Close button for manual dismiss
- ToastProvider context for global access
- Non-blocking UI overlay

**Random Font Discovery**
- Shuffle button in font selector
- One-click random font selection
- Toast notification shows selected font
- Perfect for discovering new fonts
- Located next to "Select Font" header

**Enhanced Font Preview**
- Font name badge display
- Character count statistics
- Line count statistics
- Better visual hierarchy
- Responsive stat display

**Improved Feedback**
- Toast on copy: "Copied to clipboard!"
- Toast on share: "Shareable URL copied!"
- Toast on random: "Random font: FontName"
- Error toasts for failed operations
- Removed temporary text replacement

**Smooth Animations**
- Slide-in animation for toasts
- Fade-in animation class
- Custom keyframe animations
- CSS utility classes
- Smooth transitions throughout

**Technical Improvements**
- useToast custom hook
- Context-based state management
- Auto-cleanup with setTimeout
- Unique toast IDs
- TypeScript types for toast system
- Proper event propagation

**Better UX**
- No more jarring text replacements
- Non-intrusive notifications
- Professional feedback system
- Discoverable random feature
- Informative preview stats

The app now feels polished and professional with proper user feedback!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 13:21:13 +01:00
parent a83bc5cb7a
commit 704695f14f
6 changed files with 174 additions and 17 deletions

View File

@@ -107,3 +107,24 @@
@apply bg-muted-foreground/20 rounded-lg hover:bg-muted-foreground/30;
}
}
@layer utilities {
.animate-in {
animation: fadeIn 0.2s ease-in;
}
.slide-in-from-right-full {
animation: slideInFromRight 0.3s ease-out;
}
}
@keyframes slideInFromRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

View File

@@ -1,14 +1,15 @@
import type { Metadata } from 'next';
import './globals.css';
import { ToastProvider } from '@/components/ui/Toast';
export const metadata: Metadata = {
title: 'Figlet UI - ASCII Art Text Generator',
description: 'A modern web UI for generating ASCII art text with 700+ figlet fonts. Preview custom text in any figlet font, export to multiple formats, and share your creations.',
description: 'A modern web UI for generating ASCII art text with 373 figlet fonts. Preview custom text in any figlet font, export to multiple formats, and share your creations.',
keywords: ['figlet', 'ascii art', 'text generator', 'banner', 'ascii', 'text art'],
authors: [{ name: 'Valknar' }],
openGraph: {
title: 'Figlet UI - ASCII Art Text Generator',
description: 'Generate beautiful ASCII art text with 700+ fonts',
description: 'Generate beautiful ASCII art text with 373 fonts',
type: 'website',
},
};
@@ -21,7 +22,9 @@ export default function RootLayout({
return (
<html lang="en">
<body className="antialiased">
<ToastProvider>
{children}
</ToastProvider>
</body>
</html>
);

View File

@@ -9,6 +9,7 @@ import { getFontList } from '@/lib/figlet/fontLoader';
import { debounce } from '@/lib/utils/debounce';
import { addRecentFont } from '@/lib/storage/favorites';
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
import { useToast } from '@/components/ui/Toast';
import type { FigletFont } from '@/types/figlet';
export function FigletConverter() {
@@ -17,8 +18,7 @@ export function FigletConverter() {
const [asciiArt, setAsciiArt] = React.useState('');
const [fonts, setFonts] = React.useState<FigletFont[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isCopied, setIsCopied] = React.useState(false);
const [isShared, setIsShared] = React.useState(false);
const { addToast } = useToast();
// Load fonts and check URL params on mount
React.useEffect(() => {
@@ -72,10 +72,10 @@ export function FigletConverter() {
try {
await navigator.clipboard.writeText(asciiArt);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
addToast('Copied to clipboard!', 'success');
} catch (error) {
console.error('Failed to copy:', error);
addToast('Failed to copy', 'error');
}
};
@@ -100,13 +100,21 @@ export function FigletConverter() {
try {
await navigator.clipboard.writeText(shareUrl);
setIsShared(true);
setTimeout(() => setIsShared(false), 2000);
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');
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Input and Preview */}
@@ -118,7 +126,8 @@ export function FigletConverter() {
/>
<FontPreview
text={isCopied ? 'Copied to clipboard! ✓' : isShared ? 'URL copied to clipboard! ✓' : asciiArt}
text={asciiArt}
font={selectedFont}
isLoading={isLoading}
onCopy={handleCopy}
onDownload={handleDownload}
@@ -132,6 +141,7 @@ export function FigletConverter() {
fonts={fonts}
selectedFont={selectedFont}
onSelectFont={setSelectedFont}
onRandomFont={handleRandomFont}
/>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils/cn';
export interface FontPreviewProps {
text: string;
font?: string;
isLoading?: boolean;
onCopy?: () => void;
onDownload?: () => void;
@@ -15,13 +16,22 @@ export interface FontPreviewProps {
className?: string;
}
export function FontPreview({ text, isLoading, onCopy, onDownload, onShare, className }: FontPreviewProps) {
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;
return (
<Card className={cn('relative', className)}>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">Preview</h3>
<div className="flex gap-2">
{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" />
@@ -43,6 +53,14 @@ export function FontPreview({ text, isLoading, onCopy, onDownload, onShare, clas
</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 className="relative min-h-[200px] bg-muted/50 rounded-lg p-4 overflow-x-auto">
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center">

View File

@@ -4,8 +4,9 @@ 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 } from 'lucide-react';
import { Search, X, Heart, Clock, List, Shuffle } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/Button';
import type { FigletFont } from '@/types/figlet';
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
@@ -13,12 +14,13 @@ export interface FontSelectorProps {
fonts: FigletFont[];
selectedFont: string;
onSelectFont: (fontName: string) => void;
onRandomFont?: () => void;
className?: string;
}
type FilterType = 'all' | 'favorites' | 'recent';
export function FontSelector({ fonts, selectedFont, onSelectFont, className }: FontSelectorProps) {
export function FontSelector({ fonts, selectedFont, onSelectFont, onRandomFont, className }: FontSelectorProps) {
const [searchQuery, setSearchQuery] = React.useState('');
const [filter, setFilter] = React.useState<FilterType>('all');
const [favorites, setFavorites] = React.useState<string[]>([]);
@@ -99,7 +101,20 @@ export function FontSelector({ fonts, selectedFont, onSelectFont, className }: F
return (
<Card className={className}>
<div className="p-6">
<h3 className="text-sm font-medium mb-4">Select Font</h3>
<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">

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