refactor: align QR code tool with Calculate/Media blueprint
- QRCodeGenerator: lg:grid-cols-5 layout (2/5 options, 3/5 preview); full viewport height; mobile Configure|Preview glass pill tabs - QRInput: remove shadcn Textarea/Card; native <textarea> in glass panel section; character counter in monospace - QROptions: remove shadcn Card/Label/Input/Button/Select; EC level as 4 pill buttons with recovery % label; native color inputs + pickers; transparent toggle as small pill; keep shadcn Slider for margin - QRPreview: remove shadcn Card/Button/Skeleton/ToggleGroup/Tooltip/Empty; glass card fills full height; PNG button with inline size pill group (256/512/1k/2k); empty state and pulse skeleton match other tools Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,11 @@ import { decodeQRFromUrl, updateQRUrl, getQRShareableUrl } from '@/lib/qrcode/ur
|
||||
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');
|
||||
@@ -20,6 +23,7 @@ export function QRCodeGenerator() {
|
||||
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(() => {
|
||||
@@ -37,11 +41,7 @@ export function QRCodeGenerator() {
|
||||
const generate = React.useMemo(
|
||||
() =>
|
||||
debounce(async (t: string, ec: ErrorCorrectionLevel, fg: string, bg: string, m: number) => {
|
||||
if (!t) {
|
||||
setSvgString('');
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
if (!t) { setSvgString(''); setIsGenerating(false); return; }
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const svg = await generateSvg(t, ec, fg, bg, m);
|
||||
@@ -57,13 +57,11 @@ export function QRCodeGenerator() {
|
||||
[],
|
||||
);
|
||||
|
||||
// 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 {
|
||||
@@ -71,74 +69,94 @@ export function QRCodeGenerator() {
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
downloadBlob(blob, `qrcode-${Date.now()}.png`);
|
||||
} catch {
|
||||
toast.error('Failed to export 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 }),
|
||||
]);
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||
toast.success('Image copied to clipboard!');
|
||||
} catch {
|
||||
toast.error('Failed to copy image');
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
} 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 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>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
{/* ── Main layout ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||
>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user