diff --git a/components/ascii/ASCIIConverter.tsx b/components/ascii/ASCIIConverter.tsx index d0ab233..4c32e64 100644 --- a/components/ascii/ASCIIConverter.tsx +++ b/components/ascii/ASCIIConverter.tsx @@ -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([]); const [isLoading, setIsLoading] = React.useState(false); + const [tab, setTab] = React.useState('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 ( -
- {/* Left Column - Input and Preview */} -
- - - Text - - - +
+ + {/* ── Mobile tab switcher ────────────────────────────────── */} +
+ {(['editor', 'preview'] as Tab[]).map((t) => ( + + ))} +
+ + {/* ── Main layout ────────────────────────────────────────── */} +
+ {/* Left panel: text input + font selector */} +
+ {/* Text input */} +
+ + Text + - - - +
- { commentedTextRef.current = t; }, [])} - /> -
+ {/* Font selector — fills remaining height */} +
+ +
+
- {/* Right Column - Font Selector */} -
-
- + { commentedTextRef.current = t; }, + [] + )} />
+
); } diff --git a/components/ascii/FontPreview.tsx b/components/ascii/FontPreview.tsx index a68a2c0..405281a 100644 --- a/components/ascii/FontPreview.tsx +++ b/components/ascii/FontPreview.tsx @@ -2,11 +2,6 @@ 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 { Badge } from '@/components/ui/badge'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Select, SelectContent, @@ -15,18 +10,16 @@ import { SelectValue, } from '@/components/ui/select'; import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@/components/ui/empty" -import { Copy, Download, Share2, Image as ImageIcon, AlignLeft, AlignCenter, AlignRight, Type, MessageSquareCode } from 'lucide-react'; + Copy, + Download, + Share2, + Image as ImageIcon, + AlignLeft, + AlignCenter, + AlignRight, + MessageSquareCode, + Type, +} from 'lucide-react'; import { cn } from '@/lib/utils/cn'; import { toast } from 'sonner'; @@ -34,12 +27,12 @@ export type CommentStyle = 'none' | '//' | '#' | '--' | ';' | '/* */' | '', label: ' HTML' }, + { value: '//', label: '// C / JS / Go' }, + { value: '#', label: '# Python / Shell' }, + { value: '--', label: '-- SQL / Lua' }, + { value: ';', label: '; Lisp / ASM' }, + { value: '/* */', label: '/* Block */' }, + { value: '', label: '' }, { value: '"""', label: '""" Docstring' }, ]; @@ -51,9 +44,9 @@ function applyCommentStyle(text: string, style: CommentStyle): string { case '#': case '--': case ';': - return lines.map(line => `${style} ${line}`).join('\n'); + return lines.map((l) => `${style} ${l}`).join('\n'); case '/* */': - return ['/*', ...lines.map(line => ` * ${line}`), ' */'].join('\n'); + return ['/*', ...lines.map((l) => ` * ${l}`), ' */'].join('\n'); case '': return [''].join('\n'); case '"""': @@ -73,14 +66,39 @@ export interface FontPreviewProps { } type TextAlign = 'left' | 'center' | 'right'; +type FontSize = 'xs' | 'sm' | 'base'; -export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare, onCommentedTextChange, className }: FontPreviewProps) { - const previewRef = React.useRef(null); +const ALIGN_OPTS: { value: TextAlign; icon: React.ElementType; label: string }[] = [ + { 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(null); const [textAlign, setTextAlign] = React.useState('left'); - const [fontSize, setFontSize] = React.useState<'xs' | 'sm' | 'base'>('sm'); + const [fontSize, setFontSize] = React.useState('sm'); const [commentStyle, setCommentStyle] = React.useState('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 charCount = commentedText ? commentedText.length : 0; @@ -89,183 +107,181 @@ export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare }, [commentedText, onCommentedTextChange]); const handleExportPNG = async () => { - if (!previewRef.current || !text) return; - + if (!terminalRef.current || !text) return; try { - const dataUrl = await toPng(previewRef.current, { - backgroundColor: getComputedStyle(previewRef.current).backgroundColor, + const dataUrl = await toPng(terminalRef.current, { + backgroundColor: '#06060e', 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); + } catch { 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 ( - - +
+ + {/* ── Header: label + font tag + export actions ─────────── */} +
- Preview + + Preview + {font && ( - + {font} - + )}
-
+
{onCopy && ( - - - - - Copy to clipboard - + )} {onShare && ( - - - - - Copy shareable URL - + )} - - - - - Export as PNG - + {onDownload && ( - - - - - Download as text file - + )}
- - - {/* Controls */} -
- v && setTextAlign(v as TextAlign)} - variant="outline" - size="sm" - disabled={commentStyle !== 'none'} - > - - - - - - - - - - +
- v && setFontSize(v as 'xs' | 'sm' | 'base')} - variant="outline" - size="sm" - > - - xs - - - sm - - - md - - + {/* ── Controls: alignment · size · comment style ─────────── */} +
+ {/* Alignment */} +
+ {ALIGN_OPTS.map(({ value, icon: Icon, label }) => ( + + ))} +
- + {/* Font size */} +
+ {SIZE_OPTS.map(({ value, label }) => ( + + ))} +
- {!isLoading && text && ( -
- {lineCount} lines - {charCount} chars -
+ {/* Comment style */} + + + {/* Stats */} + {!isLoading && text && ( + + {lineCount}L · {charCount}C + + )} +
+ + {/* ── Terminal window ────────────────────────────────────── */} +
+ {/* Terminal chrome */} +
+
+
+
+ {font && ( + + {font} + )}
+ {/* Content */}
{isLoading ? ( -
- - - - - - +
+ {[0.7, 1, 0.85, 0.55, 1, 0.9, 0.75].map((w, i) => ( +
+ ))}
) : text ? ( -
+            
               {commentedText}
             
) : ( - - - - - - Start typing to see your ASCII art - Enter text in the input field above to generate ASCII art with the selected font - - +
+ +

+ Start typing to see your ASCII art +

+
)}
- - +
+ +
); } diff --git a/components/ascii/FontSelector.tsx b/components/ascii/FontSelector.tsx index cab633a..1639082 100644 --- a/components/ascii/FontSelector.tsx +++ b/components/ascii/FontSelector.tsx @@ -2,19 +2,8 @@ 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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import type { ASCIIFont } from '@/types/ascii'; import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites'; @@ -28,62 +17,52 @@ export interface FontSelectorProps { 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({ fonts, selectedFont, onSelectFont, onRandomFont, - className + className, }: FontSelectorProps) { const [searchQuery, setSearchQuery] = React.useState(''); const [filter, setFilter] = React.useState('all'); const [favorites, setFavorites] = React.useState([]); const [recentFonts, setRecentFonts] = React.useState([]); - const searchInputRef = React.useRef(null); + const selectedRef = React.useRef(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]); + // Keep selected item in view when font changes externally (e.g. random) + React.useEffect(() => { + selectedRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, [selectedFont]); + + const fuse = React.useMemo( + () => new Fuse(fonts, { keys: ['name', 'fileName'], threshold: 0.3, includeScore: true }), + [fonts] + ); const filteredFonts = React.useMemo(() => { - let fontsToFilter = fonts; - - // Apply category filter + let base = fonts; if (filter === 'favorites') { - fontsToFilter = fonts.filter(f => favorites.includes(f.name)); + base = 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); - }); + base = [...fonts.filter((f) => recentFonts.includes(f.name))].sort( + (a, b) => 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; + if (!searchQuery) return base; + const hits = fuse.search(searchQuery).map((r) => r.item); + return filter === 'all' ? hits : hits.filter((f) => base.includes(f)); }, [fonts, searchQuery, fuse, filter, favorites, recentFonts]); const handleToggleFavorite = (fontName: string, e: React.MouseEvent) => { @@ -92,134 +71,140 @@ export function FontSelector({ setFavorites(getFavorites()); }; - return ( - - - Fonts - {onRandomFont && ( - - )} - - - setFilter(v as FilterType)} - className="mb-3 shrink-0" - > - - - - All - - - - Fav - - - - Recent - - - + const emptyMessage = + filter === 'favorites' + ? 'No favorites yet — click ♥ to save' + : filter === 'recent' + ? 'No recent fonts' + : searchQuery + ? 'No fonts match your search' + : 'Loading fonts…'; - {/* Search Input */} -
- - setSearchQuery(e.target.value)} - className="pl-8 pr-8 h-8 text-sm" - /> - {searchQuery && ( + return ( +
+ + {/* ── Header ────────────────────────────────────────────── */} +
+ + Fonts + +
+ + {fonts.length} + + {onRandomFont && ( )}
+
- {/* Font List */} -
- {filteredFonts.length === 0 ? ( - - - - {filter === 'favorites' ? : (filter === 'recent' ? : )} - - { - filter === 'favorites' - ? 'No favorite fonts yet' - : filter === 'recent' - ? 'No recent fonts' - : 'No fonts found' - } - - { - 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...' - } - - - - ) : ( - filteredFonts.map((font) => ( + {/* ── Filter tabs ───────────────────────────────────────── */} +
+ {FILTERS.map(({ value, icon: Icon, label }) => ( + + ))} +
+ + {/* ── Search ────────────────────────────────────────────── */} +
+ + setSearchQuery(e.target.value)} + 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" + /> + {searchQuery && ( + + )} +
+ + {/* ── Font list ─────────────────────────────────────────── */} +
+ {filteredFonts.length === 0 ? ( +
+

{emptyMessage}

+
+ ) : ( + filteredFonts.map((font) => { + const isSelected = selectedFont === font.name; + const fav = isFavorite(font.name); + return (
- )) - )} -
+ ); + }) + )} +
- {/* Stats */} -
+ {/* ── Footer ────────────────────────────────────────────── */} +
+ {filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''} - {filter === 'favorites' && ` · ${favorites.length} favorites`} - {filter === 'recent' && ` · ${recentFonts.length} recent`} -
- - + + {filter === 'favorites' && ( + {favorites.length} saved + )} + {filter === 'recent' && ( + {recentFonts.length} recent + )} +
+ +
); } diff --git a/components/ascii/TextInput.tsx b/components/ascii/TextInput.tsx index e58363e..ed7320c 100644 --- a/components/ascii/TextInput.tsx +++ b/components/ascii/TextInput.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; -import { Textarea } from '@/components/ui/textarea'; export interface TextInputProps { value: string; @@ -14,14 +13,17 @@ export interface TextInputProps { export function TextInput({ value, onChange, placeholder, className }: TextInputProps) { return (
-