refactor(ascii): align layout and UX with Calculate blueprint

Rewrites all four ASCII tool components to share the same design
language and spatial structure as the Calculator & Grapher tool.

Layout
- New responsive 2/5–3/5 grid (was fixed 2+1 col); matches Calculate
- Left panel: text input card + font selector filling remaining height
- Right panel: preview as the dominant full-height element
- Mobile: tabbed Editor / Preview switcher (same pattern as Calculator)

TextInput
- Replace shadcn Textarea with native <textarea>
- Glass border pattern (border-border/40, focus:border-primary/50)
- Monospace font, consistent counter styling

FontSelector
- Replace Card + shadcn Tabs + Button + Input + Empty with native elements
- Glass panel (glass rounded-xl) matching Calculate panel style
- Custom tab strip mirrors Calculator mobile tab pattern
- Native search input with glass border
- Font list items: border-l-2 left accent for selected state,
  hover:bg-primary/8, rose heart for favorites
- Auto-scrolls selected item into view on external changes
- Simplified empty state to single italic line

FontPreview
- Replace Card + Button + Badge + ToggleGroup + Tooltip + Empty
- Glass panel with header row (label + font tag + action buttons)
- Controls row: native toggle buttons with primary/10 active state
- Terminal window: dark #06060e background, macOS-style chrome
  (rose/amber/emerald dots), font name watermark — the hero element
- PNG export captures entire terminal including chrome at 2x
- Inline skeleton loader with pulse animation replaces Skeleton import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 07:46:21 +01:00
parent d161aeba72
commit 141ab1f4e3
4 changed files with 419 additions and 396 deletions

View File

