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 { 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">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Text</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
|
|
||||||
|
{/* ── 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
|
<TextInput
|
||||||
value={text}
|
value={text}
|
||||||
onChange={setText}
|
onChange={setText}
|
||||||
placeholder="Type your text here..."
|
placeholder="Type your text here…"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
<FontPreview
|
{/* Right panel: preview */}
|
||||||
text={asciiArt}
|
<div
|
||||||
font={selectedFont}
|
className={cn(
|
||||||
isLoading={isLoading}
|
'lg:col-span-3 flex flex-col overflow-hidden',
|
||||||
onCopy={handleCopy}
|
tab !== 'preview' && 'hidden lg:flex'
|
||||||
onDownload={handleDownload}
|
)}
|
||||||
onShare={handleShare}
|
>
|
||||||
onCommentedTextChange={React.useCallback((t: string) => { commentedTextRef.current = t; }, [])}
|
<FontPreview
|
||||||
/>
|
text={asciiArt}
|
||||||
</div>
|
font={selectedFont}
|
||||||
|
isLoading={isLoading}
|
||||||
{/* Right Column - Font Selector */}
|
onCopy={handleCopy}
|
||||||
<div className="lg:col-span-1 h-[500px] lg:h-auto relative">
|
onDownload={handleDownload}
|
||||||
<div className="lg:absolute lg:inset-0 h-full">
|
onShare={handleShare}
|
||||||
<FontSelector
|
onCommentedTextChange={React.useCallback(
|
||||||
fonts={fonts}
|
(t: string) => { commentedTextRef.current = t; },
|
||||||
selectedFont={selectedFont}
|
[]
|
||||||
onSelectFont={setSelectedFont}
|
)}
|
||||||
onRandomFont={handleRandomFont}
|
|
||||||
className="h-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user