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