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:
2026-02-28 00:58:57 +01:00
parent 695ba434e2
commit f917891a31
13 changed files with 776 additions and 16 deletions

19
app/(app)/qrcode/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import { QRCodeGenerator } from '@/components/qrcode/QRCodeGenerator';
import { AppPage } from '@/components/layout/AppPage';
import { getToolByHref } from '@/lib/tools';
const tool = getToolByHref('/qrcode')!;
export const metadata: Metadata = { title: tool.title };
export default function QRCodePage() {
return (
<AppPage
title={tool.title}
description={tool.description}
>
<QRCodeGenerator />
</AppPage>
);
}

View File

@@ -36,3 +36,15 @@ export const FaviconIcon = (props: React.SVGProps<SVGSVGElement>) => (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
);
export const QRCodeIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1" strokeWidth={2} />
<rect x="14" y="3" width="7" height="7" rx="1" strokeWidth={2} />
<rect x="3" y="14" width="7" height="7" rx="1" strokeWidth={2} />
<rect x="14" y="14" width="3" height="3" strokeWidth={2} />
<rect x="18" y="18" width="3" height="3" strokeWidth={2} />
<line x1="14" y1="18" x2="17" y2="18" strokeWidth={2} />
<line x1="18" y1="14" x2="18" y2="17" strokeWidth={2} />
</svg>
);

View File

@@ -11,7 +11,7 @@ import { addRecentFont } from '@/lib/storage/favorites';
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
import { toast } from 'sonner';
import type { ASCIIFont } from '@/types/ascii';
import { Card, CardContent } from '../ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
export function ASCIIConverter() {
const [text, setText] = React.useState('ASCII');
@@ -121,7 +121,11 @@ export function ASCIIConverter() {
{/* Left Column - Input and Preview */}
<div className="lg:col-span-2 space-y-6 overflow-y-auto custom-scrollbar">
<Card>
<CardHeader>
<CardTitle>Text</CardTitle>
</CardHeader>
<CardContent>
<TextInput
value={text}
onChange={setText}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,39 @@
import QRCode from 'qrcode';
import type { ErrorCorrectionLevel } from '@/types/qrcode';
export async function generateSvg(
text: string,
errorCorrection: ErrorCorrectionLevel,
foregroundColor: string,
backgroundColor: string,
margin: number,
): Promise<string> {
return QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: errorCorrection,
color: {
dark: foregroundColor,
light: backgroundColor,
},
margin,
});
}
export async function generateDataUrl(
text: string,
errorCorrection: ErrorCorrectionLevel,
foregroundColor: string,
backgroundColor: string,
margin: number,
size: number,
): Promise<string> {
return QRCode.toDataURL(text, {
errorCorrectionLevel: errorCorrection,
color: {
dark: foregroundColor,
light: backgroundColor,
},
margin,
width: size,
});
}

85
lib/qrcode/urlSharing.ts Normal file
View File

