Files
kit-ui/components/qrcode/QRCodeGenerator.tsx

164 lines
6.6 KiB
TypeScript
Raw Normal View History

'use client';
import * as React from 'react';
import { QRInput } from './QRInput';
import { QRPreview } from './QRPreview';
import { QROptions } from './QROptions';
import { generateSvg, generateDataUrl } from '@/lib/qrcode/qrcodeService';
import { decodeQRFromUrl, updateQRUrl, getQRShareableUrl } from '@/lib/qrcode/urlSharing';
import { downloadBlob } from '@/lib/media/utils/fileUtils';
import { debounce } from '@/lib/utils/debounce';
import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn';
import type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode';
type MobileTab = 'configure' | 'preview';
export function QRCodeGenerator() {
const [text, setText] = React.useState('https://kit.pivoine.art');
const [errorCorrection, setErrorCorrection] = React.useState<ErrorCorrectionLevel>('M');
const [foregroundColor, setForegroundColor] = React.useState('#000000');
const [backgroundColor, setBackgroundColor] = React.useState('#ffffff');
const [margin, setMargin] = React.useState(4);
const [exportSize, setExportSize] = React.useState<ExportSize>(512);
const [svgString, setSvgString] = React.useState('');
const [isGenerating, setIsGenerating] = React.useState(false);
const [mobileTab, setMobileTab] = React.useState<MobileTab>('configure');
// Load state from URL on mount
React.useEffect(() => {
const urlState = decodeQRFromUrl();
if (urlState) {
if (urlState.text !== undefined) setText(urlState.text);
if (urlState.errorCorrection) setErrorCorrection(urlState.errorCorrection);
if (urlState.foregroundColor) setForegroundColor(urlState.foregroundColor);
if (urlState.backgroundColor) setBackgroundColor(urlState.backgroundColor);
if (urlState.margin !== undefined) setMargin(urlState.margin);
}
}, []);
// Debounced generation
const generate = React.useMemo(
() =>
debounce(async (t: string, ec: ErrorCorrectionLevel, fg: string, bg: string, m: number) => {
if (!t) { setSvgString(''); setIsGenerating(false); return; }
setIsGenerating(true);
try {
const svg = await generateSvg(t, ec, fg, bg, m);
setSvgString(svg);
} catch (error) {
console.error('QR generation error:', error);
setSvgString('');
toast.error('Failed to generate QR code. Text may be too long.');
} finally {
setIsGenerating(false);
}
}, 200),
[],
);
React.useEffect(() => {
generate(text, errorCorrection, foregroundColor, backgroundColor, margin);
updateQRUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
}, [text, errorCorrection, foregroundColor, backgroundColor, margin, generate]);
const handleDownloadPng = async () => {
if (!text) return;
try {
const dataUrl = await generateDataUrl(text, errorCorrection, foregroundColor, backgroundColor, margin, exportSize);
const res = await fetch(dataUrl);
const blob = await res.blob();
downloadBlob(blob, `qrcode-${Date.now()}.png`);
} catch { toast.error('Failed to export PNG'); }
};
const handleDownloadSvg = () => {
if (!svgString) return;
const blob = new Blob([svgString], { type: 'image/svg+xml' });
downloadBlob(blob, `qrcode-${Date.now()}.svg`);
};
const handleCopyImage = async () => {
if (!text) return;
try {
const dataUrl = await generateDataUrl(text, errorCorrection, foregroundColor, backgroundColor, margin, exportSize);
const res = await fetch(dataUrl);
const blob = await res.blob();
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
toast.success('Image copied to clipboard!');
} catch { toast.error('Failed to copy image'); }
};
const handleShare = async () => {
const shareUrl = getQRShareableUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
try {
await navigator.clipboard.writeText(shareUrl);
toast.success('Shareable URL copied!');
} catch { toast.error('Failed to copy URL'); }
};
return (
<div className="flex flex-col gap-4">
{/* ── 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"
2026-03-01 12:20:15 +01:00
style={{ height: 'calc(100svh - 180px)' }}
>
{/* 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} />
<div className="border-t border-border/25" />
<QROptions
errorCorrection={errorCorrection}
foregroundColor={foregroundColor}
backgroundColor={backgroundColor}
margin={margin}
onErrorCorrectionChange={setErrorCorrection}
onForegroundColorChange={setForegroundColor}
onBackgroundColorChange={setBackgroundColor}
onMarginChange={setMargin}
/>
</div>
</div>
</div>
{/* Right: Preview */}
<div className={cn('lg:col-span-3 flex flex-col overflow-hidden', mobileTab !== 'preview' && 'hidden lg:flex')}>
<QRPreview
svgString={svgString}
isGenerating={isGenerating}
exportSize={exportSize}
onExportSizeChange={setExportSize}
onCopyImage={handleCopyImage}
onShare={handleShare}
onDownloadPng={handleDownloadPng}
onDownloadSvg={handleDownloadSvg}
/>
</div>
</div>
</div>
);
}