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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
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>
|
||||
<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…"
|
||||
maxLength={MAX_LENGTH}
|
||||
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-[9px] text-muted-foreground/30 font-mono text-right mt-1 tabular-nums">
|
||||
{value.length} / {MAX_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,93 +38,111 @@ export function QROptions({
|
||||
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>
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* 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>
|
||||
{/* Error Correction */}
|
||||
<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) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="space-y-3">
|
||||
<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"
|
||||
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
|
||||
type="text"
|
||||
value={foregroundColor}
|
||||
onChange={(e) => onForegroundColorChange(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</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>
|
||||
{/* 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>
|
||||
</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>
|
||||
<Slider
|
||||
value={[margin]}
|
||||
onValueChange={([v]) => onMarginChange(v)}
|
||||
min={0}
|
||||
max={8}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 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';
|
||||
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
<button onClick={onDownloadSvg} disabled={!svgString} className={actionBtn}>
|
||||
<FileCode className="w-3 h-3" />SVG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* QR canvas */}
|
||||
<div
|
||||
className="flex-1 min-h-0 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: 'repeating-conic-gradient(rgba(255,255,255,0.025) 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '16px 16px',
|
||||
}}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="w-56 h-56 rounded-xl bg-white/5 animate-pulse" />
|
||||
) : svgString ? (
|
||||
<div
|
||||
className="w-full max-w-sm aspect-square [&>svg]:w-full [&>svg]:h-full p-6"
|
||||
dangerouslySetInnerHTML={{ __html: svgString }}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user