@@ -0,0 +1,85 @@
'use client';
import type { ErrorCorrectionLevel } from '@/types/qrcode';
export interface QRShareableState {
text?: string;
errorCorrection?: ErrorCorrectionLevel;
foregroundColor?: string;
backgroundColor?: string;
margin?: number;
}
const DEFAULTS = {
errorCorrection: 'M' as ErrorCorrectionLevel,
foregroundColor: '#000000',
backgroundColor: '#ffffff',
margin: 4,
};
export function decodeQRFromUrl(): QRShareableState | null {
if (typeof window === 'undefined') return null;
const params = new URLSearchParams(window.location.search);
const text = params.get('text');
const ec = params.get('ec') as ErrorCorrectionLevel | null;
const fg = params.get('fg');
const bg = params.get('bg');
const margin = params.get('margin');
if (!text && !ec && !fg && !bg && !margin) return null;
return {
text: text || undefined,
errorCorrection: ec || undefined,
foregroundColor: fg ? `#${fg}` : undefined,
backgroundColor: bg ? `#${bg}` : undefined,
margin: margin ? parseInt(margin, 10) : undefined,
};
}
export function updateQRUrl(
text: string,
errorCorrection: ErrorCorrectionLevel,
foregroundColor: string,
backgroundColor: string,
margin: number,
): void {
if (typeof window === 'undefined') return;
const params = new URLSearchParams();
if (text) params.set('text', text);
if (errorCorrection !== DEFAULTS.errorCorrection) params.set('ec', errorCorrection);
if (foregroundColor !== DEFAULTS.foregroundColor) params.set('fg', foregroundColor.replace('#', ''));
if (backgroundColor !== DEFAULTS.backgroundColor) params.set('bg', backgroundColor.replace('#', ''));
if (margin !== DEFAULTS.margin) params.set('margin', String(margin));
const query = params.toString();
const newUrl = query
? `${window.location.pathname}?${query}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
export function getQRShareableUrl(
text: string,
errorCorrection: ErrorCorrectionLevel,
foregroundColor: string,
backgroundColor: string,
margin: number,
): string {
if (typeof window === 'undefined') return '';
const params = new URLSearchParams();
if (text) params.set('text', text);
if (errorCorrection !== DEFAULTS.errorCorrection) params.set('ec', errorCorrection);
if (foregroundColor !== DEFAULTS.foregroundColor) params.set('fg', foregroundColor.replace('#', ''));
if (backgroundColor !== DEFAULTS.backgroundColor) params.set('bg', backgroundColor.replace('#', ''));
if (margin !== DEFAULTS.margin) params.set('margin', String(margin));
const query = params.toString();
return `${window.location.origin}${window.location.pathname}${query ? `?${query}` : ''}`;
}

View File

@@ -1,4 +1,4 @@
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon } from '@/components/AppIcons';
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon } from '@/components/AppIcons';
export interface Tool {
/** Short display name (e.g. "Color") */
@@ -15,10 +15,6 @@ export interface Tool {
summary: string;
/** Icon component */
icon: React.ElementType;
/** Tailwind gradient utility class for the landing card */
gradient: string;
/** Hex accent color for the landing card */
accentColor: string;
/** Badge labels for the landing card */
badges: string[];
}
@@ -33,8 +29,6 @@ export const tools: Tool[] = [
summary:
'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.',
icon: ColorIcon,
gradient: 'gradient-indigo-purple',
accentColor: '#a855f7',
badges: ['Open Source', 'WCAG', 'Free'],
},
{
@@ -46,8 +40,6 @@ export const tools: Tool[] = [
summary:
'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search.',
icon: UnitsIcon,
gradient: 'gradient-cyan-purple',
accentColor: '#2dd4bf',
badges: ['Open Source', 'Real-time', 'Free'],
},
{
@@ -59,8 +51,6 @@ export const tools: Tool[] = [
summary:
'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.',
icon: ASCIIIcon,
gradient: 'gradient-yellow-amber',
accentColor: '#eab308',
badges: ['Open Source', 'ASCII Art', 'Free'],
},
{
@@ -72,8 +62,6 @@ export const tools: Tool[] = [
summary:
'Modern browser-based file converter powered by WebAssembly. Convert videos, images, and audio locally without server uploads. Privacy-first with no file size limits.',
icon: MediaIcon,
gradient: 'gradient-green-teal',
accentColor: '#10b981',
badges: ['Open Source', 'Converter', 'Free'],
},
{
@@ -85,8 +73,17 @@ export const tools: Tool[] = [
summary:
'Generate a complete set of favicons for your website. Includes PWA manifest and HTML embed code. All processing happens locally in your browser.',
icon: FaviconIcon,
gradient: 'gradient-blue-cyan',
accentColor: '#3b82f6',
badges: ['Open Source', 'Generator', 'Free'],
},
{
shortTitle: 'QR Code',
title: 'QR Code Generator',
navTitle: 'QR Code Generator',
href: '/qrcode',
description: 'Generate QR codes with custom colors, error correction, and multi-format export.',
summary:
'Generate QR codes with live preview, customizable colors, error correction levels, and export as PNG or SVG. All processing happens locally in your browser.',
icon: QRCodeIcon,
badges: ['Open Source', 'Generator', 'Free'],
},
];

View File

@@ -27,6 +27,7 @@
"jszip": "^3.10.1",
"lucide-react": "^0.575.0",
"next": "^16.1.6",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-colorful": "^5.6.1",
@@ -40,6 +41,7 @@
"@tailwindcss/postcss": "^4.2.0",
"@types/figlet": "^1.7.0",
"@types/node": "^25.3.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.21.0",

142
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
next:
specifier: ^16.1.6
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
qrcode:
specifier: ^1.5.4
version: 1.5.4
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -93,6 +96,9 @@ importers:
'@types/node':
specifier: ^25.3.0
version: 25.3.0
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react':
specifier: ^19.2.14
version: 19.2.14
@@ -1520,6 +1526,9 @@ packages:
'@types/prismjs@1.26.6':
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1861,6 +1870,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001772:
resolution: {integrity: sha512-mIwLZICj+ntVTw4BT2zfp+yu/AqV6GMKfJVJMx3MwPxs+uk/uj2GLl2dH8LQbjiLDX66amCga5nKFyDgRR43kg==}
@@ -1890,6 +1903,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -2015,6 +2031,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
dedent@1.7.1:
resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==}
peerDependencies:
@@ -2065,6 +2085,9 @@ packages:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -2366,6 +2389,10 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -2933,6 +2960,10 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -3224,14 +3255,26 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
@@ -3293,6 +3336,10 @@ packages:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -3344,6 +3391,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'}
@@ -3443,6 +3495,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -3520,6 +3575,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -3886,6 +3944,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.20:
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'}
@@ -3919,6 +3980,9 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -3926,10 +3990,18 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -5410,6 +5482,10 @@ snapshots:
'@types/prismjs@1.26.6': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 25.3.0
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -5772,6 +5848,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001772: {}
chalk@4.1.2:
@@ -5795,6 +5873,12 @@ snapshots:
client-only@0.0.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -5902,6 +5986,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
dedent@1.7.1: {}
deep-is@0.1.4: {}
@@ -5937,6 +6023,8 @@ snapshots:
diff@8.0.3: {}
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -6423,6 +6511,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -6914,6 +7007,10 @@ snapshots:
lines-and-columns@1.2.4: {}
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -7255,14 +7352,24 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
package-manager-detector@1.6.0: {}
pako@1.0.11: {}
@@ -7304,6 +7411,8 @@ snapshots:
pkce-challenge@5.0.1: {}
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@7.1.1:
@@ -7357,6 +7466,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.0:
dependencies:
side-channel: 1.1.0
@@ -7518,6 +7633,8 @@ snapshots:
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -7616,6 +7733,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -8105,6 +8224,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.20:
dependencies:
available-typed-arrays: 1.0.7
@@ -8144,12 +8265,33 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@21.1.1: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.2:
dependencies:
cliui: 8.0.1

12
types/qrcode.ts Normal file
View File

@@ -0,0 +1,12 @@
export type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H';
export type ExportSize = 256 | 512 | 1024 | 2048;
export interface QRCodeOptions {
text: string;
errorCorrection: ErrorCorrectionLevel;
foregroundColor: string;
backgroundColor: string;
margin: number;
size: ExportSize;
}