feat: add QR code generator tool
Add a sixth tool with live SVG preview, customizable foreground/background colors, error correction level, margin control, and export as PNG (256–2048px) or SVG. URL params enable shareable state. All processing runs client-side via the qrcode package. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
145
components/qrcode/QRCodeGenerator.tsx
Normal file
145
components/qrcode/QRCodeGenerator.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
34
components/qrcode/QRInput.tsx
Normal file
34
components/qrcode/QRInput.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface QRInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const MAX_LENGTH = 2048;
|
||||
|
||||
export function QRInput({ value, onChange }: QRInputProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Text</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Enter text or URL..."
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={3}
|
||||
className="resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground text-right">
|
||||
{value.length} / {MAX_LENGTH}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
137
components/qrcode/QROptions.tsx
Normal file
137
components/qrcode/QROptions.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ErrorCorrectionLevel } from '@/types/qrcode';
|
||||
|
||||
interface QROptionsProps {
|
||||
errorCorrection: ErrorCorrectionLevel;
|
||||
foregroundColor: string;
|
||||
backgroundColor: string;
|
||||
margin: number;
|
||||
onErrorCorrectionChange: (ec: ErrorCorrectionLevel) => void;
|
||||
onForegroundColorChange: (color: string) => void;
|
||||
onBackgroundColorChange: (color: string) => void;
|
||||
onMarginChange: (margin: number) => void;
|
||||
}
|
||||
|
||||
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string }[] = [
|
||||
{ value: 'L', label: 'Low (7%)' },
|
||||
{ value: 'M', label: 'Medium (15%)' },
|
||||
{ value: 'Q', label: 'Quartile (25%)' },
|
||||
{ value: 'H', label: 'High (30%)' },
|
||||
];
|
||||
|
||||
export function QROptions({
|
||||
errorCorrection,
|
||||
foregroundColor,
|
||||
backgroundColor,
|
||||
margin,
|
||||
onErrorCorrectionChange,
|
||||
onForegroundColorChange,
|
||||
onBackgroundColorChange,
|
||||
onMarginChange,
|
||||
}: QROptionsProps) {
|
||||
const isTransparent = backgroundColor === '#00000000';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Options</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Error Correction */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Error Correction</Label>
|
||||
<Select value={errorCorrection} onValueChange={(v) => onErrorCorrectionChange(v as ErrorCorrectionLevel)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EC_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Foreground</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
className="w-9 p-1 h-9 shrink-0"
|
||||
value={foregroundColor}
|
||||
onChange={(e) => onForegroundColorChange(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={foregroundColor}
|
||||
onChange={(e) => onForegroundColorChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Background</Label>
|
||||
<Button
|
||||
variant={isTransparent ? 'default' : 'outline'}
|
||||
size="xs"
|
||||
className="h-5 text-[10px] px-1.5"
|
||||
onClick={() =>
|
||||
onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000')
|
||||
}
|
||||
>
|
||||
Transparent
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
className="w-9 p-1 h-9 shrink-0"
|
||||
disabled={isTransparent}
|
||||
value={backgroundColor}
|
||||
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
disabled={isTransparent}
|
||||
value={backgroundColor}
|
||||
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Margin */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Margin</Label>
|
||||
<span className="text-xs text-muted-foreground">{margin}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[margin]}
|
||||
onValueChange={([v]) => onMarginChange(v)}
|
||||
min={0}
|
||||
max={8}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
132
components/qrcode/QRPreview.tsx
Normal file
132
components/qrcode/QRPreview.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'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 type { ExportSize } from '@/types/qrcode';
|
||||
|
||||
interface QRPreviewProps {
|
||||
svgString: string;
|
||||
isGenerating: boolean;
|
||||
exportSize: ExportSize;
|
||||
onExportSizeChange: (size: ExportSize) => void;
|
||||
onCopyImage: () => void;
|
||||
onShare: () => void;
|
||||
onDownloadPng: () => void;
|
||||
onDownloadSvg: () => void;
|
||||
}
|
||||
|
||||
export function QRPreview({
|
||||
svgString,
|
||||
isGenerating,
|
||||
exportSize,
|
||||
onExportSizeChange,
|
||||
onCopyImage,
|
||||
onShare,
|
||||
onDownloadPng,
|
||||
onDownloadSvg,
|
||||
}: QRPreviewProps) {
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<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>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={onShare} disabled={!svgString}>
|
||||
<Share2 className="h-3 w-3 mr-1" />
|
||||
Share
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy shareable URL</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={onDownloadPng} disabled={!svgString}>
|
||||
<ImageIcon className="h-3 w-3 mr-1" />
|
||||
PNG
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download as PNG</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={String(exportSize)}
|
||||
onValueChange={(v) => v && onExportSizeChange(Number(v) as ExportSize)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="256" className="h-6 px-1.5 min-w-0 text-[10px]">256</ToggleGroupItem>
|
||||
<ToggleGroupItem value="512" className="h-6 px-1.5 min-w-0 text-[10px]">512</ToggleGroupItem>
|
||||
<ToggleGroupItem value="1024" className="h-6 px-1.5 min-w-0 text-[10px]">1k</ToggleGroupItem>
|
||||
<ToggleGroupItem value="2048" className="h-6 px-1.5 min-w-0 text-[10px]">2k</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={onDownloadSvg} disabled={!svgString}>
|
||||
<FileCode className="h-3 w-3 mr-1" />
|
||||
SVG
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download as SVG</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col">
|
||||
<div className="flex-1 min-h-[200px] rounded-lg p-4 flex items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: 'repeating-conic-gradient(hsl(var(--muted)) 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '16px 16px',
|
||||
}}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Skeleton className="h-[200px] w-[200px]" />
|
||||
) : svgString ? (
|
||||
<div
|
||||
className="w-full max-w-[400px] aspect-square [&>svg]:w-full [&>svg]:h-full"
|
||||
dangerouslySetInnerHTML={{ __html: svgString }}
|
||||
/>
|
||||
) : (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<QrCode />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Enter text to generate a QR code</EmptyTitle>
|
||||
<EmptyDescription>Type text or a URL in the input field above</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user