@@ -11,7 +11,9 @@ import { addRecentFont } from '@/lib/storage/favorites';
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing'; import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { ASCIIFont } from '@/types/ascii'; import type { ASCIIFont } from '@/types/ascii';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { cn } from '@/lib/utils';
type Tab = 'editor' | 'preview';
export function ASCIIConverter() { export function ASCIIConverter() {
const [text, setText] = React.useState('ASCII'); const [text, setText] = React.useState('ASCII');
@@ -19,13 +21,11 @@ export function ASCIIConverter() {
const [asciiArt, setAsciiArt] = React.useState(''); const [asciiArt, setAsciiArt] = React.useState('');
const [fonts, setFonts] = React.useState<ASCIIFont[]>([]); const [fonts, setFonts] = React.useState<ASCIIFont[]>([]);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const [tab, setTab] = React.useState<Tab>('editor');
const commentedTextRef = React.useRef(''); const commentedTextRef = React.useRef('');
// Load fonts and check URL params on mount
React.useEffect(() => { React.useEffect(() => {
getFontList().then(setFonts); getFontList().then(setFonts);
// Check for URL parameters
const urlState = decodeFromUrl(); const urlState = decodeFromUrl();
if (urlState) { if (urlState) {
if (urlState.text) setText(urlState.text); if (urlState.text) setText(urlState.text);
@@ -33,57 +33,45 @@ export function ASCIIConverter() {
} }
}, []); }, []);
// Generate ASCII art
const generateAsciiArt = React.useMemo( const generateAsciiArt = React.useMemo(
() => debounce(async (inputText: string, fontName: string) => { () =>
if (!inputText) { debounce(async (inputText: string, fontName: string) => {
setAsciiArt(''); if (!inputText) {
setIsLoading(false); setAsciiArt('');
return; setIsLoading(false);
} return;
}
setIsLoading(true); setIsLoading(true);
try { try {
const result = await textToAscii(inputText, fontName); const result = await textToAscii(inputText, fontName);
setAsciiArt(result); setAsciiArt(result);
} catch (error) { } catch {
console.error('Error generating ASCII art:', error); setAsciiArt('Error generating ASCII art. Please try a different font.');
setAsciiArt('Error generating ASCII art. Please try a different font.'); } finally {
} finally { setIsLoading(false);
setIsLoading(false); }
} }, 300),
}, 300),
[] []
); );
// Trigger generation when text or font changes
React.useEffect(() => { React.useEffect(() => {
generateAsciiArt(text, selectedFont); generateAsciiArt(text, selectedFont);
// Track recent fonts if (selectedFont) addRecentFont(selectedFont);
if (selectedFont) {
addRecentFont(selectedFont);
}
// Update URL
updateUrl(text, selectedFont); updateUrl(text, selectedFont);
}, [text, selectedFont, generateAsciiArt]); }, [text, selectedFont, generateAsciiArt]);
// Copy to clipboard
const handleCopy = async () => { const handleCopy = async () => {
if (!asciiArt) return; if (!asciiArt) return;
try { try {
await navigator.clipboard.writeText(commentedTextRef.current || asciiArt); await navigator.clipboard.writeText(commentedTextRef.current || asciiArt);
toast.success('Copied to clipboard!'); toast.success('Copied to clipboard!');
} catch (error) { } catch {
console.error('Failed to copy:', error);
toast.error('Failed to copy'); toast.error('Failed to copy');
} }
}; };
// Download as text file
const handleDownload = () => { const handleDownload = () => {
if (!asciiArt) return; if (!asciiArt) return;
const blob = new Blob([commentedTextRef.current || asciiArt], { type: 'text/plain' }); const blob = new Blob([commentedTextRef.current || asciiArt], { type: 'text/plain' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -95,69 +83,101 @@ export function ASCIIConverter() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
// Share (copy URL to clipboard)
const handleShare = async () => { const handleShare = async () => {
const shareUrl = getShareableUrl(text, selectedFont);
try { try {
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(getShareableUrl(text, selectedFont));
toast.success('Shareable URL copied!'); toast.success('Shareable URL copied!');
} catch (error) { } catch {
console.error('Failed to copy URL:', error);
toast.error('Failed to copy URL'); toast.error('Failed to copy URL');
} }
}; };
// Random font
const handleRandomFont = () => { const handleRandomFont = () => {
if (fonts.length === 0) return; if (!fonts.length) return;
const randomIndex = Math.floor(Math.random() * fonts.length); const font = fonts[Math.floor(Math.random() * fonts.length)];
setSelectedFont(fonts[randomIndex].name); setSelectedFont(font.name);
toast.info(`Random font: ${fonts[randomIndex].name}`); toast.info(`Font: ${font.name}`);
}; };
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch lg:max-h-[800px]"> <div className="flex flex-col gap-4">
{/* Left Column - Input and Preview */}
<div className="lg:col-span-2 space-y-6 overflow-y-auto custom-scrollbar"> {/* ── Mobile tab switcher ────────────────────────────────── */}
<Card> <div className="flex lg:hidden glass rounded-xl p-1 gap-1">
<CardHeader> {(['editor', 'preview'] as Tab[]).map((t) => (
<CardTitle>Text</CardTitle> <button
</CardHeader> key={t}
<CardContent> onClick={() => setTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
tab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'editor' ? 'Editor' : 'Preview'}
</button>
))}
</div>
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
>
{/* Left panel: text input + font selector */}
<div
className={cn(
'lg:col-span-2 flex flex-col gap-3 overflow-hidden',
tab !== 'editor' && 'hidden lg:flex'
)}
>
{/* Text input */}
<div className="glass rounded-xl p-4 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
Text
</span>
<TextInput <TextInput
value={text} value={text}
onChange={setText} onChange={setText}
placeholder="Type your text here..." placeholder="Type your text here"
/> />
</CardContent> </div>
</Card>
<FontPreview {/* Font selector — fills remaining height */}
text={asciiArt} <div className="flex-1 min-h-0 overflow-hidden">
font={selectedFont} <FontSelector
isLoading={isLoading} fonts={fonts}
onCopy={handleCopy} selectedFont={selectedFont}
onDownload={handleDownload} onSelectFont={setSelectedFont}
onShare={handleShare} onRandomFont={handleRandomFont}
onCommentedTextChange={React.useCallback((t: string) => { commentedTextRef.current = t; }, [])} className="h-full"
/> />
</div> </div>
</div>
{/* Right Column - Font Selector */} {/* Right panel: preview */}
<div className="lg:col-span-1 h-[500px] lg:h-auto relative"> <div
<div className="lg:absolute lg:inset-0 h-full"> className={cn(
<FontSelector 'lg:col-span-3 flex flex-col overflow-hidden',
fonts={fonts} tab !== 'preview' && 'hidden lg:flex'
selectedFont={selectedFont} )}
onSelectFont={setSelectedFont} >
onRandomFont={handleRandomFont} <FontPreview
className="h-full" text={asciiArt}
font={selectedFont}
isLoading={isLoading}
onCopy={handleCopy}
onDownload={handleDownload}
onShare={handleShare}
onCommentedTextChange={React.useCallback(
(t: string) => { commentedTextRef.current = t; },
[]
)}
/> />
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -2,11 +2,6 @@
import * as React from 'react'; import * as React from 'react';
import { toPng } from 'html-to-image'; 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 { Badge } from '@/components/ui/badge';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -15,18 +10,16 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import {
Tooltip, Copy,
TooltipContent, Download,
TooltipTrigger, Share2,
} from '@/components/ui/tooltip'; Image as ImageIcon,
import { AlignLeft,
Empty, AlignCenter,
EmptyDescription, AlignRight,
EmptyHeader, MessageSquareCode,
EmptyMedia, Type,
EmptyTitle, } from 'lucide-react';
} from "@/components/ui/empty"
import { Copy, Download, Share2, Image as ImageIcon, AlignLeft, AlignCenter, AlignRight, Type, MessageSquareCode } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -34,12 +27,12 @@ export type CommentStyle = 'none' | '//' | '#' | '--' | ';' | '/* */' | '<!-- --
const COMMENT_STYLES: { value: CommentStyle; label: string }[] = [ const COMMENT_STYLES: { value: CommentStyle; label: string }[] = [
{ value: 'none', label: 'None' }, { value: 'none', label: 'None' },
{ value: '//', label: '// C, JS, Go' }, { value: '//', label: '// C / JS / Go' },
{ value: '#', label: '# Python, Shell' }, { value: '#', label: '# Python / Shell' },
{ value: '--', label: '-- SQL, Lua' }, { value: '--', label: '-- SQL / Lua' },
{ value: ';', label: '; Lisp, ASM' }, { value: ';', label: '; Lisp / ASM' },
{ value: '/* */', label: '/* */ Block' }, { value: '/* */', label: '/* Block */' },
{ value: '<!-- -->', label: '<!-- --> HTML' }, { value: '<!-- -->', label: '<!-- HTML -->' },
{ value: '"""', label: '""" Docstring' }, { value: '"""', label: '""" Docstring' },
]; ];
@@ -51,9 +44,9 @@ function applyCommentStyle(text: string, style: CommentStyle): string {
case '#': case '#':
case '--': case '--':
case ';': case ';':
return lines.map(line => `${style} ${line}`).join('\n'); return lines.map((l) => `${style} ${l}`).join('\n');
case '/* */': case '/* */':
return ['/*', ...lines.map(line => ` * ${line}`), ' */'].join('\n'); return ['/*', ...lines.map((l) => ` * ${l}`), ' */'].join('\n');
case '<!-- -->': case '<!-- -->':
return ['<!--', ...lines, '-->'].join('\n'); return ['<!--', ...lines, '-->'].join('\n');
case '"""': case '"""':
@@ -73,14 +66,39 @@ export interface FontPreviewProps {
} }
type TextAlign = 'left' | 'center' | 'right'; type TextAlign = 'left' | 'center' | 'right';
type FontSize = 'xs' | 'sm' | 'base';
export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare, onCommentedTextChange, className }: FontPreviewProps) { const ALIGN_OPTS: { value: TextAlign; icon: React.ElementType; label: string }[] = [
const previewRef = React.useRef<HTMLDivElement>(null); { value: 'left', icon: AlignLeft, label: 'Left' },
{ value: 'center', icon: AlignCenter, label: 'Center' },
{ value: 'right', icon: AlignRight, label: 'Right' },
];
const SIZE_OPTS: { value: FontSize; label: string }[] = [
{ value: 'xs', label: 'xs' },
{ value: 'sm', label: 'sm' },
{ value: 'base', label: 'md' },
];
export function FontPreview({
text,
font,
isLoading,
onCopy,
onDownload,
onShare,
onCommentedTextChange,
className,
}: FontPreviewProps) {
const terminalRef = React.useRef<HTMLDivElement>(null);
const [textAlign, setTextAlign] = React.useState<TextAlign>('left'); const [textAlign, setTextAlign] = React.useState<TextAlign>('left');
const [fontSize, setFontSize] = React.useState<'xs' | 'sm' | 'base'>('sm'); const [fontSize, setFontSize] = React.useState<FontSize>('sm');
const [commentStyle, setCommentStyle] = React.useState<CommentStyle>('none'); const [commentStyle, setCommentStyle] = React.useState<CommentStyle>('none');
const commentedText = React.useMemo(() => applyCommentStyle(text, commentStyle), [text, commentStyle]); const commentedText = React.useMemo(
() => applyCommentStyle(text, commentStyle),
[text, commentStyle]
);
const lineCount = commentedText ? commentedText.split('\n').length : 0; const lineCount = commentedText ? commentedText.split('\n').length : 0;
const charCount = commentedText ? commentedText.length : 0; const charCount = commentedText ? commentedText.length : 0;
@@ -89,183 +107,181 @@ export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare
}, [commentedText, onCommentedTextChange]); }, [commentedText, onCommentedTextChange]);
const handleExportPNG = async () => { const handleExportPNG = async () => {
if (!previewRef.current || !text) return; if (!terminalRef.current || !text) return;
try { try {
const dataUrl = await toPng(previewRef.current, { const dataUrl = await toPng(terminalRef.current, {
backgroundColor: getComputedStyle(previewRef.current).backgroundColor, backgroundColor: '#06060e',
pixelRatio: 2, pixelRatio: 2,
}); });
const link = document.createElement('a'); const link = document.createElement('a');
link.download = `ascii-${font || 'export'}-${Date.now()}.png`; link.download = `ascii-${font || 'export'}-${Date.now()}.png`;
link.href = dataUrl; link.href = dataUrl;
link.click(); link.click();
toast.success('Exported as PNG!'); toast.success('Exported as PNG!');
} catch (error) { } catch {
console.error('Failed to export PNG:', error);
toast.error('Failed to export PNG'); toast.error('Failed to export PNG');
} }
}; };
const actionBtn =
'flex items-center gap-1 px-2.5 py-1 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all';
return ( return (
<Card className={cn('relative', className)}> <div className={cn('glass rounded-xl p-4 flex flex-col gap-3 flex-1 min-h-0 overflow-hidden', className)}>
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
{/* ── Header: label + font tag + export actions ─────────── */}
<div className="flex items-center justify-between gap-2 shrink-0 flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CardTitle>Preview</CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Preview
</span>
{font && ( {font && (
<Badge className="text-[10px] font-mono"> <span className="px-2 py-0.5 rounded-md bg-primary/10 text-primary text-[10px] font-mono border border-primary/20">
{font} {font}
</Badge> </span>
)} )}
</div> </div>
<div className="flex gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{onCopy && ( {onCopy && (
<Tooltip> <button onClick={onCopy} className={actionBtn}>
<TooltipTrigger asChild> <Copy className="w-3 h-3" /> Copy
<Button variant="outline" size="xs" onClick={onCopy}> </button>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
</TooltipTrigger>
<TooltipContent>Copy to clipboard</TooltipContent>
</Tooltip>
)} )}
{onShare && ( {onShare && (
<Tooltip> <button onClick={onShare} className={actionBtn}>
<TooltipTrigger asChild> <Share2 className="w-3 h-3" /> Share
<Button variant="outline" size="xs" onClick={onShare}> </button>
<Share2 className="h-3 w-3 mr-1" />
Share
</Button>
</TooltipTrigger>
<TooltipContent>Copy shareable URL</TooltipContent>
</Tooltip>
)} )}
<Tooltip> <button onClick={handleExportPNG} className={actionBtn}>
<TooltipTrigger asChild> <ImageIcon className="w-3 h-3" /> PNG
<Button variant="outline" size="xs" onClick={handleExportPNG}> </button>
<ImageIcon className="h-3 w-3 mr-1" />
PNG
</Button>
</TooltipTrigger>
<TooltipContent>Export as PNG</TooltipContent>
</Tooltip>
{onDownload && ( {onDownload && (
<Tooltip> <button onClick={onDownload} className={actionBtn}>
<TooltipTrigger asChild> <Download className="w-3 h-3" /> TXT
<Button variant="outline" size="xs" onClick={onDownload}> </button>
<Download className="h-3 w-3 mr-1" />
TXT
</Button>
</TooltipTrigger>
<TooltipContent>Download as text file</TooltipContent>
</Tooltip>
)} )}
</div> </div>
</CardHeader> </div>
<CardContent className="space-y-3">
{/* Controls */}
<div className="flex items-center gap-2 flex-wrap">
<ToggleGroup
type="single"
value={textAlign}
onValueChange={(v) => v && setTextAlign(v as TextAlign)}
variant="outline"
size="sm"
disabled={commentStyle !== 'none'}
>
<ToggleGroupItem value="left" aria-label="Align left" className="px-1.5">
<AlignLeft className="h-3 w-3" />
</ToggleGroupItem>
<ToggleGroupItem value="center" aria-label="Align center" className="px-1.5">
<AlignCenter className="h-3 w-3" />
</ToggleGroupItem>
<ToggleGroupItem value="right" aria-label="Align right" className="px-1.5">
<AlignRight className="h-3 w-3" />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup {/* ── Controls: alignment · size · comment style ─────────── */}
type="single" <div className="flex items-center gap-2 shrink-0 flex-wrap">
value={fontSize} {/* Alignment */}
onValueChange={(v) => v && setFontSize(v as 'xs' | 'sm' | 'base')} <div className="flex items-center gap-0.5">
variant="outline" {ALIGN_OPTS.map(({ value, icon: Icon, label }) => (
size="sm" <button
> key={value}
<ToggleGroupItem value="xs" aria-label="Extra small font" className="px-1.5 text-[10px] uppercase"> onClick={() => setTextAlign(value)}
xs disabled={commentStyle !== 'none'}
</ToggleGroupItem> title={label}
<ToggleGroupItem value="sm" aria-label="Small font" className="px-1.5 text-[10px] uppercase"> className={cn(
sm 'px-2 py-1 rounded-md transition-all border text-xs',
</ToggleGroupItem> textAlign === value && commentStyle === 'none'
<ToggleGroupItem value="base" aria-label="Medium font" className="px-1.5 text-[10px] uppercase"> ? 'bg-primary/10 border-primary/30 text-primary'
md : 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40',
</ToggleGroupItem> commentStyle !== 'none' && 'opacity-30 cursor-not-allowed'
</ToggleGroup> )}
>
<Icon className="w-3 h-3" />
</button>
))}
</div>
<Select value={commentStyle} onValueChange={(v) => setCommentStyle(v as CommentStyle)}> {/* Font size */}
<SelectTrigger size="sm" className="h-8 w-auto gap-1 text-xs"> <div className="flex items-center gap-0.5">
<MessageSquareCode className="h-3 w-3 text-foreground shrink-0" /> {SIZE_OPTS.map(({ value, label }) => (
<SelectValue /> <button
</SelectTrigger> key={value}
<SelectContent> onClick={() => setFontSize(value)}
{COMMENT_STYLES.map((s) => ( className={cn(
<SelectItem key={s.value} value={s.value}> 'px-2 py-1 text-[10px] font-mono rounded-md transition-all border uppercase',
{s.label} fontSize === value
</SelectItem> ? 'bg-primary/10 border-primary/30 text-primary'
))} : 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40'
</SelectContent> )}
</Select> >
{label}
</button>
))}
</div>
{!isLoading && text && ( {/* Comment style */}
<div className="flex gap-2 text-[10px] text-muted-foreground ml-auto"> <Select value={commentStyle} onValueChange={(v) => setCommentStyle(v as CommentStyle)}>
<span>{lineCount} lines</span> <SelectTrigger className="h-7 w-auto gap-1.5 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors">
<span>{charCount} chars</span> <MessageSquareCode className="w-3 h-3 text-muted-foreground/60 shrink-0" />
</div> <SelectValue />
</SelectTrigger>
<SelectContent>
{COMMENT_STYLES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Stats */}
{!isLoading && text && (
<span className="ml-auto text-[10px] text-muted-foreground/30 font-mono tabular-nums">
{lineCount}L · {charCount}C
</span>
)}
</div>
{/* ── Terminal window ────────────────────────────────────── */}
<div
ref={terminalRef}
className="flex-1 min-h-0 flex flex-col rounded-xl overflow-hidden border border-white/5"
style={{ background: '#06060e' }}
>
{/* Terminal chrome */}
<div className="flex items-center gap-1.5 px-3.5 py-2 border-b border-white/5 shrink-0">
<div className="w-2.5 h-2.5 rounded-full bg-rose-500/55" />
<div className="w-2.5 h-2.5 rounded-full bg-amber-400/55" />
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/55" />
{font && (
<span className="ml-2 text-[10px] font-mono text-white/20 tracking-wider select-none">
{font}
</span>
)} )}
</div> </div>
{/* Content */}
<div <div
ref={previewRef} className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-white/8 scrollbar-track-transparent"
className={cn( style={{ textAlign: commentStyle === 'none' ? textAlign : 'left' }}
'relative min-h-[200px] bg-muted/50 rounded-lg p-4 overflow-x-auto',
commentStyle === 'none' && textAlign === 'center' && 'text-center',
commentStyle === 'none' && textAlign === 'right' && 'text-right'
)}
> >
{isLoading ? ( {isLoading ? (
<div className="space-y-3"> <div className="space-y-2 animate-pulse">
<Skeleton className="h-6 w-3/4" /> {[0.7, 1, 0.85, 0.55, 1, 0.9, 0.75].map((w, i) => (
<Skeleton className="h-6 w-full" /> <div
<Skeleton className="h-6 w-5/6" /> key={i}
<Skeleton className="h-6 w-2/3" /> className="h-3.5 rounded-sm bg-white/5"
<Skeleton className="h-6 w-full" /> style={{ width: `${w * 100}%` }}
<Skeleton className="h-6 w-4/5" /> />
))}
</div> </div>
) : text ? ( ) : text ? (
<pre className={cn( <pre
'font-mono whitespace-pre overflow-x-auto animate-in', className={cn(
fontSize === 'xs' && 'text-[10px]', 'font-mono whitespace-pre text-white/85 leading-snug',
fontSize === 'sm' && 'text-xs sm:text-sm', fontSize === 'xs' && 'text-[9px]',
fontSize === 'base' && 'text-sm sm:text-base' fontSize === 'sm' && 'text-[11px] sm:text-xs',
)}> fontSize === 'base' && 'text-xs sm:text-sm'
)}
>
{commentedText} {commentedText}
</pre> </pre>
) : ( ) : (
<Empty> <div className="h-full flex flex-col items-center justify-center gap-2 text-center">
<EmptyHeader> <Type className="w-6 h-6 text-white/10" />
<EmptyMedia variant="icon"> <p className="text-xs text-white/20 font-mono">
<Type /> Start typing to see your ASCII art
</EmptyMedia> </p>
<EmptyTitle>Start typing to see your ASCII art</EmptyTitle> </div>
<EmptyDescription>Enter text in the input field above to generate ASCII art with the selected font</EmptyDescription>
</EmptyHeader>
</Empty>
)} )}
</div> </div>
</CardContent> </div>
</Card>
</div>
); );
} }

