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 { downloadBlob } from '@/lib/media/utils/fileUtils';
|
||||||
import { debounce } from '@/lib/utils/debounce';
|
import { debounce } from '@/lib/utils/debounce';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
import type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode';
|
import type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode';
|
||||||
|
|
||||||
|
type MobileTab = 'configure' | 'preview';
|
||||||
|
|
||||||
export function QRCodeGenerator() {
|
export function QRCodeGenerator() {
|
||||||
const [text, setText] = React.useState('https://kit.pivoine.art');
|
const [text, setText] = React.useState('https://kit.pivoine.art');
|
||||||
const [errorCorrection, setErrorCorrection] = React.useState<ErrorCorrectionLevel>('M');
|
const [errorCorrection, setErrorCorrection] = React.useState<ErrorCorrectionLevel>('M');
|
||||||
@@ -20,6 +23,7 @@ export function QRCodeGenerator() {
|
|||||||
const [exportSize, setExportSize] = React.useState<ExportSize>(512);
|
const [exportSize, setExportSize] = React.useState<ExportSize>(512);
|
||||||
const [svgString, setSvgString] = React.useState('');
|
const [svgString, setSvgString] = React.useState('');
|
||||||
const [isGenerating, setIsGenerating] = React.useState(false);
|
const [isGenerating, setIsGenerating] = React.useState(false);
|
||||||
|
const [mobileTab, setMobileTab] = React.useState<MobileTab>('configure');
|
||||||
|
|
||||||
// Load state from URL on mount
|
// Load state from URL on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -37,11 +41,7 @@ export function QRCodeGenerator() {
|
|||||||
const generate = React.useMemo(
|
const generate = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
debounce(async (t: string, ec: ErrorCorrectionLevel, fg: string, bg: string, m: number) => {
|
debounce(async (t: string, ec: ErrorCorrectionLevel, fg: string, bg: string, m: number) => {
|
||||||
if (!t) {
|
if (!t) { setSvgString(''); setIsGenerating(false); return; }
|
||||||
setSvgString('');
|
|
||||||
setIsGenerating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
const svg = await generateSvg(t, ec, fg, bg, m);
|
const svg = await generateSvg(t, ec, fg, bg, m);
|
||||||
@@ -57,13 +57,11 @@ export function QRCodeGenerator() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Regenerate on changes
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
generate(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
generate(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
||||||
updateQRUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
updateQRUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
||||||
}, [text, errorCorrection, foregroundColor, backgroundColor, margin, generate]);
|
}, [text, errorCorrection, foregroundColor, backgroundColor, margin, generate]);
|
||||||
|
|
||||||
// Export: PNG download
|
|
||||||
const handleDownloadPng = async () => {
|
const handleDownloadPng = async () => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
try {
|
try {
|
||||||
@@ -71,74 +69,94 @@ export function QRCodeGenerator() {
|
|||||||
const res = await fetch(dataUrl);
|
const res = await fetch(dataUrl);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
downloadBlob(blob, `qrcode-${Date.now()}.png`);
|
downloadBlob(blob, `qrcode-${Date.now()}.png`);
|
||||||
} catch {
|
} catch { toast.error('Failed to export PNG'); }
|
||||||
toast.error('Failed to export PNG');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export: SVG download
|
|
||||||
const handleDownloadSvg = () => {
|
const handleDownloadSvg = () => {
|
||||||
if (!svgString) return;
|
if (!svgString) return;
|
||||||
const blob = new Blob([svgString], { type: 'image/svg+xml' });
|
const blob = new Blob([svgString], { type: 'image/svg+xml' });
|
||||||
downloadBlob(blob, `qrcode-${Date.now()}.svg`);
|
downloadBlob(blob, `qrcode-${Date.now()}.svg`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Copy image to clipboard
|
|
||||||
const handleCopyImage = async () => {
|
const handleCopyImage = async () => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
try {
|
try {
|
||||||
const dataUrl = await generateDataUrl(text, errorCorrection, foregroundColor, backgroundColor, margin, exportSize);
|
const dataUrl = await generateDataUrl(text, errorCorrection, foregroundColor, backgroundColor, margin, exportSize);
|
||||||
const res = await fetch(dataUrl);
|
const res = await fetch(dataUrl);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||||
new ClipboardItem({ 'image/png': blob }),
|
|
||||||
]);
|
|
||||||
toast.success('Image copied to clipboard!');
|
toast.success('Image copied to clipboard!');
|
||||||
} catch {
|
} catch { toast.error('Failed to copy image'); }
|
||||||
toast.error('Failed to copy image');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Share URL
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
const shareUrl = getQRShareableUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
const shareUrl = getQRShareableUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
toast.success('Shareable URL copied!');
|
toast.success('Shareable URL copied!');
|
||||||
} catch {
|
} catch { toast.error('Failed to copy URL'); }
|
||||||
toast.error('Failed to copy URL');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch lg:max-h-[800px]">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Left Column - Input and Options */}
|
|
||||||
<div className="lg:col-span-1 space-y-6 overflow-y-auto custom-scrollbar">
|
{/* ── Mobile tab switcher ─────────────────────────────── */}
|
||||||
<QRInput value={text} onChange={setText} />
|
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||||
<QROptions
|
{(['configure', 'preview'] as MobileTab[]).map((t) => (
|
||||||
errorCorrection={errorCorrection}
|
<button
|
||||||
foregroundColor={foregroundColor}
|
key={t}
|
||||||
backgroundColor={backgroundColor}
|
onClick={() => setMobileTab(t)}
|
||||||
margin={margin}
|
className={cn(
|
||||||
onErrorCorrectionChange={setErrorCorrection}
|
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
|
||||||
onForegroundColorChange={setForegroundColor}
|
mobileTab === t
|
||||||
onBackgroundColorChange={setBackgroundColor}
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
onMarginChange={setMargin}
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
/>
|
)}
|
||||||
|
>
|
||||||
|
{t === 'configure' ? 'Configure' : 'Preview'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Preview */}
|
{/* ── Main layout ─────────────────────────────────────── */}
|
||||||
<div className="lg:col-span-2 h-full">
|
<div
|
||||||
<QRPreview
|
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||||
svgString={svgString}
|
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||||
isGenerating={isGenerating}
|
>
|
||||||
exportSize={exportSize}
|
|
||||||
onExportSizeChange={setExportSize}
|
{/* Left: Input + Options */}
|
||||||
onCopyImage={handleCopyImage}
|
<div className={cn('lg:col-span-2 flex flex-col overflow-hidden', mobileTab !== 'configure' && 'hidden lg:flex')}>
|
||||||
onShare={handleShare}
|
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
onDownloadPng={handleDownloadPng}
|
<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">
|
||||||
onDownloadSvg={handleDownloadSvg}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
const MAX_LENGTH = 2048;
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
|
|
||||||
interface QRInputProps {
|
interface QRInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_LENGTH = 2048;
|
|
||||||
|
|
||||||
export function QRInput({ value, onChange }: QRInputProps) {
|
export function QRInput({ value, onChange }: QRInputProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div>
|
||||||
<CardHeader>
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
|
||||||
<CardTitle>Text</CardTitle>
|
Content
|
||||||
</CardHeader>
|
</span>
|
||||||
<CardContent className="space-y-2">
|
<textarea
|
||||||
<Textarea
|
value={value}
|
||||||
value={value}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
placeholder="Enter text or URL…"
|
||||||
placeholder="Enter text or URL..."
|
maxLength={MAX_LENGTH}
|
||||||
maxLength={MAX_LENGTH}
|
rows={4}
|
||||||
rows={3}
|
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"
|
||||||
className="resize-none font-mono text-sm"
|
/>
|
||||||
/>
|
<div className="text-[9px] text-muted-foreground/30 font-mono text-right mt-1 tabular-nums">
|
||||||
<div className="text-[10px] text-muted-foreground text-right">
|
{value.length} / {MAX_LENGTH}
|
||||||
{value.length} / {MAX_LENGTH}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
'use client';
|
'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 { Slider } from '@/components/ui/slider';
|
||||||
import {
|
import { cn } from '@/lib/utils/cn';
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import type { ErrorCorrectionLevel } from '@/types/qrcode';
|
import type { ErrorCorrectionLevel } from '@/types/qrcode';
|
||||||
|
|
||||||
interface QROptionsProps {
|
interface QROptionsProps {
|
||||||
@@ -25,13 +15,16 @@ interface QROptionsProps {
|
|||||||
onMarginChange: (margin: number) => void;
|
onMarginChange: (margin: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string }[] = [
|
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string; desc: string }[] = [
|
||||||
{ value: 'L', label: 'Low (7%)' },
|
{ value: 'L', label: 'L', desc: '7%' },
|
||||||
{ value: 'M', label: 'Medium (15%)' },
|
{ value: 'M', label: 'M', desc: '15%' },
|
||||||
{ value: 'Q', label: 'Quartile (25%)' },
|
{ value: 'Q', label: 'Q', desc: '25%' },
|
||||||
{ value: 'H', label: 'High (30%)' },
|
{ 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({
|
export function QROptions({
|
||||||
errorCorrection,
|
errorCorrection,
|
||||||
foregroundColor,
|
foregroundColor,
|
||||||
@@ -45,93 +38,111 @@ export function QROptions({
|
|||||||
const isTransparent = backgroundColor === '#00000000';
|
const isTransparent = backgroundColor === '#00000000';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-5">
|
||||||
<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 */}
|
{/* Error Correction */}
|
||||||
<div className="space-y-3">
|
<div>
|
||||||
<div className="space-y-1.5">
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
|
||||||
<Label className="text-xs">Foreground</Label>
|
Error Correction
|
||||||
<div className="flex gap-2">
|
</span>
|
||||||
<Input
|
<div className="flex gap-1.5">
|
||||||
type="color"
|
{EC_OPTIONS.map((opt) => (
|
||||||
className="w-9 p-1 h-9 shrink-0"
|
<button
|
||||||
value={foregroundColor}
|
key={opt.value}
|
||||||
onChange={(e) => onForegroundColorChange(e.target.value)}
|
onClick={() => onErrorCorrectionChange(opt.value)}
|
||||||
/>
|
className={cn(
|
||||||
<Input
|
'flex-1 flex flex-col items-center py-2 rounded-lg border text-xs font-mono transition-all',
|
||||||
className="font-mono text-xs"
|
errorCorrection === opt.value
|
||||||
value={foregroundColor}
|
? 'bg-primary/10 border-primary/40 text-primary'
|
||||||
onChange={(e) => onForegroundColorChange(e.target.value)}
|
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
|
||||||
/>
|
)}
|
||||||
</div>
|
>
|
||||||
</div>
|
<span className="font-semibold">{opt.label}</span>
|
||||||
<div className="space-y-1.5">
|
<span className="text-[9px] opacity-50 mt-0.5">{opt.desc}</span>
|
||||||
<div className="flex items-center justify-between">
|
</button>
|
||||||
<Label className="text-xs">Background</Label>
|
))}
|
||||||
<Button
|
</div>
|
||||||
variant={isTransparent ? 'default' : 'outline'}
|
</div>
|
||||||
size="xs"
|
|
||||||
className="h-5 text-[10px] px-1.5"
|
{/* Colors */}
|
||||||
onClick={() =>
|
<div className="space-y-3">
|
||||||
onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000')
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
|
||||||
}
|
Colors
|
||||||
>
|
</span>
|
||||||
Transparent
|
|
||||||
</Button>
|
{/* Foreground */}
|
||||||
</div>
|
<div>
|
||||||
<div className="flex gap-2">
|
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Foreground</label>
|
||||||
<Input
|
<div className="flex gap-1.5">
|
||||||
type="color"
|
<input
|
||||||
className="w-9 p-1 h-9 shrink-0"
|
type="color"
|
||||||
disabled={isTransparent}
|
value={foregroundColor}
|
||||||
value={backgroundColor}
|
onChange={(e) => onForegroundColorChange(e.target.value)}
|
||||||
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
|
||||||
/>
|
/>
|
||||||
<Input
|
<input
|
||||||
className="font-mono text-xs"
|
type="text"
|
||||||
disabled={isTransparent}
|
value={foregroundColor}
|
||||||
value={backgroundColor}
|
onChange={(e) => onForegroundColorChange(e.target.value)}
|
||||||
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Margin */}
|
{/* Background */}
|
||||||
<div className="space-y-1.5">
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<Label className="text-xs">Margin</Label>
|
<label className="text-[9px] text-muted-foreground/50 font-mono">Background</label>
|
||||||
<span className="text-xs text-muted-foreground">{margin}</span>
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
disabled={isTransparent}
|
||||||
|
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
|
||||||
|
type="text"
|
||||||
|
disabled={isTransparent}
|
||||||
|
value={isTransparent ? 'transparent' : backgroundColor}
|
||||||
|
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
||||||
|
className={cn(inputCls, isTransparent && 'opacity-30')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
|
||||||
value={[margin]}
|
|
||||||
onValueChange={([v]) => onMarginChange(v)}
|
|
||||||
min={0}
|
|
||||||
max={8}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
{/* Margin */}
|
||||||
|
<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]}
|
||||||
|
onValueChange={([v]) => onMarginChange(v)}
|
||||||
|
min={0}
|
||||||
|
max={8}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
'use client';
|
'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 { Copy, Share2, Image as ImageIcon, FileCode, QrCode } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
import type { ExportSize } from '@/types/qrcode';
|
import type { ExportSize } from '@/types/qrcode';
|
||||||
|
|
||||||
interface QRPreviewProps {
|
interface QRPreviewProps {
|
||||||
@@ -30,6 +15,16 @@ interface QRPreviewProps {
|
|||||||
onDownloadSvg: () => void;
|
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({
|
export function QRPreview({
|
||||||
svgString,
|
svgString,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
@@ -41,92 +36,81 @@ export function QRPreview({
|
|||||||
onDownloadSvg,
|
onDownloadSvg,
|
||||||
}: QRPreviewProps) {
|
}: QRPreviewProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="h-full flex flex-col">
|
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
<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>
|
{/* Action bar */}
|
||||||
<TooltipTrigger asChild>
|
<div className="flex items-center gap-1.5 mb-4 shrink-0 flex-wrap">
|
||||||
<Button variant="outline" size="xs" onClick={onShare} disabled={!svgString}>
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest mr-auto">
|
||||||
<Share2 className="h-3 w-3 mr-1" />
|
Preview
|
||||||
Share
|
</span>
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Copy shareable URL</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<button onClick={onCopyImage} disabled={!svgString} className={actionBtn}>
|
||||||
<Tooltip>
|
<Copy className="w-3 h-3" />Copy
|
||||||
<TooltipTrigger asChild>
|
</button>
|
||||||
<Button variant="outline" size="xs" onClick={onDownloadPng} disabled={!svgString}>
|
|
||||||
<ImageIcon className="h-3 w-3 mr-1" />
|
<button onClick={onShare} disabled={!svgString} className={actionBtn}>
|
||||||
PNG
|
<Share2 className="w-3 h-3" />Share
|
||||||
</Button>
|
</button>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Download as PNG</TooltipContent>
|
{/* PNG + inline size selector */}
|
||||||
</Tooltip>
|
<div className="flex items-center glass rounded-md border border-border/30">
|
||||||
<ToggleGroup
|
<button
|
||||||
type="single"
|
onClick={onDownloadPng}
|
||||||
value={String(exportSize)}
|
disabled={!svgString}
|
||||||
onValueChange={(v) => v && onExportSizeChange(Number(v) as ExportSize)}
|
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"
|
||||||
variant="outline"
|
>
|
||||||
size="sm"
|
<ImageIcon className="w-3 h-3" />PNG
|
||||||
>
|
</button>
|
||||||
<ToggleGroupItem value="256" className="h-6 px-1.5 min-w-0 text-[10px]">256</ToggleGroupItem>
|
<div className="flex items-center px-1 gap-0.5">
|
||||||
<ToggleGroupItem value="512" className="h-6 px-1.5 min-w-0 text-[10px]">512</ToggleGroupItem>
|
{EXPORT_SIZES.map(({ value, label }) => (
|
||||||
<ToggleGroupItem value="1024" className="h-6 px-1.5 min-w-0 text-[10px]">1k</ToggleGroupItem>
|
<button
|
||||||
<ToggleGroupItem value="2048" className="h-6 px-1.5 min-w-0 text-[10px]">2k</ToggleGroupItem>
|
key={value}
|
||||||
</ToggleGroup>
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tooltip>
|
<button onClick={onDownloadSvg} disabled={!svgString} className={actionBtn}>
|
||||||
<TooltipTrigger asChild>
|
<FileCode className="w-3 h-3" />SVG
|
||||||
<Button variant="outline" size="xs" onClick={onDownloadSvg} disabled={!svgString}>
|
</button>
|
||||||
<FileCode className="h-3 w-3 mr-1" />
|
</div>
|
||||||
SVG
|
|
||||||
</Button>
|
{/* QR canvas */}
|
||||||
</TooltipTrigger>
|
<div
|
||||||
<TooltipContent>Download as SVG</TooltipContent>
|
className="flex-1 min-h-0 rounded-xl flex items-center justify-center"
|
||||||
</Tooltip>
|
style={{
|
||||||
</div>
|
backgroundImage: 'repeating-conic-gradient(rgba(255,255,255,0.025) 0% 25%, transparent 0% 50%)',
|
||||||
</CardHeader>
|
backgroundSize: '16px 16px',
|
||||||
<CardContent className="flex-1 flex flex-col">
|
}}
|
||||||
<div className="flex-1 min-h-[200px] rounded-lg p-4 flex items-center justify-center"
|
>
|
||||||
style={{
|
{isGenerating ? (
|
||||||
backgroundImage: 'repeating-conic-gradient(hsl(var(--muted)) 0% 25%, transparent 0% 50%)',
|
<div className="w-56 h-56 rounded-xl bg-white/5 animate-pulse" />
|
||||||
backgroundSize: '16px 16px',
|
) : svgString ? (
|
||||||
}}
|
<div
|
||||||
>
|
className="w-full max-w-sm aspect-square [&>svg]:w-full [&>svg]:h-full p-6"
|
||||||
{isGenerating ? (
|
dangerouslySetInnerHTML={{ __html: svgString }}
|
||||||
<Skeleton className="h-[200px] w-[200px]" />
|
/>
|
||||||
) : svgString ? (
|
) : (
|
||||||
<div
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
className="w-full max-w-[400px] aspect-square [&>svg]:w-full [&>svg]:h-full"
|
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
dangerouslySetInnerHTML={{ __html: svgString }}
|
<QrCode className="w-6 h-6 text-primary/40" />
|
||||||
/>
|
</div>
|
||||||
) : (
|
<div>
|
||||||
<Empty>
|
<p className="text-sm font-medium text-foreground/40">No QR code yet</p>
|
||||||
<EmptyHeader>
|
<p className="text-[10px] text-muted-foreground/30 font-mono mt-1">Enter text or a URL to generate</p>
|
||||||
<EmptyMedia variant="icon">
|
</div>
|
||||||
<QrCode />
|
</div>
|
||||||
</EmptyMedia>
|
)}
|
||||||
<EmptyTitle>Enter text to generate a QR code</EmptyTitle>
|
</div>
|
||||||
<EmptyDescription>Type text or a URL in the input field above</EmptyDescription>
|
</div>
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user