| 'use client'; |
|
|
| import React, { useLayoutEffect, useRef, useState } from 'react'; |
| import type { DotType, CornerSquareType, CornerDotType } from 'qr-code-styling'; |
| import type { DetectedLink } from '../lib/platforms'; |
| import { checkQrContrast, MIN_QR_CONTRAST } from '../lib/contrast'; |
| import ColorPicker from './ColorPicker'; |
|
|
| export type LogoMode = 'auto' | 'custom' | 'none'; |
|
|
| export interface QrStyle { |
| dotsColor: string; |
| cornersSquareColor: string; |
| cornersDotColor: string; |
| qrBackgroundColor: string; |
| dotsType: DotType; |
| cornersSquareType: CornerSquareType; |
| cornersDotType: CornerDotType; |
| logoMode: LogoMode; |
| customLogoDataUrl: string | null; |
| bgImageDataUrl: string | null; |
| |
| bgImageSrc: string | null; |
| bgCropZoom: number; |
| |
| bgCropOffset: { x: number; y: number }; |
| imageVisibility: number; |
| } |
|
|
| export const DEFAULT_QR_STYLE: QrStyle = { |
| dotsColor: '#000000', |
| cornersSquareColor: '#000000', |
| cornersDotColor: '#000000', |
| qrBackgroundColor: '#FFFFFF', |
| dotsType: 'rounded', |
| cornersSquareType: 'extra-rounded', |
| cornersDotType: 'dot', |
| logoMode: 'auto', |
| customLogoDataUrl: null, |
| bgImageDataUrl: null, |
| bgImageSrc: null, |
| bgCropZoom: 1, |
| bgCropOffset: { x: 0, y: 0 }, |
| imageVisibility: 0.45, |
| }; |
|
|
| const DARK_PRESETS = ['#000000', '#1f2937', '#2563eb', '#7c3aed', '#dc2626', '#059669', '#db2777', '#b45309']; |
| const LIGHT_PRESETS = ['#FFFFFF', '#f1f5f9', '#fef9c3', '#dbeafe', '#dcfce7', '#fce7f3', '#111827', '#000000']; |
|
|
| const DOT_TYPES: { value: DotType; label: string }[] = [ |
| { value: 'rounded', label: 'Rounded' }, |
| { value: 'dots', label: 'Dots' }, |
| { value: 'classy', label: 'Classy' }, |
| { value: 'classy-rounded', label: 'Classy Round' }, |
| { value: 'square', label: 'Square' }, |
| { value: 'extra-rounded', label: 'Extra Round' }, |
| ]; |
|
|
| const CORNER_SQUARE_TYPES: { value: CornerSquareType; label: string }[] = [ |
| { value: 'extra-rounded', label: 'Rounded' }, |
| { value: 'square', label: 'Square' }, |
| { value: 'dot', label: 'Dot' }, |
| ]; |
|
|
| const CORNER_DOT_TYPES: { value: CornerDotType; label: string }[] = [ |
| { value: 'dot', label: 'Dot' }, |
| { value: 'square', label: 'Square' }, |
| ]; |
|
|
| const MAX_LOGO_BYTES = 1024 * 1024; |
| const MAX_BG_DIMENSION = 1024; |
| const CROP_OUT = 512; |
|
|
| |
| const CARD_COLORS: { name: string; value: string | null }[] = [ |
| { name: 'White', value: null }, |
| { name: 'Transparent', value: 'transparent' }, |
| { name: 'Silver', value: 'linear-gradient(135deg, #fdfbfb, #ebedee)' }, |
| { name: 'Peach Sky', value: 'linear-gradient(135deg, #fff1eb, #ace0f9)' }, |
| { name: 'Cream', value: 'linear-gradient(135deg, #fefce8, #fde68a)' }, |
| { name: 'Lavender', value: 'linear-gradient(135deg, #e0c3fc, #8ec5fc)' }, |
| { name: 'Cloud', value: 'linear-gradient(135deg, #f5f7fa, #c3cfe2)' }, |
| { name: 'Blush', value: '#fdf2f8' }, |
| { name: 'Mint', value: '#f0fdf4' }, |
| ]; |
|
|
| const CARD_IMAGES = [ |
| '/cards/card1.jpg', '/cards/card2.jpg', '/cards/card3.jpg', '/cards/card4.jpg', '/cards/card5.jpg', |
| '/cards/card6.jpg', '/cards/card7.jpg', '/cards/card8.jpg', '/cards/card9.jpg', |
| ]; |
|
|
| interface Gradient { name: string; colors: string[] } |
| interface SolidColor { name: string; color: string } |
|
|
| interface CustomizePanelProps { |
| qrStyle: QrStyle; |
| onStyleChange: (patch: Partial<QrStyle>) => void; |
| link: DetectedLink; |
| onClose: () => void; |
| gradients: Gradient[]; |
| solidColors: SolidColor[]; |
| gradientIndex: number; |
| customColor: string | null; |
| onSelectGradient: (index: number) => void; |
| onSelectSolidColor: (color: string) => void; |
| cardStyle: 'solid' | 'image'; |
| onSetCardStyle: (style: 'solid' | 'image') => void; |
| cardBg: string | null; |
| onSetCardBg: (bg: string | null) => void; |
| cardImage: string; |
| onSetCardImage: (url: string) => void; |
| dockStyle?: React.CSSProperties; |
| } |
|
|
| type PanelTab = 'colors' | 'shapes' | 'logo' | 'image'; |
|
|
| |
| |
| function isLightColor(hex: string): boolean { |
| const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); |
| if (!m) return false; |
| const n = parseInt(m[1], 16); |
| const r = (n >> 16) & 255; |
| const g = (n >> 8) & 255; |
| const b = n & 255; |
| |
| return 0.299 * r + 0.587 * g + 0.114 * b > 230; |
| } |
|
|
| function readFileAsDataUrl(file: File): Promise<string> { |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader(); |
| reader.onload = () => resolve(reader.result as string); |
| reader.onerror = () => reject(reader.error); |
| reader.readAsDataURL(file); |
| }); |
| } |
|
|
| function downscaleImage(dataUrl: string, maxDim: number): Promise<string> { |
| return new Promise((resolve, reject) => { |
| const img = new Image(); |
| img.onload = () => { |
| const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); |
| if (scale >= 1) { |
| resolve(dataUrl); |
| return; |
| } |
| const canvas = document.createElement('canvas'); |
| canvas.width = Math.round(img.width * scale); |
| canvas.height = Math.round(img.height * scale); |
| const ctx = canvas.getContext('2d'); |
| if (!ctx) { |
| resolve(dataUrl); |
| return; |
| } |
| ctx.drawImage(img, 0, 0, canvas.width, canvas.height); |
| resolve(canvas.toDataURL('image/jpeg', 0.85)); |
| }; |
| img.onerror = () => reject(new Error('Could not load image')); |
| img.src = dataUrl; |
| }); |
| } |
|
|
| |
| |
| |
| function renderCrop( |
| src: string, |
| zoom: number, |
| offset: { x: number; y: number }, |
| view: number, |
| out: number |
| ): Promise<string> { |
| return new Promise((resolve, reject) => { |
| const img = new Image(); |
| img.onload = () => { |
| const w = img.naturalWidth; |
| const h = img.naturalHeight; |
| const canvas = document.createElement('canvas'); |
| canvas.width = out; |
| canvas.height = out; |
| const ctx = canvas.getContext('2d'); |
| if (!ctx || !w || !h) { |
| resolve(src); |
| return; |
| } |
| const cover = Math.max(view / w, view / h); |
| const displayScale = cover * zoom; |
| const k = out / view; |
| const destW = w * displayScale * k; |
| const destH = h * displayScale * k; |
| const cx = out / 2 + offset.x * k; |
| const cy = out / 2 + offset.y * k; |
| ctx.fillStyle = '#ffffff'; |
| ctx.fillRect(0, 0, out, out); |
| ctx.drawImage(img, cx - destW / 2, cy - destH / 2, destW, destH); |
| resolve(canvas.toDataURL('image/jpeg', 0.85)); |
| }; |
| img.onerror = () => reject(new Error('Could not load image')); |
| img.src = src; |
| }); |
| } |
|
|
| const CustomizePanel: React.FC<CustomizePanelProps> = ({ |
| qrStyle, |
| onStyleChange, |
| link, |
| onClose, |
| gradients, |
| solidColors, |
| gradientIndex, |
| customColor, |
| onSelectGradient, |
| onSelectSolidColor, |
| cardStyle, |
| onSetCardStyle, |
| cardBg, |
| onSetCardBg, |
| cardImage, |
| onSetCardImage, |
| dockStyle, |
| }) => { |
| const [tab, setTab] = useState<PanelTab>('colors'); |
| const logoInputRef = useRef<HTMLInputElement>(null); |
| const bgInputRef = useRef<HTMLInputElement>(null); |
|
|
| |
| const [cropSrc, setCropSrc] = useState<string | null>(null); |
| const [cropZoom, setCropZoom] = useState(1); |
| const [cropOffset, setCropOffset] = useState({ x: 0, y: 0 }); |
| const cropDimsRef = useRef({ w: 0, h: 0 }); |
| const cropViewRef = useRef<HTMLDivElement>(null); |
| const pendingOffsetRef = useRef({ x: 0, y: 0 }); |
| const dragRef = useRef<{ sx: number; sy: number; ox: number; oy: number } | null>(null); |
|
|
| |
| const clampOffset = (x: number, y: number, zoom: number, view: number) => { |
| const { w, h } = cropDimsRef.current; |
| if (!w || !h) return { x, y }; |
| const displayScale = Math.max(view / w, view / h) * zoom; |
| const maxX = Math.max(0, (w * displayScale - view) / 2); |
| const maxY = Math.max(0, (h * displayScale - view) / 2); |
| return { |
| x: Math.max(-maxX, Math.min(maxX, x)), |
| y: Math.max(-maxY, Math.min(maxY, y)), |
| }; |
| }; |
|
|
| const openCrop = (src: string, zoom = 1, normOffset = { x: 0, y: 0 }) => { |
| cropDimsRef.current = { w: 0, h: 0 }; |
| setCropZoom(zoom); |
| pendingOffsetRef.current = normOffset; |
| setCropOffset({ x: 0, y: 0 }); |
| setCropSrc(src); |
| }; |
|
|
| |
| |
| useLayoutEffect(() => { |
| if (!cropSrc) return; |
| const view = cropViewRef.current?.clientWidth ?? 240; |
| setCropOffset({ |
| x: pendingOffsetRef.current.x * view, |
| y: pendingOffsetRef.current.y * view, |
| }); |
| }, [cropSrc]); |
|
|
| const handleCropZoom = (zoom: number) => { |
| const view = cropViewRef.current?.clientWidth ?? 240; |
| setCropZoom(zoom); |
| setCropOffset((o) => clampOffset(o.x, o.y, zoom, view)); |
| }; |
|
|
| const handleCropPointerDown = (e: React.PointerEvent) => { |
| try { |
| e.currentTarget.setPointerCapture?.(e.pointerId); |
| } catch { |
| |
| } |
| dragRef.current = { sx: e.clientX, sy: e.clientY, ox: cropOffset.x, oy: cropOffset.y }; |
| }; |
|
|
| const handleCropPointerMove = (e: React.PointerEvent) => { |
| if (!dragRef.current) return; |
| const view = cropViewRef.current?.clientWidth ?? 240; |
| const nx = dragRef.current.ox + (e.clientX - dragRef.current.sx); |
| const ny = dragRef.current.oy + (e.clientY - dragRef.current.sy); |
| setCropOffset(clampOffset(nx, ny, cropZoom, view)); |
| }; |
|
|
| const handleCropPointerUp = () => { |
| dragRef.current = null; |
| }; |
|
|
| const applyCrop = async () => { |
| if (!cropSrc) return; |
| const view = cropViewRef.current?.clientWidth ?? 240; |
| try { |
| const out = await renderCrop(cropSrc, cropZoom, cropOffset, view, CROP_OUT); |
| onStyleChange({ |
| bgImageDataUrl: out, |
| bgImageSrc: cropSrc, |
| bgCropZoom: cropZoom, |
| bgCropOffset: { x: cropOffset.x / view, y: cropOffset.y / view }, |
| }); |
| } catch { |
| alert('Could not process that image.'); |
| } |
| setCropSrc(null); |
| }; |
|
|
| const contrast = checkQrContrast(qrStyle.dotsColor, qrStyle.qrBackgroundColor); |
| const imageQrActive = Boolean(qrStyle.bgImageDataUrl); |
|
|
| const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { |
| const file = e.target.files?.[0]; |
| e.target.value = ''; |
| if (!file) return; |
| if (file.size > MAX_LOGO_BYTES) { |
| alert('Logo image must be under 1 MB.'); |
| return; |
| } |
| try { |
| const dataUrl = await readFileAsDataUrl(file); |
| onStyleChange({ customLogoDataUrl: dataUrl, logoMode: 'custom' }); |
| } catch { |
| alert('Could not read that image file.'); |
| } |
| }; |
|
|
| const handleBgUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { |
| const file = e.target.files?.[0]; |
| e.target.value = ''; |
| if (!file) return; |
| try { |
| const dataUrl = await readFileAsDataUrl(file); |
| const scaled = await downscaleImage(dataUrl, MAX_BG_DIMENSION); |
| openCrop(scaled); |
| } catch { |
| alert('Could not read that image file.'); |
| } |
| }; |
|
|
| const colorRow = ( |
| label: string, |
| value: string, |
| presets: string[], |
| apply: (color: string) => void, |
| disabled = false |
| ) => ( |
| <div className={`qr-color-row${disabled ? ' disabled' : ''}`}> |
| <span className="qr-color-row-label">{label}</span> |
| <div className="qr-swatch-row"> |
| {presets.map((color) => ( |
| <button |
| key={color} |
| className={`qr-mini-swatch ${value.toLowerCase() === color.toLowerCase() ? 'active' : ''}`} |
| style={{ background: color }} |
| onClick={() => apply(color)} |
| title={color} |
| disabled={disabled} |
| /> |
| ))} |
| <ColorPicker value={value} onChange={apply} disabled={disabled} /> |
| </div> |
| </div> |
| ); |
|
|
| const shapeRow = <T extends string>( |
| label: string, |
| value: T, |
| options: { value: T; label: string }[], |
| apply: (v: T) => void |
| ) => ( |
| <div className="qr-color-section"> |
| <h4>{label}</h4> |
| <div className="qr-shape-grid"> |
| {options.map((opt) => ( |
| <button |
| key={opt.value} |
| className={`qr-shape-option ${value === opt.value ? 'active' : ''}`} |
| onClick={() => apply(opt.value)} |
| > |
| {opt.label} |
| </button> |
| ))} |
| </div> |
| </div> |
| ); |
|
|
| return ( |
| <div className="qr-customize-panel" style={dockStyle} onClick={(e) => e.stopPropagation()}> |
| <div className="qr-color-picker-header"> |
| <h3>Customize</h3> |
| <button onClick={onClose} className="qr-color-close" aria-label="Close">×</button> |
| </div> |
| |
| <div className="qr-tabs"> |
| {([ |
| ['colors', 'Colors'], |
| ['shapes', 'Shapes'], |
| ['logo', 'Logo'], |
| ['image', 'Image'], |
| ] as [PanelTab, string][]).map(([id, label]) => ( |
| <button |
| key={id} |
| className={`qr-tab ${tab === id ? 'active' : ''}`} |
| onClick={() => setTab(id)} |
| > |
| {label} |
| </button> |
| ))} |
| </div> |
| |
| {tab === 'colors' && ( |
| <> |
| <div className="qr-color-section"> |
| <h4>QR Code</h4> |
| {colorRow('Dots', qrStyle.dotsColor, DARK_PRESETS, (c) => |
| onStyleChange({ dotsColor: c }), imageQrActive |
| )} |
| {colorRow('Corners', qrStyle.cornersSquareColor, DARK_PRESETS, (c) => |
| onStyleChange({ cornersSquareColor: c }), imageQrActive |
| )} |
| {colorRow('Corner dots', qrStyle.cornersDotColor, DARK_PRESETS, (c) => |
| onStyleChange({ cornersDotColor: c }), imageQrActive |
| )} |
| {!qrStyle.bgImageDataUrl && |
| colorRow('Background', qrStyle.qrBackgroundColor, LIGHT_PRESETS, (c) => |
| onStyleChange({ qrBackgroundColor: c }) |
| )} |
| {imageQrActive && ( |
| <p className="qr-panel-note"> |
| Dot and corner colors are disabled while an image QR is active. |
| The photo shows through the dark modules, so they stay black to |
| keep the code scannable. |
| </p> |
| )} |
| </div> |
| |
| {!qrStyle.bgImageDataUrl && |
| !contrast.ok && ( |
| <div className="qr-contrast-warning"> |
| {contrast.inverted |
| ? 'Light dots on a dark background fail in many scanner apps.' |
| : `Low contrast (${contrast.ratio.toFixed(1)}:1, needs ${MIN_QR_CONTRAST}:1). This QR may not scan reliably.`} |
| <button |
| className="qr-contrast-reset" |
| onClick={() => |
| onStyleChange({ |
| dotsColor: '#000000', |
| cornersSquareColor: '#000000', |
| cornersDotColor: '#000000', |
| qrBackgroundColor: '#FFFFFF', |
| }) |
| } |
| > |
| Reset to black & white |
| </button> |
| </div> |
| )} |
| |
| <div className="qr-color-section"> |
| <h4>Card Style</h4> |
| <div className="qr-shape-grid qr-shape-grid-2"> |
| <button |
| className={`qr-shape-option ${cardStyle === 'solid' ? 'active' : ''}`} |
| onClick={() => onSetCardStyle('solid')} |
| > |
| Solid |
| </button> |
| <button |
| className={`qr-shape-option ${cardStyle === 'image' ? 'active' : ''}`} |
| onClick={() => onSetCardStyle('image')} |
| > |
| Image |
| </button> |
| </div> |
| {cardStyle === 'solid' && ( |
| <div className="qr-color-grid" style={{ marginTop: 10 }}> |
| {CARD_COLORS.map((c) => ( |
| <button |
| key={c.name} |
| className={`qr-color-swatch qr-card-swatch ${c.value === 'transparent' ? 'qr-swatch-transparent' : ''} ${cardBg === c.value ? 'active' : ''}`} |
| style={c.value === 'transparent' ? undefined : { background: c.value ?? '#ffffff' }} |
| onClick={() => onSetCardBg(c.value)} |
| title={c.name} |
| /> |
| ))} |
| </div> |
| )} |
| {cardStyle === 'image' && ( |
| <div className="qr-color-grid" style={{ marginTop: 10 }}> |
| {CARD_IMAGES.map((src) => ( |
| <button |
| key={src} |
| className={`qr-color-swatch qr-card-image-swatch ${cardImage === src ? 'active' : ''}`} |
| style={{ backgroundImage: `url(${src})` }} |
| onClick={() => onSetCardImage(src)} |
| title="Card image" |
| /> |
| ))} |
| </div> |
| )} |
| </div> |
| {/* The background sections tint the area behind the card, which only |
| shows for solid cards — hide them when the card is a photo. */} |
| {cardStyle !== 'image' && ( |
| <> |
| <div className="qr-color-section"> |
| <h4>Card Background</h4> |
| <div className="qr-color-grid"> |
| {gradients.map((gradient, index) => ( |
| <button |
| key={index} |
| className={`qr-color-swatch ${gradientIndex === index && !customColor ? 'active' : ''}`} |
| style={{ background: `linear-gradient(135deg, ${gradient.colors.join(', ')})` }} |
| onClick={() => onSelectGradient(index)} |
| title={gradient.name} |
| /> |
| ))} |
| </div> |
| </div> |
| <div className="qr-color-section"> |
| <h4>Card Solid Colors</h4> |
| <div className="qr-color-grid"> |
| {solidColors.map((color, index) => ( |
| <button |
| key={index} |
| className={`qr-color-swatch ${isLightColor(color.color) ? 'qr-card-swatch' : ''} ${customColor === color.color ? 'active' : ''}`} |
| style={{ background: color.color }} |
| onClick={() => onSelectSolidColor(color.color)} |
| title={color.name} |
| /> |
| ))} |
| </div> |
| </div> |
| </> |
| )} |
| </> |
| )} |
|
|
| {tab === 'shapes' && ( |
| <> |
| {shapeRow('Dots', qrStyle.dotsType, DOT_TYPES, (v) => onStyleChange({ dotsType: v }))} |
| {shapeRow('Corner frames', qrStyle.cornersSquareType, CORNER_SQUARE_TYPES, (v) => |
| onStyleChange({ cornersSquareType: v }) |
| )} |
| {shapeRow('Corner dots', qrStyle.cornersDotType, CORNER_DOT_TYPES, (v) => |
| onStyleChange({ cornersDotType: v }) |
| )} |
| </> |
| )} |
|
|
| {tab === 'logo' && ( |
| <div className="qr-color-section"> |
| <h4>Center Logo</h4> |
| <div className="qr-logo-options"> |
| <button |
| className={`qr-logo-option ${qrStyle.logoMode === 'auto' ? 'active' : ''}`} |
| onClick={() => onStyleChange({ logoMode: 'auto' })} |
| > |
| <img src={link.logoUrl} alt="" className="qr-logo-thumb" /> |
| <span>Auto ({link.platform?.name ?? link.hostname})</span> |
| </button> |
| <button |
| className={`qr-logo-option ${qrStyle.logoMode === 'custom' ? 'active' : ''}`} |
| onClick={() => |
| qrStyle.customLogoDataUrl |
| ? onStyleChange({ logoMode: 'custom' }) |
| : logoInputRef.current?.click() |
| } |
| > |
| {qrStyle.customLogoDataUrl ? ( |
| <img src={qrStyle.customLogoDataUrl} alt="" className="qr-logo-thumb" /> |
| ) : ( |
| <span className="qr-logo-thumb qr-logo-thumb-empty">+</span> |
| )} |
| <span>Your logo</span> |
| </button> |
| <button |
| className={`qr-logo-option ${qrStyle.logoMode === 'none' ? 'active' : ''}`} |
| onClick={() => onStyleChange({ logoMode: 'none' })} |
| > |
| <span className="qr-logo-thumb qr-logo-thumb-empty">∅</span> |
| <span>No logo</span> |
| </button> |
| </div> |
| <div className="qr-upload-row"> |
| <button className="qr-upload-btn" onClick={() => logoInputRef.current?.click()}> |
| {qrStyle.customLogoDataUrl ? 'Replace uploaded logo' : 'Upload a logo'} |
| </button> |
| {qrStyle.customLogoDataUrl && ( |
| <button |
| className="qr-upload-btn danger" |
| onClick={() => onStyleChange({ customLogoDataUrl: null, logoMode: 'auto' })} |
| > |
| Remove |
| </button> |
| )} |
| </div> |
| <input |
| ref={logoInputRef} |
| type="file" |
| accept="image/png,image/jpeg,image/svg+xml,image/webp" |
| style={{ display: 'none' }} |
| onChange={handleLogoUpload} |
| /> |
| </div> |
| )} |
|
|
| {tab === 'image' && ( |
| <div className="qr-color-section"> |
| <h4>Image QR</h4> |
| {cropSrc ? ( |
| <> |
| <div |
| ref={cropViewRef} |
| className="qr-crop-view" |
| onPointerDown={handleCropPointerDown} |
| onPointerMove={handleCropPointerMove} |
| onPointerUp={handleCropPointerUp} |
| onPointerCancel={handleCropPointerUp} |
| > |
| <img |
| src={cropSrc} |
| alt="Crop preview" |
| className="qr-crop-img" |
| draggable={false} |
| style={{ transform: `translate(${cropOffset.x}px, ${cropOffset.y}px) scale(${cropZoom})` }} |
| onLoad={(e) => { |
| cropDimsRef.current = { |
| w: e.currentTarget.naturalWidth, |
| h: e.currentTarget.naturalHeight, |
| }; |
| }} |
| /> |
| <div className="qr-crop-grid" /> |
| </div> |
| <div className="qr-slider-row"> |
| <span className="qr-color-row-label">Zoom</span> |
| <input |
| type="range" |
| className="qr-slider" |
| min={100} |
| max={300} |
| value={Math.round(cropZoom * 100)} |
| onChange={(e) => handleCropZoom(Number(e.target.value) / 100)} |
| /> |
| <span className="qr-slider-value">{cropZoom.toFixed(1)}x</span> |
| </div> |
| <div className="qr-upload-row"> |
| <button className="qr-upload-btn primary" onClick={applyCrop}> |
| Apply |
| </button> |
| <button className="qr-upload-btn" onClick={() => setCropSrc(null)}> |
| Cancel |
| </button> |
| </div> |
| <p className="qr-panel-note">Drag to reposition and zoom to fill the frame.</p> |
| </> |
| ) : qrStyle.bgImageDataUrl ? ( |
| <> |
| <img src={qrStyle.bgImageDataUrl} alt="Background preview" className="qr-bg-preview" /> |
| <div className="qr-slider-row"> |
| <span className="qr-color-row-label">Visibility</span> |
| <input |
| type="range" |
| className="qr-slider" |
| min={0} |
| max={100} |
| value={Math.round(qrStyle.imageVisibility * 100)} |
| onChange={(e) => |
| onStyleChange({ imageVisibility: Number(e.target.value) / 100 }) |
| } |
| /> |
| <span className="qr-slider-value">{Math.round(qrStyle.imageVisibility * 100)}%</span> |
| </div> |
| <div className="qr-upload-row"> |
| <button |
| className="qr-upload-btn" |
| onClick={() => |
| openCrop( |
| qrStyle.bgImageSrc ?? qrStyle.bgImageDataUrl!, |
| qrStyle.bgCropZoom, |
| qrStyle.bgCropOffset |
| ) |
| } |
| > |
| Crop / Zoom |
| </button> |
| <button className="qr-upload-btn" onClick={() => bgInputRef.current?.click()}> |
| Replace |
| </button> |
| <button |
| className="qr-upload-btn danger" |
| onClick={() => |
| onStyleChange({ |
| bgImageDataUrl: null, |
| bgImageSrc: null, |
| bgCropZoom: 1, |
| bgCropOffset: { x: 0, y: 0 }, |
| }) |
| } |
| > |
| Remove |
| </button> |
| </div> |
| </> |
| ) : ( |
| <div className="qr-upload-row"> |
| <button className="qr-upload-btn" onClick={() => bgInputRef.current?.click()}> |
| Upload an image |
| </button> |
| </div> |
| )} |
| <input |
| ref={bgInputRef} |
| type="file" |
| accept="image/png,image/jpeg,image/webp" |
| style={{ display: 'none' }} |
| onChange={handleBgUpload} |
| /> |
| </div> |
| )} |
| </div> |
| ); |
| }; |
|
|
| export default CustomizePanel; |
|
|