refactor: align QR code tool with Calculate/Media blueprint

- QRCodeGenerator: lg:grid-cols-5 layout (2/5 options, 3/5 preview);
  full viewport height; mobile Configure|Preview glass pill tabs
- QRInput: remove shadcn Textarea/Card; native <textarea> in glass panel
  section; character counter in monospace
- QROptions: remove shadcn Card/Label/Input/Button/Select; EC level as
  4 pill buttons with recovery % label; native color inputs + pickers;
  transparent toggle as small pill; keep shadcn Slider for margin
- QRPreview: remove shadcn Card/Button/Skeleton/ToggleGroup/Tooltip/Empty;
  glass card fills full height; PNG button with inline size pill group
  (256/512/1k/2k); empty state and pulse skeleton match other tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 08:37:39 +01:00
parent 7da20c37c1
commit 50cf5823f9
4 changed files with 275 additions and 267 deletions

View File

@@ -9,8 +9,11 @@ import { decodeQRFromUrl, updateQRUrl, getQRShareableUrl } from '@/lib/qrcode/ur
import { downloadBlob } from '@/lib/media/utils/fileUtils'; import { downloadBlob } from '@/lib/media/utils/fileUtils';
import { debounce } from '@/lib/utils/debounce'; import { debounce } from '@/lib/utils/debounce';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn';
import type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode'; import type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode';
type MobileTab = 'configure' | 'preview';
export function QRCodeGenerator() { export function QRCodeGenerator() {
const [text, setText] = React.useState('https://kit.pivoine.art'); const [text, setText] = React.useState('https://kit.pivoine.art');
const [errorCorrection, setErrorCorrection] = React.useState<ErrorCorrectionLevel>('M'); const [errorCorrection, setErrorCorrection] = React.useState<ErrorCorrectionLevel>('M');
@@ -20,6 +23,7 @@ export function QRCodeGenerator() {
const [exportSize, setExportSize] = React.useState<ExportSize>(512); const [exportSize, setExportSize] = React.useState<ExportSize>(512);
const [svgString, setSvgString] = React.useState(''); const [svgString, setSvgString] = React.useState('');
const [isGenerating, setIsGenerating] = React.useState(false); const [isGenerating, setIsGenerating] = React.useState(false);
const [mobileTab, setMobileTab] = React.useState<MobileTab>('configure');
// Load state from URL on mount // Load state from URL on mount
React.useEffect(() => { React.useEffect(() => {
@@ -37,11 +41,7 @@ export function QRCodeGenerator() {
const generate = React.useMemo( const generate = React.useMemo(
() => () =>
debounce(async (t: string, ec: ErrorCorrectionLevel, fg: string, bg: string, m: number) => { debounce(async (t: string, ec: ErrorCorrectionLevel, fg: string, bg: string, m: number) => {
if (!t) { if (!t) { setSvgString(''); setIsGenerating(false); return; }
setSvgString('');
setIsGenerating(false);
return;
}
setIsGenerating(true); setIsGenerating(true);
try { try {
const svg = await generateSvg(t, ec, fg, bg, m); const svg = await generateSvg(t, ec, fg, bg, m);
@@ -57,13 +57,11 @@ export function QRCodeGenerator() {
[], [],
); );
// Regenerate on changes
React.useEffect(() => { React.useEffect(() => {
generate(text, errorCorrection, foregroundColor, backgroundColor, margin); generate(text, errorCorrection, foregroundColor, backgroundColor, margin);
updateQRUrl(text, errorCorrection, foregroundColor, backgroundColor, margin); updateQRUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
}, [text, errorCorrection, foregroundColor, backgroundColor, margin, generate]); }, [text, errorCorrection, foregroundColor, backgroundColor, margin, generate]);
// Export: PNG download
const handleDownloadPng = async () => { const handleDownloadPng = async () => {
if (!text) return; if (!text) return;
try { try {
@@ -71,50 +69,67 @@ export function QRCodeGenerator() {
const res = await fetch(dataUrl); const res = await fetch(dataUrl);
const blob = await res.blob(); const blob = await res.blob();
downloadBlob(blob, `qrcode-${Date.now()}.png`); downloadBlob(blob, `qrcode-${Date.now()}.png`);
} catch { } catch { toast.error('Failed to export PNG'); }
toast.error('Failed to export PNG');
}
}; };
// Export: SVG download
const handleDownloadSvg = () => { const handleDownloadSvg = () => {
if (!svgString) return; if (!svgString) return;
const blob = new Blob([svgString], { type: 'image/svg+xml' }); const blob = new Blob([svgString], { type: 'image/svg+xml' });
downloadBlob(blob, `qrcode-${Date.now()}.svg`); downloadBlob(blob, `qrcode-${Date.now()}.svg`);
}; };
// Copy image to clipboard
const handleCopyImage = async () => { const handleCopyImage = async () => {
if (!text) return; if (!text) return;
try { try {
const dataUrl = await generateDataUrl(text, errorCorrection, foregroundColor, backgroundColor, margin, exportSize); const dataUrl = await generateDataUrl(text, errorCorrection, foregroundColor, backgroundColor, margin, exportSize);
const res = await fetch(dataUrl); const res = await fetch(dataUrl);
const blob = await res.blob(); const blob = await res.blob();
await navigator.clipboard.write([ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
new ClipboardItem({ 'image/png': blob }),
]);
toast.success('Image copied to clipboard!'); toast.success('Image copied to clipboard!');
} catch { } catch { toast.error('Failed to copy image'); }
toast.error('Failed to copy image');
}
}; };
// Share URL
const handleShare = async () => { const handleShare = async () => {
const shareUrl = getQRShareableUrl(text, errorCorrection, foregroundColor, backgroundColor, margin); const shareUrl = getQRShareableUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
try { try {
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(shareUrl);
toast.success('Shareable URL copied!'); toast.success('Shareable URL copied!');
} catch { } catch { toast.error('Failed to copy URL'); }
toast.error('Failed to copy URL');
}
}; };
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 Options */}
<div className="lg:col-span-1 space-y-6 overflow-y-auto custom-scrollbar"> {/* ── Mobile tab switcher ─────────────────────────────── */}
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
{(['configure', 'preview'] as MobileTab[]).map((t) => (
<button
key={t}
onClick={() => setMobileTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
mobileTab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'configure' ? 'Configure' : '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: Input + Options */}
<div className={cn('lg:col-span-2 flex flex-col overflow-hidden', mobileTab !== 'configure' && 'hidden lg:flex')}>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5 space-y-5">
<QRInput value={text} onChange={setText} /> <QRInput value={text} onChange={setText} />
<div className="border-t border-border/25" />
<QROptions <QROptions
errorCorrection={errorCorrection} errorCorrection={errorCorrection}
foregroundColor={foregroundColor} foregroundColor={foregroundColor}
@@ -126,9 +141,11 @@ export function QRCodeGenerator() {
onMarginChange={setMargin} onMarginChange={setMargin}
/> />
</div> </div>
</div>
</div>
{/* Right Column - Preview */} {/* Right: Preview */}
<div className="lg:col-span-2 h-full"> <div className={cn('lg:col-span-3 flex flex-col overflow-hidden', mobileTab !== 'preview' && 'hidden lg:flex')}>
<QRPreview <QRPreview
svgString={svgString} svgString={svgString}
isGenerating={isGenerating} isGenerating={isGenerating}
@@ -141,5 +158,6 @@ export function QRCodeGenerator() {
/> />
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -1,34 +1,29 @@
'use client'; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; const MAX_LENGTH = 2048;
import { Textarea } from '@/components/ui/textarea';
interface QRInputProps { interface QRInputProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
const MAX_LENGTH = 2048;
export function QRInput({ value, onChange }: QRInputProps) { export function QRInput({ value, onChange }: QRInputProps) {
return ( return (
<Card> <div>
<CardHeader> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
<CardTitle>Text</CardTitle> Content
</CardHeader> </span>
<CardContent className="space-y-2"> <textarea
<Textarea
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder="Enter text or URL..." placeholder="Enter text or URL"
maxLength={MAX_LENGTH} maxLength={MAX_LENGTH}
rows={3} rows={4}
className="resize-none font-mono text-sm" className="w-full bg-transparent border border-border/40 rounded-lg px-3 py-2.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30 resize-none"
/> />
<div className="text-[10px] text-muted-foreground text-right"> <div className="text-[9px] text-muted-foreground/30 font-mono text-right mt-1 tabular-nums">
{value.length} / {MAX_LENGTH} {value.length} / {MAX_LENGTH}
</div> </div>
</CardContent> </div>
</Card>
); );
} }

View File

@@ -1,17 +1,7 @@
'use client'; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { import { cn } from '@/lib/utils/cn';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ErrorCorrectionLevel } from '@/types/qrcode'; import type { ErrorCorrectionLevel } from '@/types/qrcode';
interface QROptionsProps { interface QROptionsProps {
@@ -25,13 +15,16 @@ interface QROptionsProps {
onMarginChange: (margin: number) => void; onMarginChange: (margin: number) => void;
} }
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string }[] = [ const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string; desc: string }[] = [
{ value: 'L', label: 'Low (7%)' }, { value: 'L', label: 'L', desc: '7%' },
{ value: 'M', label: 'Medium (15%)' }, { value: 'M', label: 'M', desc: '15%' },
{ value: 'Q', label: 'Quartile (25%)' }, { value: 'Q', label: 'Q', desc: '25%' },
{ value: 'H', label: 'High (30%)' }, { value: 'H', label: 'H', desc: '30%' },
]; ];
const inputCls =
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
export function QROptions({ export function QROptions({
errorCorrection, errorCorrection,
foregroundColor, foregroundColor,
@@ -45,83 +38,102 @@ export function QROptions({
const isTransparent = backgroundColor === '#00000000'; const isTransparent = backgroundColor === '#00000000';
return ( return (
<Card> <div className="space-y-5">
<CardHeader>
<CardTitle>Options</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Error Correction */} {/* Error Correction */}
<div className="space-y-1.5"> <div>
<Label className="text-xs">Error Correction</Label> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
<Select value={errorCorrection} onValueChange={(v) => onErrorCorrectionChange(v as ErrorCorrectionLevel)}> Error Correction
<SelectTrigger className="w-full"> </span>
<SelectValue /> <div className="flex gap-1.5">
</SelectTrigger>
<SelectContent>
{EC_OPTIONS.map((opt) => ( {EC_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <button
{opt.label} key={opt.value}
</SelectItem> onClick={() => onErrorCorrectionChange(opt.value)}
className={cn(
'flex-1 flex flex-col items-center py-2 rounded-lg border text-xs font-mono transition-all',
errorCorrection === opt.value
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
)}
>
<span className="font-semibold">{opt.label}</span>
<span className="text-[9px] opacity-50 mt-0.5">{opt.desc}</span>
</button>
))} ))}
</SelectContent> </div>
</Select>
</div> </div>
{/* Colors */} {/* Colors */}
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1.5"> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
<Label className="text-xs">Foreground</Label> Colors
<div className="flex gap-2"> </span>
<Input
{/* Foreground */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Foreground</label>
<div className="flex gap-1.5">
<input
type="color" type="color"
className="w-9 p-1 h-9 shrink-0"
value={foregroundColor} value={foregroundColor}
onChange={(e) => onForegroundColorChange(e.target.value)} onChange={(e) => onForegroundColorChange(e.target.value)}
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
/> />
<Input <input
className="font-mono text-xs" type="text"
value={foregroundColor} value={foregroundColor}
onChange={(e) => onForegroundColorChange(e.target.value)} onChange={(e) => onForegroundColorChange(e.target.value)}
className={inputCls}
/> />
</div> </div>
</div> </div>
<div className="space-y-1.5">
<div className="flex items-center justify-between"> {/* Background */}
<Label className="text-xs">Background</Label> <div>
<Button <div className="flex items-center justify-between mb-1.5">
variant={isTransparent ? 'default' : 'outline'} <label className="text-[9px] text-muted-foreground/50 font-mono">Background</label>
size="xs" <button
className="h-5 text-[10px] px-1.5" onClick={() => onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000')}
onClick={() => className={cn(
onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000') 'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
} isTransparent
? 'border-primary/40 text-primary bg-primary/10'
: 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
)}
> >
Transparent Transparent
</Button> </button>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-1.5">
<Input <input
type="color" type="color"
className="w-9 p-1 h-9 shrink-0"
disabled={isTransparent} disabled={isTransparent}
value={backgroundColor} value={isTransparent ? '#ffffff' : backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)} onChange={(e) => onBackgroundColorChange(e.target.value)}
className={cn(
'w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5 transition-opacity',
isTransparent && 'opacity-30 cursor-not-allowed'
)}
/> />
<Input <input
className="font-mono text-xs" type="text"
disabled={isTransparent} disabled={isTransparent}
value={backgroundColor} value={isTransparent ? 'transparent' : backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)} onChange={(e) => onBackgroundColorChange(e.target.value)}
className={cn(inputCls, isTransparent && 'opacity-30')}
/> />
</div> </div>
</div> </div>
</div> </div>
{/* Margin */} {/* Margin */}
<div className="space-y-1.5"> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-2">
<Label className="text-xs">Margin</Label> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
<span className="text-xs text-muted-foreground">{margin}</span> Margin
</span>
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{margin}</span>
</div> </div>
<Slider <Slider
value={[margin]} value={[margin]}
@@ -131,7 +143,6 @@ export function QROptions({
step={1} step={1}
/> />
</div> </div>
</CardContent> </div>
</Card>
); );
} }

View File

@@ -1,22 +1,7 @@
'use client'; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Copy, Share2, Image as ImageIcon, FileCode, QrCode } from 'lucide-react'; import { Copy, Share2, Image as ImageIcon, FileCode, QrCode } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { ExportSize } from '@/types/qrcode'; import type { ExportSize } from '@/types/qrcode';
interface QRPreviewProps { interface QRPreviewProps {
@@ -30,6 +15,16 @@ interface QRPreviewProps {
onDownloadSvg: () => void; onDownloadSvg: () => void;
} }
const EXPORT_SIZES: { value: ExportSize; label: string }[] = [
{ value: 256, label: '256' },
{ value: 512, label: '512' },
{ value: 1024, label: '1k' },
{ value: 2048, label: '2k' },
];
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 disabled:opacity-40 disabled:cursor-not-allowed';
export function QRPreview({ export function QRPreview({
svgString, svgString,
isGenerating, isGenerating,
@@ -41,92 +36,81 @@ export function QRPreview({
onDownloadSvg, onDownloadSvg,
}: QRPreviewProps) { }: QRPreviewProps) {
return ( return (
<Card className="h-full flex flex-col"> <div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<CardTitle>Preview</CardTitle>
<div className="flex items-center gap-1.5 flex-wrap">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="xs" onClick={onCopyImage} disabled={!svgString}>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
</TooltipTrigger>
<TooltipContent>Copy image to clipboard</TooltipContent>
</Tooltip>
<Tooltip> {/* Action bar */}
<TooltipTrigger asChild> <div className="flex items-center gap-1.5 mb-4 shrink-0 flex-wrap">
<Button variant="outline" size="xs" onClick={onShare} disabled={!svgString}> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest mr-auto">
<Share2 className="h-3 w-3 mr-1" /> Preview
Share </span>
</Button>
</TooltipTrigger>
<TooltipContent>Copy shareable URL</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1"> <button onClick={onCopyImage} disabled={!svgString} className={actionBtn}>
<Tooltip> <Copy className="w-3 h-3" />Copy
<TooltipTrigger asChild> </button>
<Button variant="outline" size="xs" onClick={onDownloadPng} disabled={!svgString}>
<ImageIcon className="h-3 w-3 mr-1" /> <button onClick={onShare} disabled={!svgString} className={actionBtn}>
PNG <Share2 className="w-3 h-3" />Share
</Button> </button>
</TooltipTrigger>
<TooltipContent>Download as PNG</TooltipContent> {/* PNG + inline size selector */}
</Tooltip> <div className="flex items-center glass rounded-md border border-border/30">
<ToggleGroup <button
type="single" onClick={onDownloadPng}
value={String(exportSize)} disabled={!svgString}
onValueChange={(v) => v && onExportSizeChange(Number(v) as ExportSize)} className="flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-xs text-muted-foreground hover:text-primary transition-all disabled:opacity-40 disabled:cursor-not-allowed border-r border-border/20"
variant="outline"
size="sm"
> >
<ToggleGroupItem value="256" className="h-6 px-1.5 min-w-0 text-[10px]">256</ToggleGroupItem> <ImageIcon className="w-3 h-3" />PNG
<ToggleGroupItem value="512" className="h-6 px-1.5 min-w-0 text-[10px]">512</ToggleGroupItem> </button>
<ToggleGroupItem value="1024" className="h-6 px-1.5 min-w-0 text-[10px]">1k</ToggleGroupItem> <div className="flex items-center px-1 gap-0.5">
<ToggleGroupItem value="2048" className="h-6 px-1.5 min-w-0 text-[10px]">2k</ToggleGroupItem> {EXPORT_SIZES.map(({ value, label }) => (
</ToggleGroup> <button
key={value}
onClick={() => onExportSizeChange(value)}
className={cn(
'text-[9px] font-mono px-1.5 py-0.5 rounded transition-all',
exportSize === value
? 'text-primary bg-primary/10'
: 'text-muted-foreground/40 hover:text-muted-foreground'
)}
>
{label}
</button>
))}
</div>
</div> </div>
<Tooltip> <button onClick={onDownloadSvg} disabled={!svgString} className={actionBtn}>
<TooltipTrigger asChild> <FileCode className="w-3 h-3" />SVG
<Button variant="outline" size="xs" onClick={onDownloadSvg} disabled={!svgString}> </button>
<FileCode className="h-3 w-3 mr-1" />
SVG
</Button>
</TooltipTrigger>
<TooltipContent>Download as SVG</TooltipContent>
</Tooltip>
</div> </div>
</CardHeader>
<CardContent className="flex-1 flex flex-col"> {/* QR canvas */}
<div className="flex-1 min-h-[200px] rounded-lg p-4 flex items-center justify-center" <div
className="flex-1 min-h-0 rounded-xl flex items-center justify-center"
style={{ style={{
backgroundImage: 'repeating-conic-gradient(hsl(var(--muted)) 0% 25%, transparent 0% 50%)', backgroundImage: 'repeating-conic-gradient(rgba(255,255,255,0.025) 0% 25%, transparent 0% 50%)',
backgroundSize: '16px 16px', backgroundSize: '16px 16px',
}} }}
> >
{isGenerating ? ( {isGenerating ? (
<Skeleton className="h-[200px] w-[200px]" /> <div className="w-56 h-56 rounded-xl bg-white/5 animate-pulse" />
) : svgString ? ( ) : svgString ? (
<div <div
className="w-full max-w-[400px] aspect-square [&>svg]:w-full [&>svg]:h-full" className="w-full max-w-sm aspect-square [&>svg]:w-full [&>svg]:h-full p-6"
dangerouslySetInnerHTML={{ __html: svgString }} dangerouslySetInnerHTML={{ __html: svgString }}
/> />
) : ( ) : (
<Empty> <div className="flex flex-col items-center gap-3 text-center">
<EmptyHeader> <div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<EmptyMedia variant="icon"> <QrCode className="w-6 h-6 text-primary/40" />
<QrCode /> </div>
</EmptyMedia> <div>
<EmptyTitle>Enter text to generate a QR code</EmptyTitle> <p className="text-sm font-medium text-foreground/40">No QR code yet</p>
<EmptyDescription>Type text or a URL in the input field above</EmptyDescription> <p className="text-[10px] text-muted-foreground/30 font-mono mt-1">Enter text or a URL to generate</p>
</EmptyHeader> </div>
</Empty> </div>
)} )}
</div> </div>
</CardContent> </div>
</Card>
); );
} }