mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(qr-generate): rewrite settings with content types, style pickers, colors, logo
This commit is contained in:
@@ -1,158 +1,774 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
export function QrGenerateSettings() {
|
||||
const [text, setText] = useState("");
|
||||
const [size, setSize] = useState(400);
|
||||
const [errorCorrection, setErrorCorrection] = useState<"L" | "M" | "Q" | "H">("M");
|
||||
const [foreground, setForeground] = useState("#000000");
|
||||
const [background, setBackground] = useState("#FFFFFF");
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
import {
|
||||
Contact,
|
||||
Download,
|
||||
Globe,
|
||||
ImagePlus,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Phone,
|
||||
Type,
|
||||
Wifi,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import QRCodeStyling from "qr-code-styling";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||
import {
|
||||
type ContentType,
|
||||
type CornerDotType,
|
||||
type CornerSquareType,
|
||||
type DotType,
|
||||
type DownloadFormat,
|
||||
encodeQrData,
|
||||
useQrStore,
|
||||
} from "@/stores/qr-store";
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!text) return;
|
||||
// ── Content type tab definitions ─────────────────────────────────────
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
setPreviewUrl(null);
|
||||
const CONTENT_TYPES: {
|
||||
id: ContentType;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}[] = [
|
||||
{ id: "url", label: "URL", icon: Globe },
|
||||
{ id: "text", label: "Text", icon: Type },
|
||||
{ id: "wifi", label: "WiFi", icon: Wifi },
|
||||
{ id: "vcard", label: "vCard", icon: Contact },
|
||||
{ id: "email", label: "Email", icon: Mail },
|
||||
{ id: "phone", label: "Phone", icon: Phone },
|
||||
{ id: "sms", label: "SMS", icon: MessageSquare },
|
||||
];
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/tools/qr-generate", {
|
||||
method: "POST",
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ text, size, errorCorrection, foreground, background }),
|
||||
});
|
||||
// ── Style option definitions ─────────────────────────────────────────
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Generation failed: ${res.status}`);
|
||||
}
|
||||
const DOT_TYPES: { value: DotType; label: string }[] = [
|
||||
{ value: "square", label: "Square" },
|
||||
{ value: "rounded", label: "Rounded" },
|
||||
{ value: "dots", label: "Dots" },
|
||||
{ value: "classy", label: "Classy" },
|
||||
{ value: "classy-rounded", label: "Classy Rnd" },
|
||||
{ value: "extra-rounded", label: "Extra Rnd" },
|
||||
];
|
||||
|
||||
const result = await res.json();
|
||||
setDownloadUrl(result.downloadUrl);
|
||||
setPreviewUrl(result.downloadUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Generation failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
const CORNER_SQUARE_TYPES: { value: CornerSquareType; label: string }[] = [
|
||||
{ value: "square", label: "Square" },
|
||||
{ value: "rounded", label: "Rounded" },
|
||||
{ value: "dot", label: "Dot" },
|
||||
{ value: "extra-rounded", label: "Extra Rnd" },
|
||||
];
|
||||
|
||||
const CORNER_DOT_TYPES: { value: CornerDotType; label: string }[] = [
|
||||
{ value: "square", label: "Square" },
|
||||
{ value: "rounded", label: "Rounded" },
|
||||
{ value: "dot", label: "Dot" },
|
||||
];
|
||||
|
||||
const DOWNLOAD_FORMATS: { value: DownloadFormat; label: string; desc: string }[] = [
|
||||
{ value: "png", label: "PNG", desc: "Best for digital" },
|
||||
{ value: "svg", label: "SVG", desc: "Best for print" },
|
||||
{ value: "jpeg", label: "JPEG", desc: "Smaller file" },
|
||||
{ value: "webp", label: "WebP", desc: "Modern format" },
|
||||
];
|
||||
|
||||
// ── Data entry forms per content type ────────────────────────────────
|
||||
|
||||
const INPUT_CLASS =
|
||||
"w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground";
|
||||
const TEXTAREA_CLASS = `${INPUT_CLASS} resize-none`;
|
||||
|
||||
function UrlForm() {
|
||||
const { textData, setTextData } = useQrStore();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={textData}
|
||||
onChange={(e) => setTextData(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className={INPUT_CLASS}
|
||||
data-testid="qr-input-url"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextForm() {
|
||||
const { textData, setTextData } = useQrStore();
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Text</label>
|
||||
<textarea
|
||||
value={textData}
|
||||
onChange={(e) => setTextData(e.target.value)}
|
||||
placeholder="Enter any text..."
|
||||
rows={3}
|
||||
className={TEXTAREA_CLASS}
|
||||
data-testid="qr-input-text"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WifiForm() {
|
||||
const { wifiData, setWifiData } = useQrStore();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label htmlFor="qr-text" className="text-xs text-muted-foreground">
|
||||
Text / URL
|
||||
</label>
|
||||
<textarea
|
||||
id="qr-text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Enter text or URL..."
|
||||
rows={3}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground resize-none"
|
||||
<label className="text-xs text-muted-foreground">Network Name (SSID)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wifiData.ssid}
|
||||
onChange={(e) => setWifiData({ ssid: e.target.value })}
|
||||
placeholder="MyNetwork"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="qr-size" className="text-xs text-muted-foreground">
|
||||
Size
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{size}px</span>
|
||||
</div>
|
||||
<label className="text-xs text-muted-foreground">Password</label>
|
||||
<input
|
||||
id="qr-size"
|
||||
type="range"
|
||||
min={100}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={size}
|
||||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
type="text"
|
||||
value={wifiData.password}
|
||||
onChange={(e) => setWifiData({ password: e.target.value })}
|
||||
placeholder="Password"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="qr-error-correction" className="text-xs text-muted-foreground">
|
||||
Error Correction
|
||||
</label>
|
||||
<label className="text-xs text-muted-foreground">Encryption</label>
|
||||
<select
|
||||
id="qr-error-correction"
|
||||
value={errorCorrection}
|
||||
onChange={(e) => setErrorCorrection(e.target.value as "L" | "M" | "Q" | "H")}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
value={wifiData.encryption}
|
||||
onChange={(e) => setWifiData({ encryption: e.target.value as "WPA" | "WEP" | "nopass" })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
<option value="L">Low (7%)</option>
|
||||
<option value="M">Medium (15%)</option>
|
||||
<option value="Q">Quartile (25%)</option>
|
||||
<option value="H">High (30%)</option>
|
||||
<option value="WPA">WPA / WPA2</option>
|
||||
<option value="WEP">WEP</option>
|
||||
<option value="nopass">None</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wifiData.hidden}
|
||||
onChange={(e) => setWifiData({ hidden: e.target.checked })}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Hidden network
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VCardForm() {
|
||||
const { vcardData, setVcardData } = useQrStore();
|
||||
const field = (label: string, key: keyof typeof vcardData, placeholder: string) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-muted-foreground">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={vcardData[key]}
|
||||
onChange={(e) => setVcardData({ [key]: e.target.value })}
|
||||
placeholder={placeholder}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="qr-foreground" className="text-xs text-muted-foreground">
|
||||
Foreground
|
||||
<div className="flex-1">{field("First Name", "firstName", "John")}</div>
|
||||
<div className="flex-1">{field("Last Name", "lastName", "Doe")}</div>
|
||||
</div>
|
||||
{field("Phone", "phone", "+1 234 567 890")}
|
||||
{field("Email", "email", "john@example.com")}
|
||||
{field("Organization", "organization", "Company")}
|
||||
{field("Job Title", "title", "Developer")}
|
||||
{field("Website", "url", "https://example.com")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailForm() {
|
||||
const { emailData, setEmailData } = useQrStore();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">To</label>
|
||||
<input
|
||||
type="email"
|
||||
value={emailData.to}
|
||||
onChange={(e) => setEmailData({ to: e.target.value })}
|
||||
placeholder="recipient@example.com"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
value={emailData.subject}
|
||||
onChange={(e) => setEmailData({ subject: e.target.value })}
|
||||
placeholder="Subject line"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Body</label>
|
||||
<textarea
|
||||
value={emailData.body}
|
||||
onChange={(e) => setEmailData({ body: e.target.value })}
|
||||
placeholder="Email body..."
|
||||
rows={2}
|
||||
className={TEXTAREA_CLASS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneForm() {
|
||||
const { phoneData, setPhoneData } = useQrStore();
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneData}
|
||||
onChange={(e) => setPhoneData(e.target.value)}
|
||||
placeholder="+1 234 567 890"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmsForm() {
|
||||
const { smsData, setSmsData } = useQrStore();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={smsData.phone}
|
||||
onChange={(e) => setSmsData({ phone: e.target.value })}
|
||||
placeholder="+1 234 567 890"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Message</label>
|
||||
<textarea
|
||||
value={smsData.message}
|
||||
onChange={(e) => setSmsData({ message: e.target.value })}
|
||||
placeholder="Your message..."
|
||||
rows={2}
|
||||
className={TEXTAREA_CLASS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FORM_MAP: Record<ContentType, React.ComponentType> = {
|
||||
url: UrlForm,
|
||||
text: TextForm,
|
||||
wifi: WifiForm,
|
||||
vcard: VCardForm,
|
||||
email: EmailForm,
|
||||
phone: PhoneForm,
|
||||
sms: SmsForm,
|
||||
};
|
||||
|
||||
// ── Pill button helpers ──────────────────────────────────────────────
|
||||
|
||||
function PillButton({
|
||||
selected,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`text-[10px] py-1.5 rounded transition-colors ${
|
||||
selected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main settings component ──────────────────────────────────────────
|
||||
|
||||
export function QrGenerateSettings() {
|
||||
const store = useQrStore();
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasData = !!encodeQrData(store);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const data = encodeQrData(store);
|
||||
if (!data) return;
|
||||
|
||||
const qr = new QRCodeStyling({
|
||||
width: store.size,
|
||||
height: store.size,
|
||||
data,
|
||||
margin: 8,
|
||||
qrOptions: { errorCorrectionLevel: store.errorCorrection },
|
||||
dotsOptions: {
|
||||
type: store.dotType,
|
||||
...(store.dotGradientEnabled
|
||||
? {
|
||||
gradient: {
|
||||
type: store.dotGradientType,
|
||||
rotation: store.dotGradientRotation * (Math.PI / 180),
|
||||
colorStops: [
|
||||
{ offset: 0, color: store.dotGradientColor1 },
|
||||
{ offset: 1, color: store.dotGradientColor2 },
|
||||
],
|
||||
},
|
||||
}
|
||||
: { color: store.dotColor }),
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: store.cornerSquareType,
|
||||
color: store.useCustomCornerColors ? store.cornerSquareColor : undefined,
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: store.cornerDotType,
|
||||
color: store.useCustomCornerColors ? store.cornerDotColor : undefined,
|
||||
},
|
||||
backgroundOptions: store.bgTransparent ? { color: "transparent" } : { color: store.bgColor },
|
||||
...(store.logoDataUrl
|
||||
? {
|
||||
image: store.logoDataUrl,
|
||||
imageOptions: {
|
||||
hideBackgroundDots: store.hideBackgroundDots,
|
||||
imageSize: store.logoSize,
|
||||
margin: store.logoMargin,
|
||||
crossOrigin: "anonymous" as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} as never);
|
||||
|
||||
qr.download({
|
||||
name: "qrcode",
|
||||
extension: store.downloadFormat,
|
||||
});
|
||||
}, [store]);
|
||||
|
||||
const handleLogoUpload = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
store.setLogoFile(file ?? null);
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const ContentForm = FORM_MAP[store.contentType];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Content type tabs ── */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{CONTENT_TYPES.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => store.setContentType(id)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
||||
store.contentType === id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Data entry form ── */}
|
||||
<ContentForm />
|
||||
|
||||
{/* ── Style section ── */}
|
||||
<CollapsibleSection title="Style" defaultOpen>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1.5 block">Dot Pattern</label>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{DOT_TYPES.map(({ value, label }) => (
|
||||
<PillButton
|
||||
key={value}
|
||||
selected={store.dotType === value}
|
||||
onClick={() => store.setDotType(value)}
|
||||
>
|
||||
{label}
|
||||
</PillButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1.5 block">Corner Square</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{CORNER_SQUARE_TYPES.map(({ value, label }) => (
|
||||
<PillButton
|
||||
key={value}
|
||||
selected={store.cornerSquareType === value}
|
||||
onClick={() => store.setCornerSquareType(value)}
|
||||
>
|
||||
{label}
|
||||
</PillButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1.5 block">Corner Dot</label>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{CORNER_DOT_TYPES.map(({ value, label }) => (
|
||||
<PillButton
|
||||
key={value}
|
||||
selected={store.cornerDotType === value}
|
||||
onClick={() => store.setCornerDotType(value)}
|
||||
>
|
||||
{label}
|
||||
</PillButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ── Colors section ── */}
|
||||
<CollapsibleSection title="Colors">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Dot Color</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
type="color"
|
||||
value={store.dotColor}
|
||||
onChange={(e) => store.setDotColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border shrink-0"
|
||||
disabled={store.dotGradientEnabled}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={store.dotColor}
|
||||
onChange={(e) => store.setDotColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
|
||||
disabled={store.dotGradientEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gradient toggle */}
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={store.dotGradientEnabled}
|
||||
onChange={(e) => store.setDotGradientEnabled(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Use gradient
|
||||
</label>
|
||||
<input
|
||||
id="qr-foreground"
|
||||
type="color"
|
||||
value={foreground}
|
||||
onChange={(e) => setForeground(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
/>
|
||||
|
||||
{store.dotGradientEnabled && (
|
||||
<div className="space-y-2 pl-2 border-l-2 border-primary/20 ml-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-muted-foreground">From</label>
|
||||
<input
|
||||
type="color"
|
||||
value={store.dotGradientColor1}
|
||||
onChange={(e) => store.setDotGradientColor1(e.target.value)}
|
||||
className="w-full h-7 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-muted-foreground">To</label>
|
||||
<input
|
||||
type="color"
|
||||
value={store.dotGradientColor2}
|
||||
onChange={(e) => store.setDotGradientColor2(e.target.value)}
|
||||
className="w-full h-7 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<PillButton
|
||||
selected={store.dotGradientType === "linear"}
|
||||
onClick={() => store.setDotGradientType("linear")}
|
||||
>
|
||||
Linear
|
||||
</PillButton>
|
||||
<PillButton
|
||||
selected={store.dotGradientType === "radial"}
|
||||
onClick={() => store.setDotGradientType("radial")}
|
||||
>
|
||||
Radial
|
||||
</PillButton>
|
||||
</div>
|
||||
{store.dotGradientType === "linear" && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-[10px] text-muted-foreground">Rotation</label>
|
||||
<span className="text-[10px] font-mono text-foreground">
|
||||
{store.dotGradientRotation}°
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
step={15}
|
||||
value={store.dotGradientRotation}
|
||||
onChange={(e) => store.setDotGradientRotation(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-muted-foreground">Background</label>
|
||||
<label className="flex items-center gap-1 text-[10px] text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={store.bgTransparent}
|
||||
onChange={(e) => store.setBgTransparent(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Transparent
|
||||
</label>
|
||||
</div>
|
||||
{!store.bgTransparent && (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
type="color"
|
||||
value={store.bgColor}
|
||||
onChange={(e) => store.setBgColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={store.bgColor}
|
||||
onChange={(e) => store.setBgColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom corner colors */}
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={store.useCustomCornerColors}
|
||||
onChange={(e) => store.setUseCustomCornerColors(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Custom corner colors
|
||||
</label>
|
||||
|
||||
{store.useCustomCornerColors && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-muted-foreground">Corner Square</label>
|
||||
<input
|
||||
type="color"
|
||||
value={store.cornerSquareColor}
|
||||
onChange={(e) => store.setCornerSquareColor(e.target.value)}
|
||||
className="w-full h-7 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-muted-foreground">Corner Dot</label>
|
||||
<input
|
||||
type="color"
|
||||
value={store.cornerDotColor}
|
||||
onChange={(e) => store.setCornerDotColor(e.target.value)}
|
||||
className="w-full h-7 rounded border border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="qr-background" className="text-xs text-muted-foreground">
|
||||
Background
|
||||
</label>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ── Logo section ── */}
|
||||
<CollapsibleSection title="Logo">
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
id="qr-background"
|
||||
type="color"
|
||||
value={background}
|
||||
onChange={(e) => setBackground(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleLogoUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
{store.logoDataUrl ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={store.logoDataUrl}
|
||||
alt="Logo"
|
||||
className="w-10 h-10 rounded border border-border object-contain"
|
||||
/>
|
||||
<span className="flex-1 text-xs text-foreground truncate">
|
||||
{store.logoFile?.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => store.setLogoFile(null)}
|
||||
className="p-1 rounded hover:bg-muted"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
className="w-full py-3 rounded-lg border-2 border-dashed border-border text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
Add logo image
|
||||
</button>
|
||||
)}
|
||||
|
||||
{store.logoDataUrl && (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Logo Size</label>
|
||||
<span className="text-xs font-mono text-foreground">
|
||||
{Math.round(store.logoSize * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={0.5}
|
||||
step={0.05}
|
||||
value={store.logoSize}
|
||||
onChange={(e) => store.setLogoSize(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Logo Margin</label>
|
||||
<span className="text-xs font-mono text-foreground">{store.logoMargin}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
value={store.logoMargin}
|
||||
onChange={(e) => store.setLogoMargin(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={store.hideBackgroundDots}
|
||||
onChange={(e) => store.setHideBackgroundDots(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Clean area behind logo
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ── Download section ── */}
|
||||
<CollapsibleSection title="Download" defaultOpen>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1.5 block">Format</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{DOWNLOAD_FORMATS.map(({ value, label, desc }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => store.setDownloadFormat(value)}
|
||||
className={`text-left px-2 py-1.5 rounded transition-colors ${
|
||||
store.downloadFormat === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
<span
|
||||
className={`block text-[10px] ${
|
||||
store.downloadFormat === value
|
||||
? "text-primary-foreground/70"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Size</label>
|
||||
<span className="text-xs font-mono text-foreground">{store.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={200}
|
||||
max={2000}
|
||||
step={100}
|
||||
value={store.size}
|
||||
onChange={(e) => store.setSize(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Error Correction</label>
|
||||
<select
|
||||
value={store.errorCorrection}
|
||||
onChange={(e) => store.setErrorCorrection(e.target.value as "L" | "M" | "Q" | "H")}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
<option value="L">Low (7%) - Max density</option>
|
||||
<option value="M">Medium (15%) - General use</option>
|
||||
<option value="Q">Quartile (25%) - With small logo</option>
|
||||
<option value="H">High (30%) - With large logo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="qr-generate-submit"
|
||||
onClick={handleGenerate}
|
||||
disabled={!text || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Generating..." : "Generate QR Code"}
|
||||
</button>
|
||||
|
||||
{previewUrl && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="QR Code"
|
||||
className="max-w-full rounded border border-border"
|
||||
style={{ maxHeight: 200 }}
|
||||
/>
|
||||
<a
|
||||
href={downloadUrl ?? undefined}
|
||||
download
|
||||
<button
|
||||
type="button"
|
||||
data-testid="qr-generate-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
onClick={handleDownload}
|
||||
disabled={!hasData}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download QR Code
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user