Import source: src/components/CenterPanel/QRPreview

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:17:01 +03:30
parent 84163ce082
commit a2966f04ba
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import QRCodeStyling from 'qr-code-styling';
import { useQR } from '../../context/QRContext';
import { useTheme } from '../../context/ThemeContext';
import { encodeQR } from '../../utils/encodeQR';
import { THEME_CARD_BG } from '../../config/styleOptions';
import type { Pattern, CornerSquareStyle, CornerDotStyle } from '../../types';
const DOT_MAP: Record<Pattern, string> = {
square: 'square', dots: 'dots', rounded: 'rounded',
classy: 'classy', 'classy-rounded': 'classy-rounded',
};
const CS_MAP: Record<CornerSquareStyle, string> = {
square: 'square', dot: 'dot', 'extra-rounded': 'extra-rounded',
};
const CD_MAP: Record<CornerDotStyle, string> = { square: 'square', dot: 'dot' };
const SIZE_MAP = { social: 512, card: 1024, print: 2048 };
const DARK_BG: Record<string, string> = {
paper: '#1C1A14', midnight: '#0D0E1A', pastel: '#1A0D12',
};
let qrInstance: QRCodeStyling | null = null;
function buildOptions(state: ReturnType<typeof useQR>['state'], isDark: boolean, size = 340) {
const cardBg = isDark ? DARK_BG[state.theme] : THEME_CARD_BG[state.theme];
const bgColor = state.bgColor ?? cardBg;
const dotColor = state.qrColor === 'rainbow' ? '#7C3AED' : state.qrColor;
const dotsOptions: Record<string, unknown> = state.gradientEnabled
? { type: DOT_MAP[state.pattern], gradient: { type: 'linear', rotation: 45, colorStops: [{ offset: 0, color: dotColor }, { offset: 1, color: state.gradientColor2 }] } }
: { type: DOT_MAP[state.pattern], color: dotColor };
const cornersSquareOptions: Record<string, unknown> = state.gradientEnabled
? { type: CS_MAP[state.cornerSquareStyle], gradient: { type: 'linear', rotation: 45, colorStops: [{ offset: 0, color: dotColor }, { offset: 1, color: state.gradientColor2 }] } }
: { type: CS_MAP[state.cornerSquareStyle], color: dotColor };
const cornersDotOptions: Record<string, unknown> = { type: CD_MAP[state.cornerDotStyle], color: dotColor };
return {
width: size, height: size,
data: encodeQR(state) || 'https://craftqr.app',
dotsOptions, cornersSquareOptions, cornersDotOptions,
backgroundOptions: { color: bgColor },
...(state.logoDataUrl ? { image: state.logoDataUrl, imageOptions: { margin: 6, imageSize: 0.28, crossOrigin: 'anonymous' } } : {}),
qrOptions: { errorCorrectionLevel: state.logoDataUrl ? 'H' : 'M' } as const,
};
}
export function QRPreview() {
const { state } = useQR();
const { isDark } = useTheme();
const cardRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [qrSize, setQrSize] = useState(340);
const [, setAnimKey] = useState(0);
const qrData = encodeQR(state);
const cardBg = isDark ? DARK_BG[state.theme] : THEME_CARD_BG[state.theme];
// Adaptive QR size — tracks card width via ResizeObserver
useEffect(() => {
if (!cardRef.current) return;
const ro = new ResizeObserver(entries => {
const w = entries[0].contentRect.width;
const newSize = Math.min(Math.max(200, Math.floor(w - 48)), 600);
setQrSize(prev => {
if (Math.abs(prev - newSize) > 10) return newSize;
return prev;
});
});
ro.observe(cardRef.current);
return () => ro.disconnect();
}, []);
const initQR = useCallback((size: number) => {
if (!containerRef.current) return;
containerRef.current.replaceChildren();
qrInstance = new QRCodeStyling(buildOptions(state, isDark, size) as never);
qrInstance.append(containerRef.current);
}, []);
useEffect(() => { initQR(qrSize); return () => { qrInstance = null; }; }, [qrSize]);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
if (!qrInstance) return;
qrInstance.update(buildOptions(state, isDark, qrSize) as never);
if (wrapperRef.current) {
wrapperRef.current.classList.remove('qr-appear');
void wrapperRef.current.offsetHeight;
wrapperRef.current.classList.add('qr-appear');
}
setAnimKey(k => k + 1);
}, 280);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [qrData, state.qrColor, state.bgColor, state.pattern, state.logoDataUrl,
state.cornerSquareStyle, state.cornerDotStyle, state.gradientEnabled,
state.gradientColor2, state.theme, isDark, qrSize]);
return (
<div ref={cardRef} className="rounded-[18px] p-4 sm:p-6 flex flex-col items-center gap-3 transition-all duration-300"
style={{ background: cardBg, boxShadow: 'var(--preview-shadow)' }}>
<div ref={wrapperRef} className="qr-appear rounded-xl overflow-hidden relative">
<div ref={containerRef} />
{!qrData && (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-xl"
style={{ background: `${cardBg}CC`, backdropFilter: 'blur(2px)' }}>
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" style={{ opacity: 0.3 }}>
<rect x="2" y="2" width="13" height="13" rx="2" stroke="currentColor" strokeWidth="2"/>
<rect x="21" y="2" width="13" height="13" rx="2" stroke="currentColor" strokeWidth="2"/>
<rect x="2" y="21" width="13" height="13" rx="2" stroke="currentColor" strokeWidth="2"/>
<rect x="5.5" y="5.5" width="6" height="6" rx="1" fill="currentColor" style={{ opacity: 0.5 }}/>
<rect x="24.5" y="5.5" width="6" height="6" rx="1" fill="currentColor" style={{ opacity: 0.5 }}/>
<rect x="5.5" y="24.5" width="6" height="6" rx="1" fill="currentColor" style={{ opacity: 0.5 }}/>
<circle cx="26" cy="26" r="6" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.4 }}/>
<path d="M23 26h6M26 23v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ opacity: 0.4 }}/>
</svg>
<p className="text-[13px] font-medium mt-2" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
Enter content to generate
</p>
</div>
)}
</div>
{state.frameLabel && (
<p className="text-[13px] sm:text-[14px] font-semibold tracking-widest uppercase text-center px-4"
style={{ color: isDark ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.55)', letterSpacing: '0.2em' }}>
{state.frameLabel}
</p>
)}
</div>
);
}
export function downloadQR(state: ReturnType<typeof useQR>['state'], isDark: boolean, format: 'png' | 'svg' = 'png') {
const size = SIZE_MAP[state.downloadSize];
const dl = new QRCodeStyling(buildOptions(state, isDark, size) as never);
dl.download({ name: 'qrcode', extension: format });
}
export async function copyQR(
state: ReturnType<typeof useQR>['state'],
isDark: boolean,
): Promise<void> {
const container = document.createElement('div');
container.style.cssText = 'position:fixed;left:-9999px;top:-9999px;pointer-events:none;';
document.body.appendChild(container);
const temp = new QRCodeStyling(buildOptions(state, isDark, 512) as never);
temp.append(container);
await new Promise(r => setTimeout(r, 220));
try {
const svgEl = container.querySelector('svg');
if (!svgEl) throw new Error('SVG not found');
const blob = new Blob([new XMLSerializer().serializeToString(svgEl)], { type: 'image/svg+xml' });
await navigator.clipboard.write([new ClipboardItem({ 'image/svg+xml': blob })]);
} finally {
document.body.removeChild(container);
}
}