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;
|
@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 type { Metadata } from 'next';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import { ToastProvider } from '@/components/ui/Toast';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Figlet UI - ASCII Art Text Generator',
|
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'],
|
keywords: ['figlet', 'ascii art', 'text generator', 'banner', 'ascii', 'text art'],
|
||||||
authors: [{ name: 'Valknar' }],
|
authors: [{ name: 'Valknar' }],
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'Figlet UI - ASCII Art Text Generator',
|
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',
|
type: 'website',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -21,7 +22,9 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
{children}
|
<ToastProvider>
|
||||||
|
{children}
|
||||||
|
</ToastProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getFontList } from '@/lib/figlet/fontLoader';
|
|||||||
import { debounce } from '@/lib/utils/debounce';
|
import { debounce } from '@/lib/utils/debounce';
|
||||||
import { addRecentFont } from '@/lib/storage/favorites';
|
import { addRecentFont } from '@/lib/storage/favorites';
|
||||||
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
|
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import type { FigletFont } from '@/types/figlet';
|
import type { FigletFont } from '@/types/figlet';
|
||||||
|
|
||||||
export function FigletConverter() {
|
export function FigletConverter() {
|
||||||
@@ -17,8 +18,7 @@ export function FigletConverter() {
|
|||||||
const [asciiArt, setAsciiArt] = React.useState('');
|
const [asciiArt, setAsciiArt] = React.useState('');
|
||||||
const [fonts, setFonts] = React.useState<FigletFont[]>([]);
|
const [fonts, setFonts] = React.useState<FigletFont[]>([]);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [isCopied, setIsCopied] = React.useState(false);
|
const { addToast } = useToast();
|
||||||
const [isShared, setIsShared] = React.useState(false);
|
|
||||||
|
|
||||||
// Load fonts and check URL params on mount
|
// Load fonts and check URL params on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -72,10 +72,10 @@ export function FigletConverter() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(asciiArt);
|
await navigator.clipboard.writeText(asciiArt);
|
||||||
setIsCopied(true);
|
addToast('Copied to clipboard!', 'success');
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy:', error);
|
console.error('Failed to copy:', error);
|
||||||
|
addToast('Failed to copy', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,13 +100,21 @@ export function FigletConverter() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
setIsShared(true);
|
addToast('Shareable URL copied!', 'success');
|
||||||
setTimeout(() => setIsShared(false), 2000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy URL:', 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 (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Left Column - Input and Preview */}
|
{/* Left Column - Input and Preview */}
|
||||||
@@ -118,7 +126,8 @@ export function FigletConverter() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FontPreview
|
<FontPreview
|
||||||
text={isCopied ? 'Copied to clipboard! ✓' : isShared ? 'URL copied to clipboard! ✓' : asciiArt}
|
text={asciiArt}
|
||||||
|
font={selectedFont}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
@@ -132,6 +141,7 @@ export function FigletConverter() {
|
|||||||
fonts={fonts}
|
fonts={fonts}
|
||||||
selectedFont={selectedFont}
|
selectedFont={selectedFont}
|
||||||
onSelectFont={setSelectedFont}
|
onSelectFont={setSelectedFont}
|
||||||
|
onRandomFont={handleRandomFont}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils/cn';
|
|||||||
|
|
||||||
export interface FontPreviewProps {
|
export interface FontPreviewProps {
|
||||||
text: string;
|
text: string;
|
||||||
|
font?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onCopy?: () => void;
|
onCopy?: () => void;
|
||||||
onDownload?: () => void;
|
onDownload?: () => void;
|
||||||
@@ -15,13 +16,22 @@ export interface FontPreviewProps {
|
|||||||
className?: string;
|
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 (
|
return (
|
||||||
<Card className={cn('relative', className)}>
|
<Card className={cn('relative', className)}>
|
||||||
<div className="p-6">
|
<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">
|
||||||
<h3 className="text-sm font-medium">Preview</h3>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex 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 && (
|
{onCopy && (
|
||||||
<Button variant="outline" size="sm" onClick={onCopy}>
|
<Button variant="outline" size="sm" onClick={onCopy}>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
@@ -43,6 +53,14 @@ export function FontPreview({ text, isLoading, onCopy, onDownload, onShare, clas
|
|||||||
</div>
|
</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 className="relative min-h-[200px] bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
<div className="relative min-h-[200px] bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<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 Fuse from 'fuse.js';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Card } from '@/components/ui/Card';
|
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 { cn } from '@/lib/utils/cn';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
import type { FigletFont } from '@/types/figlet';
|
import type { FigletFont } from '@/types/figlet';
|
||||||
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
|
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
|
||||||
|
|
||||||
@@ -13,12 +14,13 @@ export interface FontSelectorProps {
|
|||||||
fonts: FigletFont[];
|
fonts: FigletFont[];
|
||||||
selectedFont: string;
|
selectedFont: string;
|
||||||
onSelectFont: (fontName: string) => void;
|
onSelectFont: (fontName: string) => void;
|
||||||
|
onRandomFont?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterType = 'all' | 'favorites' | 'recent';
|
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 [searchQuery, setSearchQuery] = React.useState('');
|
||||||
const [filter, setFilter] = React.useState<FilterType>('all');
|
const [filter, setFilter] = React.useState<FilterType>('all');
|
||||||
const [favorites, setFavorites] = React.useState<string[]>([]);
|
const [favorites, setFavorites] = React.useState<string[]>([]);
|
||||||
@@ -99,7 +101,20 @@ export function FontSelector({ fonts, selectedFont, onSelectFont, className }: F
|
|||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<div className="p-6">
|
<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 */}
|
{/* Filter Tabs */}
|
||||||
<div className="flex gap-1 mb-4 p-1 bg-muted rounded-lg">
|
<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