refactor: rename figlet app to ascii and update all references

This commit is contained in:
2026-02-26 12:31:10 +01:00
parent 484423f299
commit e1406f427e
455 changed files with 47 additions and 47 deletions

View File

@@ -0,0 +1,158 @@
'use client';
import * as React from 'react';
import { TextInput } from './TextInput';
import { FontPreview } from './FontPreview';
import { FontSelector } from './FontSelector';
import { textToAscii } from '@/lib/ascii/asciiService';
import { getFontList } from '@/lib/ascii/fontLoader';
import { debounce } from '@/lib/utils/debounce';
import { addRecentFont } from '@/lib/storage/favorites';
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
import { toast } from 'sonner';
import type { ASCIIFont } from '@/types/ascii';
import { Car } from 'lucide-react';
import { Card, CardContent } from '../ui/card';
export function ASCIIConverter() {
const [text, setText] = React.useState('ASCII');
const [selectedFont, setSelectedFont] = React.useState('Standard');
const [asciiArt, setAsciiArt] = React.useState('');
const [fonts, setFonts] = React.useState<ASCIIFont[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
// 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.useMemo(
() => 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);
toast.success('Copied to clipboard!');
} catch (error) {
console.error('Failed to copy:', error);
toast.error('Failed to copy');
}
};
// 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 = `ascii-${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);
toast.success('Shareable URL copied!');
} catch (error) {
console.error('Failed to copy URL:', error);
toast.error('Failed to copy URL');
}
};
// Random font
const handleRandomFont = () => {
if (fonts.length === 0) return;
const randomIndex = Math.floor(Math.random() * fonts.length);
setSelectedFont(fonts[randomIndex].name);
toast.info(`Random font: ${fonts[randomIndex].name}`);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch lg:max-h-[800px]">
{/* Left Column - Input and Preview */}
<div className="lg:col-span-2 space-y-6 overflow-y-auto custom-scrollbar">
<Card>
<CardContent>
<TextInput
value={text}
onChange={setText}
placeholder="Type your text here..."
/>
</CardContent>
</Card>
<FontPreview
text={asciiArt}
font={selectedFont}
isLoading={isLoading}
onCopy={handleCopy}
onDownload={handleDownload}
onShare={handleShare}
/>
</div>
{/* Right Column - Font Selector */}
<div className="lg:col-span-1 h-[500px] lg:h-auto relative">
<div className="lg:absolute lg:inset-0 h-full">
<FontSelector
fonts={fonts}
selectedFont={selectedFont}
onSelectFont={setSelectedFont}
onRandomFont={handleRandomFont}
className="h-full"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import * as React from 'react';
import { toPng } from 'html-to-image';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { Copy, Download, Share2, Image as ImageIcon, AlignLeft, AlignCenter, AlignRight, Type } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { toast } from 'sonner';
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 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 = `ascii-${font || 'export'}-${Date.now()}.png`;
link.href = dataUrl;
link.click();
toast.success('Exported as PNG!');
} catch (error) {
console.error('Failed to export PNG:', error);
toast.error('Failed to export PNG');
}
};
return (
<Card className={cn('relative', className)}>
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<CardTitle>Preview</CardTitle>
{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-3 w-3 mr-2" />
Copy
</Button>
)}
{onShare && (
<Button variant="outline" size="sm" onClick={onShare} title="Copy shareable URL">
<Share2 className="h-3 w-3 mr-2" />
Share
</Button>
)}
<Button variant="outline" size="sm" onClick={handleExportPNG} title="Export as PNG">
<ImageIcon className="h-3 w-3 mr-2" />
PNG
</Button>
{onDownload && (
<Button variant="outline" size="sm" onClick={onDownload}>
<Download className="h-3 w-3 mr-2" />
TXT
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 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>
{!isLoading && text && (
<div className="flex gap-4 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>
) : (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Type />
</EmptyMedia>
<EmptyTitle>Start typing to see your ASCII art</EmptyTitle>
<EmptyDescription>Enter text in the input field above to generate ASCII art with the selected font</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,237 @@
'use client';
import * as React from 'react';
import Fuse from 'fuse.js';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { Search, X, Heart, Clock, List, Shuffle } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/button';
import type { ASCIIFont } from '@/types/ascii';
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
export interface FontSelectorProps {
fonts: ASCIIFont[];
selectedFont: string;
onSelectFont: (fontName: string) => void;
onRandomFont?: () => void;
className?: string;
}
type FilterType = 'all' | 'favorites' | 'recent';
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[]>([]);
const [recentFonts, setRecentFonts] = React.useState<string[]>([]);
const searchInputRef = React.useRef<HTMLInputElement>(null);
// Load favorites and recent fonts
React.useEffect(() => {
setFavorites(getFavorites());
setRecentFonts(getRecentFonts());
}, []);
// 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={cn("flex flex-col min-h-0 overflow-hidden", className)}>
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2 space-y-0">
<CardTitle>Select Font</CardTitle>
{onRandomFont && (
<Button
variant="outline"
size="sm"
onClick={onRandomFont}
title="Random font"
>
<Shuffle className="h-3 w-3 mr-2" />
Random
</Button>
)}
</CardHeader>
<CardContent className="flex flex-col flex-1 min-h-0 pt-0">
{/* Filter Tabs */}
<div className="flex gap-1 mb-4 p-1 bg-muted rounded-lg shrink-0">
<button
onClick={() => setFilter('all')}
className={cn(
'flex-1 flex items-center justify-center 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="h-3 w-3 mr-1.5" />
All
</button>
<button
onClick={() => setFilter('favorites')}
className={cn(
'flex-1 flex items-center justify-center 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="h-3 w-3 mr-1.5" />
Favorites
</button>
<button
onClick={() => setFilter('recent')}
className={cn(
'flex-1 flex items-center justify-center 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="h-3 w-3 mr-1.5" />
Recent
</button>
</div>
{/* Search Input */}
<div className="relative mb-4 shrink-0">
<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..."
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="flex-1 overflow-y-auto space-y-1 pr-2">
{filteredFonts.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
{filter === 'favorites' ? <Heart /> : (filter === 'recent' ? <Clock /> : <Search />)}
</EmptyMedia>
<EmptyTitle>{
filter === 'favorites'
? 'No favorite fonts yet'
: filter === 'recent'
? 'No recent fonts'
: 'No fonts found'
}</EmptyTitle>
<EmptyDescription>
{
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...'
}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
filteredFonts.map((font) => (
<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>
<button
onClick={(e) => handleToggleFavorite(font.name, e)}
className="p-1"
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/30 hover:text-red-500/50'
)}
/>
</button>
</div>
))
)}
</div>
{/* Stats */}
<div className="mt-4 pt-4 border-t text-xs text-muted-foreground shrink-0">
{filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''}
{filter === 'favorites' && `${favorites.length} total favorites`}
{filter === 'recent' && `${recentFonts.length} recent`}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Textarea } from '@/components/ui/textarea';
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="h-32 resize-none"
maxLength={100}
/>
<div className="absolute bottom-2 right-2 text-xs text-muted-foreground">
{value.length}/100
</div>
</div>
);
}