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:
@@ -11,7 +11,9 @@ 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 { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'editor' | 'preview';
|
||||
|
||||
export function ASCIIConverter() {
|
||||
const [text, setText] = React.useState('ASCII');
|
||||
@@ -19,13 +21,11 @@ export function ASCIIConverter() {
|
||||
const [asciiArt, setAsciiArt] = React.useState('');
|
||||
const [fonts, setFonts] = React.useState<ASCIIFont[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [tab, setTab] = React.useState<Tab>('editor');
|
||||
const commentedTextRef = React.useRef('');
|
||||
|
||||
// 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);
|
||||
@@ -33,57 +33,45 @@ export function ASCIIConverter() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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),
|
||||
() =>
|
||||
debounce(async (inputText: string, fontName: string) => {
|
||||
if (!inputText) {
|
||||
setAsciiArt('');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await textToAscii(inputText, fontName);
|
||||
setAsciiArt(result);
|
||||
} catch {
|
||||
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
|
||||
if (selectedFont) addRecentFont(selectedFont);
|
||||
updateUrl(text, selectedFont);
|
||||
}, [text, selectedFont, generateAsciiArt]);
|
||||
|
||||
// Copy to clipboard
|
||||
const handleCopy = async () => {
|
||||
if (!asciiArt) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(commentedTextRef.current || asciiArt);
|
||||
toast.success('Copied to clipboard!');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
// Download as text file
|
||||
const handleDownload = () => {
|
||||
if (!asciiArt) return;
|
||||
|
||||
const blob = new Blob([commentedTextRef.current || asciiArt], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -95,69 +83,101 @@ export function ASCIIConverter() {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Share (copy URL to clipboard)
|
||||
const handleShare = async () => {
|
||||
const shareUrl = getShareableUrl(text, selectedFont);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
await navigator.clipboard.writeText(getShareableUrl(text, selectedFont));
|
||||
toast.success('Shareable URL copied!');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy URL:', error);
|
||||
} catch {
|
||||
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}`);
|
||||
if (!fonts.length) return;
|
||||
const font = fonts[Math.floor(Math.random() * fonts.length)];
|
||||
setSelectedFont(font.name);
|
||||
toast.info(`Font: ${font.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>
|
||||
<CardHeader>
|
||||
<CardTitle>Text</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── Mobile tab switcher ────────────────────────────────── */}
|
||||
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||
{(['editor', 'preview'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
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
|
||||
value={text}
|
||||
onChange={setText}
|
||||
placeholder="Type your text here..."
|
||||
placeholder="Type your text here…"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
<FontPreview
|
||||
text={asciiArt}
|
||||
font={selectedFont}
|
||||
isLoading={isLoading}
|
||||
onCopy={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onCommentedTextChange={React.useCallback((t: string) => { commentedTextRef.current = t; }, [])}
|
||||
/>
|
||||
</div>
|
||||
{/* Font selector — fills remaining height */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<FontSelector
|
||||
fonts={fonts}
|
||||
selectedFont={selectedFont}
|
||||
onSelectFont={setSelectedFont}
|
||||
onRandomFont={handleRandomFont}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</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"
|
||||
{/* Right panel: preview */}
|
||||
<div
|
||||
className={cn(
|
||||
'lg:col-span-3 flex flex-col overflow-hidden',
|
||||
tab !== 'preview' && 'hidden lg:flex'
|
||||
)}
|
||||
>
|
||||
<FontPreview
|
||||
text={asciiArt}
|
||||
font={selectedFont}
|
||||
isLoading={isLoading}
|
||||
onCopy={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onCommentedTextChange={React.useCallback(
|
||||
(t: string) => { commentedTextRef.current = t; },
|
||||
[]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user