Spaces:
Paused
Paused
| '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; | |
| /** The uploaded photo before cropping, so re-cropping starts from it. */ | |
| bgImageSrc: string | null; | |
| bgCropZoom: number; | |
| /** Pan offset normalized to the crop viewport side. */ | |
| 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; // square output the QR pattern is cut from | |
| // Light tints only: the card must keep dark text and QR contrast readable. | |
| 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'; | |
| // Very light swatches (e.g. white) need an outline + dark check to be visible | |
| // against the white panel. | |
| 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; | |
| // Perceived luminance (0–255); above ~230 reads as "near-white". | |
| 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; | |
| }); | |
| } | |
| // Bake the user's zoom/pan into a square image, matching exactly what the | |
| // crop viewport shows: `view` is the on-screen viewport side in px, the image | |
| // is object-fit: cover then scaled by `zoom` and shifted by `offset` px. | |
| 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; // viewport px -> output px | |
| 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); | |
| // Crop / zoom editor for the Image-QR photo | |
| 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); | |
| // Keep the photo covering the frame — no empty gaps at the edges. | |
| 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); | |
| }; | |
| // Restore the saved pan once the crop viewport exists; offsets are stored | |
| // normalized to the viewport side so they survive layout size changes. | |
| 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 { | |
| // Capture is best-effort; dragging still works without it. | |
| } | |
| 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; | |