View File

@@ -2,19 +2,8 @@
import * as React from 'react'; import * as React from 'react';
import Fuse from 'fuse.js'; 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 { 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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { ASCIIFont } from '@/types/ascii'; import type { ASCIIFont } from '@/types/ascii';
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites'; import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
@@ -28,62 +17,52 @@ export interface FontSelectorProps {
type FilterType = 'all' | 'favorites' | 'recent'; type FilterType = 'all' | 'favorites' | 'recent';
const FILTERS: { value: FilterType; icon: React.ElementType; label: string }[] = [
{ value: 'all', icon: List, label: 'All' },
{ value: 'favorites', icon: Heart, label: 'Fav' },
{ value: 'recent', icon: Clock, label: 'Recent' },
];
export function FontSelector({ export function FontSelector({
fonts, fonts,
selectedFont, selectedFont,
onSelectFont, onSelectFont,
onRandomFont, onRandomFont,
className className,
}: FontSelectorProps) { }: 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[]>([]);
const [recentFonts, setRecentFonts] = React.useState<string[]>([]); const [recentFonts, setRecentFonts] = React.useState<string[]>([]);
const searchInputRef = React.useRef<HTMLInputElement>(null); const selectedRef = React.useRef<HTMLButtonElement>(null);
// Load favorites and recent fonts
React.useEffect(() => { React.useEffect(() => {
setFavorites(getFavorites()); setFavorites(getFavorites());
setRecentFonts(getRecentFonts()); setRecentFonts(getRecentFonts());
}, []); }, []);
// Initialize Fuse.js for fuzzy search // Keep selected item in view when font changes externally (e.g. random)
const fuse = React.useMemo(() => { React.useEffect(() => {
return new Fuse(fonts, { selectedRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
keys: ['name', 'fileName'], }, [selectedFont]);
threshold: 0.3,
includeScore: true, const fuse = React.useMemo(
}); () => new Fuse(fonts, { keys: ['name', 'fileName'], threshold: 0.3, includeScore: true }),
}, [fonts]); [fonts]
);
const filteredFonts = React.useMemo(() => { const filteredFonts = React.useMemo(() => {
let fontsToFilter = fonts; let base = fonts;
// Apply category filter
if (filter === 'favorites') { if (filter === 'favorites') {
fontsToFilter = fonts.filter(f => favorites.includes(f.name)); base = fonts.filter((f) => favorites.includes(f.name));
} else if (filter === 'recent') { } else if (filter === 'recent') {
fontsToFilter = fonts.filter(f => recentFonts.includes(f.name)); base = [...fonts.filter((f) => recentFonts.includes(f.name))].sort(
// Sort by recent order (a, b) => recentFonts.indexOf(a.name) - recentFonts.indexOf(b.name)
fontsToFilter.sort((a, b) => { );
return recentFonts.indexOf(a.name) - recentFonts.indexOf(b.name);
});
} }
if (!searchQuery) return base;
// Apply search query const hits = fuse.search(searchQuery).map((r) => r.item);
if (!searchQuery) return fontsToFilter; return filter === 'all' ? hits : hits.filter((f) => base.includes(f));
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]); }, [fonts, searchQuery, fuse, filter, favorites, recentFonts]);
const handleToggleFavorite = (fontName: string, e: React.MouseEvent) => { const handleToggleFavorite = (fontName: string, e: React.MouseEvent) => {
@@ -92,134 +71,140 @@ export function FontSelector({
setFavorites(getFavorites()); setFavorites(getFavorites());
}; };
return ( const emptyMessage =
<Card className={cn("flex flex-col min-h-0 overflow-hidden", className)}> filter === 'favorites'
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2 space-y-0"> ? 'No favorites yet — click ♥ to save'
<CardTitle>Fonts</CardTitle> : filter === 'recent'
{onRandomFont && ( ? 'No recent fonts'
<Button : searchQuery
variant="outline" ? 'No fonts match your search'
size="xs" : 'Loading fonts…';
onClick={onRandomFont}
title="Random font"
>
<Shuffle className="h-3 w-3 mr-1" />
Random
</Button>
)}
</CardHeader>
<CardContent className="flex flex-col flex-1 min-h-0 pt-0">
<Tabs
value={filter}
onValueChange={(v) => setFilter(v as FilterType)}
className="mb-3 shrink-0"
>
<TabsList className="w-full">
<TabsTrigger value="all" className="flex-1">
<List className="h-3 w-3" />
All
</TabsTrigger>
<TabsTrigger value="favorites" className="flex-1">
<Heart className="h-3 w-3" />
Fav
</TabsTrigger>
<TabsTrigger value="recent" className="flex-1">
<Clock className="h-3 w-3" />
Recent
</TabsTrigger>
</TabsList>
</Tabs>
{/* Search Input */} return (
<div className="relative mb-3 shrink-0"> <div className={cn('glass rounded-xl p-3 flex flex-col min-h-0 overflow-hidden', className)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input {/* ── Header ────────────────────────────────────────────── */}
ref={searchInputRef} <div className="flex items-center justify-between mb-3 shrink-0">
type="text" <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
placeholder="Search fonts..." Fonts
value={searchQuery} </span>
onChange={(e) => setSearchQuery(e.target.value)} <div className="flex items-center gap-2.5">
className="pl-8 pr-8 h-8 text-sm" <span className="text-[10px] text-muted-foreground/35 font-mono tabular-nums">
/> {fonts.length}
{searchQuery && ( </span>
{onRandomFont && (
<button <button
onClick={() => setSearchQuery('')} onClick={onRandomFont}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" className="text-muted-foreground/50 hover:text-primary transition-colors"
aria-label="Clear search" title="Random font"
> >
<X className="h-3.5 w-3.5" /> <Shuffle className="w-3.5 h-3.5" />
</button> </button>
)} )}
</div> </div>
</div>
{/* Font List */} {/* ── Filter tabs ───────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto space-y-0.5 pr-1 scrollbar"> <div className="flex glass rounded-lg p-0.5 gap-0.5 mb-3 shrink-0">
{filteredFonts.length === 0 ? ( {FILTERS.map(({ value, icon: Icon, label }) => (
<Empty> <button
<EmptyHeader> key={value}
<EmptyMedia variant="icon"> onClick={() => setFilter(value)}
{filter === 'favorites' ? <Heart /> : (filter === 'recent' ? <Clock /> : <Search />)} className={cn(
</EmptyMedia> 'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-all',
<EmptyTitle>{ filter === value
filter === 'favorites' ? 'bg-primary text-primary-foreground shadow-sm'
? 'No favorite fonts yet' : 'text-muted-foreground hover:text-foreground'
: filter === 'recent' )}
? 'No recent fonts' >
: 'No fonts found' <Icon className="w-3 h-3" />
}</EmptyTitle> {label}
<EmptyDescription> </button>
{ ))}
filter === 'favorites' </div>
? 'Click the heart icon on any font to add it to your favorites'
: filter === 'recent' {/* ── Search ────────────────────────────────────────────── */}
? 'Fonts you use will appear here' <div className="relative mb-3 shrink-0">
: searchQuery <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground/40 pointer-events-none" />
? 'Try a different search term' <input
: 'Loading fonts...' type="text"
} placeholder="Search fonts…"
</EmptyDescription> value={searchQuery}
</EmptyHeader> onChange={(e) => setSearchQuery(e.target.value)}
</Empty> className="w-full bg-transparent border border-border/40 rounded-lg pl-8 pr-7 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30"
) : ( />
filteredFonts.map((font) => ( {searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{/* ── Font list ─────────────────────────────────────────── */}
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent space-y-0.5 pr-0.5">
{filteredFonts.length === 0 ? (
<div className="py-10 text-center">
<p className="text-xs text-muted-foreground/35 italic">{emptyMessage}</p>
</div>
) : (
filteredFonts.map((font) => {
const isSelected = selectedFont === font.name;
const fav = isFavorite(font.name);
return (
<div <div
key={font.name} key={font.name}
className={cn( className={cn(
'group flex items-center gap-1 px-2 py-1.5 rounded text-xs transition-colors', 'group flex items-center gap-1.5 rounded-lg transition-all cursor-pointer',
'hover:bg-accent hover:text-accent-foreground', 'border-l-2',
selectedFont === font.name && 'bg-accent text-accent-foreground font-medium' isSelected
? 'bg-primary/10 border-primary text-primary'
: 'border-transparent text-foreground/65 hover:bg-primary/8 hover:text-foreground'
)} )}
> >
<button <button
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelectFont(font.name)} onClick={() => onSelectFont(font.name)}
className="flex-1 text-left truncate" className="flex-1 text-left text-xs font-mono truncate px-2 py-1.5"
> >
{font.name} {font.name}
</button> </button>
<button <button
onClick={(e) => handleToggleFavorite(font.name, e)} onClick={(e) => handleToggleFavorite(font.name, e)}
className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" className={cn(
aria-label={isFavorite(font.name) ? 'Remove from favorites' : 'Add to favorites'} 'shrink-0 pr-2 transition-all',
fav ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
aria-label={fav ? 'Remove from favorites' : 'Add to favorites'}
> >
<Heart <Heart
className={cn( className={cn(
'h-3 w-3 transition-colors', 'w-3 h-3 transition-colors',
isFavorite(font.name) ? 'fill-red-500 text-red-500 !opacity-100' : 'text-muted-foreground/50 hover:text-red-500/50' fav ? 'fill-rose-500 text-rose-500' : 'text-muted-foreground/40 hover:text-rose-400'
)} )}
/> />
</button> </button>
</div> </div>
)) );
)} })
</div> )}
</div>
{/* Stats */} {/* ── Footer ────────────────────────────────────────────── */}
<div className="mt-3 pt-3 border-t text-[10px] text-muted-foreground shrink-0"> <div className="mt-3 pt-2.5 border-t border-border/25 flex items-center justify-between shrink-0">
<span className="text-[10px] text-muted-foreground/35 font-mono tabular-nums">
{filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''} {filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''}
{filter === 'favorites' && ` · ${favorites.length} favorites`} </span>
{filter === 'recent' && ` · ${recentFonts.length} recent`} {filter === 'favorites' && (
</div> <span className="text-[10px] text-muted-foreground/35">{favorites.length} saved</span>
</CardContent> )}
</Card> {filter === 'recent' && (
<span className="text-[10px] text-muted-foreground/35">{recentFonts.length} recent</span>
)}
</div>
</div>
); );
} }

View File

@@ -2,7 +2,6 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Textarea } from '@/components/ui/textarea';
export interface TextInputProps { export interface TextInputProps {
value: string; value: string;
@@ -14,14 +13,17 @@ export interface TextInputProps {
export function TextInput({ value, onChange, placeholder, className }: TextInputProps) { export function TextInput({ value, onChange, placeholder, className }: TextInputProps) {
return ( return (
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
<Textarea <textarea
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || 'Type something...'} placeholder={placeholder || 'Type something'}
className="h-32 resize-none" rows={4}
maxLength={100} maxLength={100}
className="w-full bg-transparent resize-none font-mono text-sm outline-none text-foreground placeholder:text-muted-foreground/35 border border-border/40 rounded-lg px-3 py-2.5 focus:border-primary/50 transition-colors"
spellCheck={false}
autoComplete="off"
/> />
<div className="absolute bottom-2 right-2 text-xs text-muted-foreground"> <div className="absolute bottom-3 right-3 text-[10px] text-muted-foreground/35 font-mono pointer-events-none tabular-nums">
{value.length}/100 {value.length}/100
</div> </div>
</div> </div>