2026-02-22 21:35:53 +01:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import * as React from 'react';
|
|
|
|
|
import { TextInput } from './TextInput';
|
|
|
|
|
import { FontPreview } from './FontPreview';
|
|
|
|
|
import { FontSelector } from './FontSelector';
|
2026-02-26 12:31:10 +01:00
|
|
|
import { textToAscii } from '@/lib/ascii/asciiService';
|
|
|
|
|
import { getFontList } from '@/lib/ascii/fontLoader';
|
2026-02-22 21:35:53 +01:00
|
|
|
import { debounce } from '@/lib/utils/debounce';
|
|
|
|
|
import { addRecentFont } from '@/lib/storage/favorites';
|
|
|
|
|
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
|
2026-02-23 02:04:46 +01:00
|
|
|
import { toast } from 'sonner';
|
2026-02-26 12:31:10 +01:00
|
|
|
import type { ASCIIFont } from '@/types/ascii';
|
2026-03-01 07:46:21 +01:00
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
|
|
|
|
|
type Tab = 'editor' | 'preview';
|
2026-02-22 21:35:53 +01:00
|
|
|
|
2026-02-26 12:31:10 +01:00
|
|
|
export function ASCIIConverter() {
|
|
|
|
|
const [text, setText] = React.useState('ASCII');
|
2026-02-22 21:35:53 +01:00
|
|
|
const [selectedFont, setSelectedFont] = React.useState('Standard');
|
|
|
|
|
const [asciiArt, setAsciiArt] = React.useState('');
|
2026-02-26 12:31:10 +01:00
|
|
|
const [fonts, setFonts] = React.useState<ASCIIFont[]>([]);
|
2026-02-22 21:35:53 +01:00
|
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
2026-03-01 07:46:21 +01:00
|
|
|
const [tab, setTab] = React.useState<Tab>('editor');
|
2026-02-27 19:11:25 +01:00
|
|
|
const commentedTextRef = React.useRef('');
|
2026-02-22 21:35:53 +01:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
getFontList().then(setFonts);
|
|
|
|
|
const urlState = decodeFromUrl();
|
|
|
|
|
if (urlState) {
|
|
|
|
|
if (urlState.text) setText(urlState.text);
|
|
|
|
|
if (urlState.font) setSelectedFont(urlState.font);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-23 02:31:49 +01:00
|
|
|
const generateAsciiArt = React.useMemo(
|
2026-03-01 07:46:21 +01:00
|
|
|
() =>
|
|
|
|
|
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),
|
2026-02-22 21:35:53 +01:00
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
generateAsciiArt(text, selectedFont);
|
2026-03-01 07:46:21 +01:00
|
|
|
if (selectedFont) addRecentFont(selectedFont);
|
2026-02-22 21:35:53 +01:00
|
|
|
updateUrl(text, selectedFont);
|
|
|
|
|
}, [text, selectedFont, generateAsciiArt]);
|
|
|
|
|
|
|
|
|
|
const handleCopy = async () => {
|
|
|
|
|
if (!asciiArt) return;
|
|
|
|
|
try {
|
2026-02-27 19:11:25 +01:00
|
|
|
await navigator.clipboard.writeText(commentedTextRef.current || asciiArt);
|
2026-02-23 02:04:46 +01:00
|
|
|
toast.success('Copied to clipboard!');
|
2026-03-01 07:46:21 +01:00
|
|
|
} catch {
|
2026-02-23 02:04:46 +01:00
|
|
|
toast.error('Failed to copy');
|
2026-02-22 21:35:53 +01:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDownload = () => {
|
|
|
|
|
if (!asciiArt) return;
|
2026-02-27 19:11:25 +01:00
|
|
|
const blob = new Blob([commentedTextRef.current || asciiArt], { type: 'text/plain' });
|
2026-02-22 21:35:53 +01:00
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.href = url;
|
2026-02-26 12:31:10 +01:00
|
|
|
a.download = `ascii-${selectedFont}-${Date.now()}.txt`;
|
2026-02-22 21:35:53 +01:00
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleShare = async () => {
|
|
|
|
|
try {
|
2026-03-01 07:46:21 +01:00
|
|
|
await navigator.clipboard.writeText(getShareableUrl(text, selectedFont));
|
2026-02-23 02:04:46 +01:00
|
|
|
toast.success('Shareable URL copied!');
|
2026-03-01 07:46:21 +01:00
|
|
|
} catch {
|
2026-02-23 02:04:46 +01:00
|
|
|
toast.error('Failed to copy URL');
|
2026-02-22 21:35:53 +01:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRandomFont = () => {
|
2026-03-01 07:46:21 +01:00
|
|
|
if (!fonts.length) return;
|
|
|
|
|
const font = fonts[Math.floor(Math.random() * fonts.length)];
|
|
|
|
|
setSelectedFont(font.name);
|
|
|
|
|
toast.info(`Font: ${font.name}`);
|
2026-02-22 21:35:53 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-01 07:46:21 +01:00
|
|
|
<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"
|
2026-03-01 11:53:49 +01:00
|
|
|
style={{ height: 'calc(100svh - 220px)' }}
|
2026-03-01 07:46:21 +01:00
|
|
|
>
|
|
|
|
|
{/* 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>
|
2026-02-25 21:32:05 +01:00
|
|
|
<TextInput
|
|
|
|
|
value={text}
|
|
|
|
|
onChange={setText}
|
2026-03-01 07:46:21 +01:00
|
|
|
placeholder="Type your text here…"
|
2026-02-25 21:32:05 +01:00
|
|
|
/>
|
2026-03-01 07:46:21 +01:00
|
|
|
</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>
|
2026-02-22 21:35:53 +01:00
|
|
|
|
2026-03-01 07:46:21 +01:00
|
|
|
{/* 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; },
|
|
|
|
|
[]
|
|
|
|
|
)}
|
2026-02-23 02:04:46 +01:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-22 21:35:53 +01:00
|
|
|
</div>
|
2026-03-01 07:46:21 +01:00
|
|
|
|
2026-02-22 21:35:53 +01:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|