146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
|
|
'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 type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode';
|
||
|
|
|
||
|
|
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);
|
||
|
|
|
||
|
|
// 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),
|
||
|
|
[],
|
||
|
|
);
|
||
|
|
|
||
|
|
// Regenerate on changes
|
||
|
|
React.useEffect(() => {
|
||
|
|
generate(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
||
|
|
updateQRUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
||
|
|
}, [text, errorCorrection, foregroundColor, backgroundColor, margin, generate]);
|
||
|
|
|
||
|
|
// Export: PNG download
|
||
|
|
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');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Export: SVG download
|
||
|
|
const handleDownloadSvg = () => {
|
||
|
|
if (!svgString) return;
|
||
|
|
const blob = new Blob([svgString], { type: 'image/svg+xml' });
|
||
|
|
downloadBlob(blob, `qrcode-${Date.now()}.svg`);
|
||
|
|
};
|
||
|
|
|
||
|
|
// Copy image to clipboard
|
||
|
|
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');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Share URL
|
||
|
|
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="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch lg:max-h-[800px]">
|
||
|
|
{/* Left Column - Input and Options */}
|
||
|
|
<div className="lg:col-span-1 space-y-6 overflow-y-auto custom-scrollbar">
|
||
|
|
<QRInput value={text} onChange={setText} />
|
||
|
|
<QROptions
|
||
|
|
errorCorrection={errorCorrection}
|
||
|
|
foregroundColor={foregroundColor}
|
||
|
|
backgroundColor={backgroundColor}
|
||
|
|
margin={margin}
|
||
|
|
onErrorCorrectionChange={setErrorCorrection}
|
||
|
|
onForegroundColorChange={setForegroundColor}
|
||
|
|
onBackgroundColorChange={setBackgroundColor}
|
||
|
|
onMarginChange={setMargin}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Right Column - Preview */}
|
||
|
|
<div className="lg:col-span-2 h-full">
|
||
|
|
<QRPreview
|
||
|
|
svgString={svgString}
|
||
|
|
isGenerating={isGenerating}
|
||
|
|
exportSize={exportSize}
|
||
|
|
onExportSizeChange={setExportSize}
|
||
|
|
onCopyImage={handleCopyImage}
|
||
|
|
onShare={handleShare}
|
||
|
|
onDownloadPng={handleDownloadPng}
|
||
|
|
onDownloadSvg={handleDownloadSvg}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|