Files
kit-ui/components/ascii/FontPreview.tsx
Sebastian Krüger 6ecdc33933 feat: add cardBtn style for card title row buttons
Smaller variant for buttons that sit next to section labels in card headers
(Preview, Color, Results rows). Applied to QRPreview, FontPreview,
ColorManipulation, and FileConverter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:36:19 +01:00

277 lines
9.5 KiB
TypeScript

'use client';
import * as React from 'react';
import { toPng } from 'html-to-image';
import {
Copy,
Download,
Share2,
Image as ImageIcon,
AlignLeft,
AlignCenter,
AlignRight,
MessageSquareCode,
Type,
} from 'lucide-react';
import { cn, actionBtn, cardBtn } from '@/lib/utils';
import { toast } from 'sonner';
export type CommentStyle = 'none' | '//' | '#' | '--' | ';' | '/* */' | '<!-- -->' | '"""';
const COMMENT_STYLES: { value: CommentStyle; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: '//', label: '// C / JS / Go' },
{ value: '#', label: '# Python / Shell' },
{ value: '--', label: '-- SQL / Lua' },
{ value: ';', label: '; Lisp / ASM' },
{ value: '/* */', label: '/* Block */' },
{ value: '<!-- -->', label: '<!-- HTML -->' },
{ value: '"""', label: '""" Docstring' },
];
function applyCommentStyle(text: string, style: CommentStyle): string {
if (style === 'none' || !text) return text;
const lines = text.split('\n');
switch (style) {
case '//':
case '#':
case '--':
case ';':
return lines.map((l) => `${style} ${l}`).join('\n');
case '/* */':
return ['/*', ...lines.map((l) => ` * ${l}`), ' */'].join('\n');
case '<!-- -->':
return ['<!--', ...lines, '-->'].join('\n');
case '"""':
return ['"""', ...lines, '"""'].join('\n');
}
}
export interface FontPreviewProps {
text: string;
font?: string;
isLoading?: boolean;
onCopy?: () => void;
onDownload?: () => void;
onShare?: () => void;
onCommentedTextChange?: (commentedText: string) => void;
className?: string;
}
type TextAlign = 'left' | 'center' | 'right';
type FontSize = 'xs' | 'sm' | 'base';
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<HTMLDivElement>(null);
const [textAlign, setTextAlign] = React.useState<TextAlign>('left');
const [fontSize, setFontSize] = React.useState<FontSize>('sm');
const [commentStyle, setCommentStyle] = React.useState<CommentStyle>('none');
const commentedText = React.useMemo(
() => applyCommentStyle(text, commentStyle),
[text, commentStyle]
);
const lineCount = commentedText ? commentedText.split('\n').length : 0;
const charCount = commentedText ? commentedText.length : 0;
React.useEffect(() => {
onCommentedTextChange?.(commentedText);
}, [commentedText, onCommentedTextChange]);
const handleExportPNG = async () => {
if (!terminalRef.current || !text) return;
try {
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 {
toast.error('Failed to export PNG');
}
};
return (
<div className={cn('glass rounded-xl p-4 flex flex-col gap-3 flex-1 min-h-0 overflow-hidden', className)}>
{/* ── 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">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Preview
</span>
{font && (
<span className="px-2 py-0.5 rounded-md bg-primary/10 text-primary text-[10px] font-mono border border-primary/20">
{font}
</span>
)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{onCopy && (
<button onClick={onCopy} className={cardBtn}>
<Copy className="w-3 h-3" /> Copy
</button>
)}
{onShare && (
<button onClick={onShare} className={cardBtn}>
<Share2 className="w-3 h-3" /> Share
</button>
)}
<button onClick={handleExportPNG} className={cardBtn}>
<ImageIcon className="w-3 h-3" /> PNG
</button>
{onDownload && (
<button onClick={onDownload} className={cardBtn}>
<Download className="w-3 h-3" /> TXT
</button>
)}
</div>
</div>
{/* ── Controls: alignment · size · comment style ─────────── */}
<div className="flex items-center gap-2 shrink-0 flex-wrap">
{/* Alignment */}
<div className="flex items-center gap-0.5">
{ALIGN_OPTS.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTextAlign(value)}
disabled={commentStyle !== 'none'}
title={label}
className={cn(
'px-2 py-1 h-6 rounded-md transition-all border text-xs',
textAlign === value && commentStyle === 'none'
? 'bg-primary/10 border-primary/30 text-primary'
: 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40',
commentStyle !== 'none' && 'opacity-30 cursor-not-allowed'
)}
>
<Icon className="w-3 h-3" />
</button>
))}
</div>
{/* Font size */}
<div className="flex items-center gap-0.5">
{SIZE_OPTS.map(({ value, label }) => (
<button
key={value}
onClick={() => setFontSize(value)}
className={cn(
'px-2 py-1 text-[10px] font-mono rounded-md transition-all border uppercase',
fontSize === value
? 'bg-primary/10 border-primary/30 text-primary'
: 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40'
)}
>
{label}
</button>
))}
</div>
{/* Comment style */}
<div className="flex items-center gap-1 px-2 py-1.25 glass rounded-md border border-border/30 text-muted-foreground hover:border-primary/30 hover:text-primary transition-colors">
<MessageSquareCode className="w-3 h-3 shrink-0" />
<select
value={commentStyle}
onChange={(e) => setCommentStyle(e.target.value as CommentStyle)}
className="bg-transparent outline-none text-[10px] font-mono cursor-pointer"
>
{COMMENT_STYLES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
{/* 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>
{/* Content */}
<div
className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-white/8 scrollbar-track-transparent"
style={{ textAlign: commentStyle === 'none' ? textAlign : 'left' }}
>
{isLoading ? (
<div className="space-y-2 animate-pulse">
{[0.7, 1, 0.85, 0.55, 1, 0.9, 0.75].map((w, i) => (
<div
key={i}
className="h-3.5 rounded-sm bg-white/5"
style={{ width: `${w * 100}%` }}
/>
))}
</div>
) : text ? (
<pre
className={cn(
'font-mono whitespace-pre text-white/85 leading-snug',
fontSize === 'xs' && 'text-[9px]',
fontSize === 'sm' && 'text-[11px] sm:text-xs',
fontSize === 'base' && 'text-xs sm:text-sm'
)}
>
{commentedText}
</pre>
) : (
<div className="h-full flex flex-col items-center justify-center gap-2 text-center">
<Type className="w-6 h-6 text-white/10" />
<p className="text-xs text-white/20 font-mono">
Start typing to see your ASCII art
</p>
</div>
)}
</div>
</div>
</div>
);
}