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:
2026-03-01 08:37:39 +01:00
parent 7da20c37c1
commit 50cf5823f9
4 changed files with 275 additions and 267 deletions

View File

@@ -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,50 +69,67 @@ 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">
<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>
{/* ── 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}
@@ -126,9 +141,11 @@ export function QRCodeGenerator() {
onMarginChange={setMargin}
/>
</div>
</div>
</div>
{/* Right Column - Preview */}
<div className="lg:col-span-2 h-full">
{/* Right: Preview */}
<div className={cn('lg:col-span-3 flex flex-col overflow-hidden', mobileTab !== 'preview' && 'hidden lg:flex')}>
<QRPreview
svgString={svgString}
isGenerating={isGenerating}
@@ -141,5 +158,6 @@ export function QRCodeGenerator() {
/>
</div>
</div>
</div>
);
}

View File

@@ -1,34 +1,29 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
const MAX_LENGTH = 2048;
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
<div>
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
Content
</span>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter text or URL..."
placeholder="Enter text or URL"
maxLength={MAX_LENGTH}
rows={3}
className="resize-none font-mono text-sm"
rows={4}
className="w-full bg-transparent border border-border/40 rounded-lg px-3 py-2.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30 resize-none"
/>
<div className="text-[10px] text-muted-foreground text-right">
<div className="text-[9px] text-muted-foreground/30 font-mono text-right mt-1 tabular-nums">
{value.length} / {MAX_LENGTH}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,17 +1,7 @@
'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 { cn } from '@/lib/utils/cn';
import type { ErrorCorrectionLevel } from '@/types/qrcode';
interface QROptionsProps {
@@ -25,13 +15,16 @@ interface QROptionsProps {
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%)' },
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string; desc: string }[] = [
{ value: 'L', label: 'L', desc: '7%' },
{ value: 'M', label: 'M', desc: '15%' },
{ value: 'Q', label: 'Q', desc: '25%' },
{ value: 'H', label: 'H', desc: '30%' },
];
const inputCls =
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
export function QROptions({
errorCorrection,
foregroundColor,
@@ -45,83 +38,102 @@ export function QROptions({
const isTransparent = backgroundColor === '#00000000';
return (
<Card>
<CardHeader>
<CardTitle>Options</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-5">
{/* 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>
<div>
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
Error Correction
</span>
<div className="flex gap-1.5">
{EC_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
<button
key={opt.value}
onClick={() => onErrorCorrectionChange(opt.value)}
className={cn(
'flex-1 flex flex-col items-center py-2 rounded-lg border text-xs font-mono transition-all',
errorCorrection === opt.value
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
)}
>
<span className="font-semibold">{opt.label}</span>
<span className="text-[9px] opacity-50 mt-0.5">{opt.desc}</span>
</button>
))}
</SelectContent>
</Select>
</div>
</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
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
Colors
</span>
{/* Foreground */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Foreground</label>
<div className="flex gap-1.5">
<input
type="color"
className="w-9 p-1 h-9 shrink-0"
value={foregroundColor}
onChange={(e) => onForegroundColorChange(e.target.value)}
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
/>
<Input
className="font-mono text-xs"
<input
type="text"
value={foregroundColor}
onChange={(e) => onForegroundColorChange(e.target.value)}
className={inputCls}
/>
</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')
}
{/* Background */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[9px] text-muted-foreground/50 font-mono">Background</label>
<button
onClick={() => onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000')}
className={cn(
'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
isTransparent
? 'border-primary/40 text-primary bg-primary/10'
: 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
)}
>
Transparent
</Button>
</button>
</div>
<div className="flex gap-2">
<Input
<div className="flex gap-1.5">
<input
type="color"
className="w-9 p-1 h-9 shrink-0"
disabled={isTransparent}
value={backgroundColor}
value={isTransparent ? '#ffffff' : backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)}
className={cn(
'w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5 transition-opacity',
isTransparent && 'opacity-30 cursor-not-allowed'
)}
/>
<Input
className="font-mono text-xs"
<input
type="text"
disabled={isTransparent}
value={backgroundColor}
value={isTransparent ? 'transparent' : backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)}
className={cn(inputCls, isTransparent && 'opacity-30')}
/>
</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>
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Margin
</span>
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{margin}</span>
</div>
<Slider
value={[margin]}
@@ -131,7 +143,6 @@ export function QROptions({
step={1}
/>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,22 +1,7 @@
'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 { cn } from '@/lib/utils/cn';
import type { ExportSize } from '@/types/qrcode';
interface QRPreviewProps {
@@ -30,6 +15,16 @@ interface QRPreviewProps {
onDownloadSvg: () => void;
}
const EXPORT_SIZES: { value: ExportSize; label: string }[] = [
{ value: 256, label: '256' },
{ value: 512, label: '512' },
{ value: 1024, label: '1k' },
{ value: 2048, label: '2k' },
];
const actionBtn =
'flex items-center gap-1 px-2.5 py-1 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
export function QRPreview({
svgString,
isGenerating,
@@ -41,92 +36,81 @@ export function QRPreview({
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>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<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>
{/* Action bar */}
<div className="flex items-center gap-1.5 mb-4 shrink-0 flex-wrap">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest mr-auto">
Preview
</span>
<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"
<button onClick={onCopyImage} disabled={!svgString} className={actionBtn}>
<Copy className="w-3 h-3" />Copy
</button>
<button onClick={onShare} disabled={!svgString} className={actionBtn}>
<Share2 className="w-3 h-3" />Share
</button>
{/* PNG + inline size selector */}
<div className="flex items-center glass rounded-md border border-border/30">
<button
onClick={onDownloadPng}
disabled={!svgString}
className="flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-xs text-muted-foreground hover:text-primary transition-all disabled:opacity-40 disabled:cursor-not-allowed border-r border-border/20"
>
<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>
<ImageIcon className="w-3 h-3" />PNG
</button>
<div className="flex items-center px-1 gap-0.5">
{EXPORT_SIZES.map(({ value, label }) => (
<button
key={value}
onClick={() => onExportSizeChange(value)}
className={cn(
'text-[9px] font-mono px-1.5 py-0.5 rounded transition-all',
exportSize === value
? 'text-primary bg-primary/10'
: 'text-muted-foreground/40 hover:text-muted-foreground'
)}
>
{label}
</button>
))}
</div>
</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>
<button onClick={onDownloadSvg} disabled={!svgString} className={actionBtn}>
<FileCode className="w-3 h-3" />SVG
</button>
</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"
{/* QR canvas */}
<div
className="flex-1 min-h-0 rounded-xl flex items-center justify-center"
style={{
backgroundImage: 'repeating-conic-gradient(hsl(var(--muted)) 0% 25%, transparent 0% 50%)',
backgroundImage: 'repeating-conic-gradient(rgba(255,255,255,0.025) 0% 25%, transparent 0% 50%)',
backgroundSize: '16px 16px',
}}
>
{isGenerating ? (
<Skeleton className="h-[200px] w-[200px]" />
<div className="w-56 h-56 rounded-xl bg-white/5 animate-pulse" />
) : svgString ? (
<div
className="w-full max-w-[400px] aspect-square [&>svg]:w-full [&>svg]:h-full"
className="w-full max-w-sm aspect-square [&>svg]:w-full [&>svg]:h-full p-6"
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 className="flex flex-col items-center gap-3 text-center">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<QrCode className="w-6 h-6 text-primary/40" />
</div>
<div>
<p className="text-sm font-medium text-foreground/40">No QR code yet</p>
<p className="text-[10px] text-muted-foreground/30 font-mono mt-1">Enter text or a URL to generate</p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}