From f917891a31661b15ec060b6a44025a9884cdc802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sat, 28 Feb 2026 00:58:57 +0100 Subject: [PATCH] feat: add QR code generator tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/(app)/qrcode/page.tsx | 19 ++++ components/AppIcons.tsx | 12 +++ components/ascii/ASCIIConverter.tsx | 6 +- components/qrcode/QRCodeGenerator.tsx | 145 ++++++++++++++++++++++++++ components/qrcode/QRInput.tsx | 34 ++++++ components/qrcode/QROptions.tsx | 137 ++++++++++++++++++++++++ components/qrcode/QRPreview.tsx | 132 +++++++++++++++++++++++ lib/qrcode/qrcodeService.ts | 39 +++++++ lib/qrcode/urlSharing.ts | 85 +++++++++++++++ lib/tools.tsx | 27 +++-- package.json | 2 + pnpm-lock.yaml | 142 +++++++++++++++++++++++++ types/qrcode.ts | 12 +++ 13 files changed, 776 insertions(+), 16 deletions(-) create mode 100644 app/(app)/qrcode/page.tsx create mode 100644 components/qrcode/QRCodeGenerator.tsx create mode 100644 components/qrcode/QRInput.tsx create mode 100644 components/qrcode/QROptions.tsx create mode 100644 components/qrcode/QRPreview.tsx create mode 100644 lib/qrcode/qrcodeService.ts create mode 100644 lib/qrcode/urlSharing.ts create mode 100644 types/qrcode.ts diff --git a/app/(app)/qrcode/page.tsx b/app/(app)/qrcode/page.tsx new file mode 100644 index 0000000..7a9c6f5 --- /dev/null +++ b/app/(app)/qrcode/page.tsx @@ -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 ( + + + + ); +} diff --git a/components/AppIcons.tsx b/components/AppIcons.tsx index ccebc0a..acd7cb6 100644 --- a/components/AppIcons.tsx +++ b/components/AppIcons.tsx @@ -36,3 +36,15 @@ export const FaviconIcon = (props: React.SVGProps) => ( ); + +export const QRCodeIcon = (props: React.SVGProps) => ( + + + + + + + + + +); diff --git a/components/ascii/ASCIIConverter.tsx b/components/ascii/ASCIIConverter.tsx index 7f19ed3..d0ab233 100644 --- a/components/ascii/ASCIIConverter.tsx +++ b/components/ascii/ASCIIConverter.tsx @@ -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 */}
+ + Text + + ('M'); + const [foregroundColor, setForegroundColor] = React.useState('#000000'); + const [backgroundColor, setBackgroundColor] = React.useState('#ffffff'); + const [margin, setMargin] = React.useState(4); + const [exportSize, setExportSize] = React.useState(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 ( +
+ {/* Left Column - Input and Options */} +
+ + +
+ + {/* Right Column - Preview */} +
+ +
+
+ ); +} diff --git a/components/qrcode/QRInput.tsx b/components/qrcode/QRInput.tsx new file mode 100644 index 0000000..fdf8edf --- /dev/null +++ b/components/qrcode/QRInput.tsx @@ -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 ( + + + Text + + +