Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from 'react'; | |
| import axios from 'axios'; | |
| import { motion } from 'framer-motion'; | |
| import { FaPalette, FaFont, FaArrowsAlt, FaChevronLeft, FaChevronRight, FaMousePointer } from 'react-icons/fa'; | |
| const DesignStep = ({ fileData, designParams, setDesignParams, mappings, setMappings, onNext, onBack }) => { | |
| const [previewImage, setPreviewImage] = useState(null); | |
| const [loadingPreview, setLoadingPreview] = useState(false); | |
| const [previewName, setPreviewName] = useState("Sample Name"); | |
| const [pdfSize, setPdfSize] = useState({ width: 595, height: 842 }); | |
| const imageRef = useRef(null); | |
| // Fetch clean preview once on mount | |
| useEffect(() => { | |
| fetchCleanPreview(); | |
| }, []); | |
| const fetchCleanPreview = async () => { | |
| setLoadingPreview(true); | |
| const formData = new FormData(); | |
| formData.append("name", " "); // Space for clean background (avoids empty string issues) | |
| formData.append("name_color", "#000000"); | |
| formData.append("font_size", "60"); | |
| formData.append("fontname", "helv"); | |
| try { | |
| const response = await axios.post("/api/certificates/preview", formData, { | |
| responseType: 'blob' | |
| }); | |
| const imageUrl = URL.createObjectURL(response.data); | |
| setPreviewImage(imageUrl); | |
| } catch (error) { | |
| console.error("Error fetching preview:", error); | |
| if (error.response) { | |
| console.error("Response data:", error.response.data); | |
| console.error("Response status:", error.response.status); | |
| } | |
| } finally { | |
| setLoadingPreview(false); | |
| } | |
| }; | |
| const handleImageLoad = (e) => { | |
| const img = e.target; | |
| setPdfSize({ width: img.naturalWidth, height: img.naturalHeight }); | |
| }; | |
| const handleImageClick = (e) => { | |
| if (!imageRef.current) return; | |
| const rect = imageRef.current.getBoundingClientRect(); | |
| const clickX = e.clientX - rect.left; | |
| const clickY = e.clientY - rect.top; | |
| const scaleX = pdfSize.width / rect.width; | |
| const scaleY = pdfSize.height / rect.height; | |
| const pdfX = clickX * scaleX; | |
| const pdfY = clickY * scaleY; | |
| setDesignParams({ ...designParams, x: Math.round(pdfX), y: Math.round(pdfY) }); | |
| }; | |
| const handleKeyboardMove = (e) => { | |
| if (!designParams.x && !designParams.y) return; | |
| const stepX = e.shiftKey ? 60 : 30; | |
| const stepY = e.shiftKey ? 40 : 20; | |
| let newX = designParams.x || pdfSize.width / 2; | |
| let newY = designParams.y || pdfSize.height / 2; | |
| switch (e.key) { | |
| case 'ArrowLeft': newX -= stepX; break; | |
| case 'ArrowRight': newX += stepX; break; | |
| case 'ArrowUp': newY -= stepY; break; | |
| case 'ArrowDown': newY += stepY; break; | |
| default: return; | |
| } | |
| e.preventDefault(); | |
| setDesignParams({ ...designParams, x: Math.round(newX), y: Math.round(newY) }); | |
| }; | |
| const InputGroup = ({ label, icon: Icon, children }) => ( | |
| <div className="bg-white border border-gray-200 p-5 rounded-lg shadow-sm"> | |
| <div className="flex items-center space-x-2 mb-4 text-blue-600"> | |
| <Icon className="text-lg" /> | |
| <span className="font-semibold text-sm uppercase tracking-wide">{label}</span> | |
| </div> | |
| {children} | |
| </div> | |
| ); | |
| return ( | |
| <div className="flex flex-col lg:flex-row gap-8"> | |
| <motion.div | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| className="w-full lg:w-1/3 space-y-6" | |
| > | |
| <div className="mb-6"> | |
| <h2 className="text-2xl font-bold text-gray-900">Customize Design</h2> | |
| <p className="text-gray-600 text-sm mt-1">Fine-tune the appearance of your certificates.</p> | |
| </div> | |
| <InputGroup label="Data Mapping" icon={FaFont}> | |
| <div className="space-y-3"> | |
| <div> | |
| <label className="block text-xs font-medium text-gray-700 mb-1">Name Column</label> | |
| <select | |
| className="w-full border border-gray-300 rounded-lg p-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none bg-white" | |
| value={mappings.name_column} | |
| onChange={(e) => setMappings({ ...mappings, name_column: e.target.value })} | |
| > | |
| <option value="">Select Column</option> | |
| {fileData.columns.map(col => <option key={col} value={col}>{col}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-medium text-gray-700 mb-1">Email Column</label> | |
| <select | |
| className="w-full border border-gray-300 rounded-lg p-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none bg-white" | |
| value={mappings.email_column} | |
| onChange={(e) => setMappings({ ...mappings, email_column: e.target.value })} | |
| > | |
| <option value="">Select Column</option> | |
| {fileData.columns.map(col => <option key={col} value={col}>{col}</option>)} | |
| </select> | |
| </div> | |
| </div> | |
| </InputGroup> | |
| <InputGroup label="Typography & Color" icon={FaPalette}> | |
| <div className="space-y-4"> | |
| <div> | |
| <div className="flex justify-between text-xs font-medium text-gray-700 mb-1"> | |
| <span>Font Size</span> | |
| <span className="text-blue-600">{designParams.font_size}px</span> | |
| </div> | |
| <input | |
| type="range" min="10" max="200" | |
| value={designParams.font_size} | |
| onChange={(e) => setDesignParams({ ...designParams, font_size: parseInt(e.target.value) })} | |
| className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-medium text-gray-700 mb-1">Text Color</label> | |
| <div className="flex items-center space-x-3"> | |
| <input | |
| type="color" | |
| value={designParams.name_color} | |
| onChange={(e) => setDesignParams({ ...designParams, name_color: e.target.value })} | |
| className="w-10 h-10 rounded-lg cursor-pointer border border-gray-300" | |
| /> | |
| <span className="text-gray-700 font-mono text-sm">{designParams.name_color}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </InputGroup> | |
| <InputGroup label="Positioning" icon={FaArrowsAlt}> | |
| <div className="space-y-3"> | |
| <div className="bg-blue-50 border border-blue-200 rounded-lg p-3"> | |
| <div className="flex items-start space-x-2"> | |
| <FaMousePointer className="text-blue-600 mt-0.5 flex-shrink-0" /> | |
| <div className="text-xs text-blue-700"> | |
| <p className="font-semibold mb-1">Real-Time Positioning</p> | |
| <ul className="space-y-1"> | |
| <li>• Click preview to set position</li> | |
| <li>• Arrow ←→: 30px, ↑↓: 20px (Shift: 2x)</li> | |
| <li>• Drag sliders for smooth control</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="space-y-3"> | |
| <div> | |
| <div className="flex justify-between text-xs font-medium text-gray-700 mb-1"> | |
| <label>X Position</label> | |
| <span className="text-blue-600">{designParams.x || 'Auto'}</span> | |
| </div> | |
| <input | |
| type="range" | |
| min="0" | |
| max={pdfSize.width} | |
| value={designParams.x || pdfSize.width / 2} | |
| onChange={(e) => setDesignParams({ ...designParams, x: parseInt(e.target.value) })} | |
| className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600" | |
| /> | |
| </div> | |
| <div> | |
| <div className="flex justify-between text-xs font-medium text-gray-700 mb-1"> | |
| <label>Y Position</label> | |
| <span className="text-blue-600">{designParams.y || 'Auto'}</span> | |
| </div> | |
| <input | |
| type="range" | |
| min="0" | |
| max={pdfSize.height} | |
| value={designParams.y || pdfSize.height / 2} | |
| onChange={(e) => setDesignParams({ ...designParams, y: parseInt(e.target.value) })} | |
| className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600" | |
| /> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => setDesignParams({ ...designParams, x: null, y: null })} | |
| className="w-full text-xs text-blue-600 hover:text-blue-700 font-medium mt-2" | |
| > | |
| Reset to Auto Center | |
| </button> | |
| </div> | |
| </InputGroup> | |
| <div className="flex space-x-3 pt-4"> | |
| <button | |
| onClick={onBack} | |
| className="flex-1 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors flex items-center justify-center space-x-2 font-medium" | |
| > | |
| <FaChevronLeft className="text-xs" /> | |
| <span>Back</span> | |
| </button> | |
| <button | |
| onClick={onNext} | |
| disabled={!mappings.name_column || !mappings.email_column} | |
| className={`flex-1 py-3 rounded-lg text-white font-semibold shadow-md flex items-center justify-center space-x-2 transition-all ${!mappings.name_column || !mappings.email_column | |
| ? 'bg-gray-300 cursor-not-allowed text-gray-500' | |
| : 'bg-blue-600 hover:bg-blue-700 hover:shadow-lg' | |
| }`} | |
| > | |
| <span>Next Step</span> | |
| <FaChevronRight className="text-xs" /> | |
| </button> | |
| </div> | |
| </motion.div> | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.98 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| className="w-full lg:w-2/3 bg-gray-50 rounded-lg border border-gray-200 p-6 flex items-center justify-center min-h-[600px]" | |
| tabIndex={0} | |
| onKeyDown={handleKeyboardMove} | |
| > | |
| {loadingPreview ? ( | |
| <div className="flex flex-col items-center space-y-4"> | |
| <div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div> | |
| <p className="text-gray-600 font-medium">Loading Preview...</p> | |
| </div> | |
| ) : previewImage ? ( | |
| <div className="relative"> | |
| <img | |
| ref={imageRef} | |
| src={previewImage} | |
| alt="Certificate Preview" | |
| className="max-w-full max-h-full shadow-xl rounded border border-gray-300 cursor-crosshair" | |
| onClick={handleImageClick} | |
| onLoad={handleImageLoad} | |
| /> | |
| {/* Real-time Frontend Text Overlay */} | |
| {designParams.x !== null && designParams.y !== null && imageRef.current && ( | |
| <div | |
| className="absolute pointer-events-none flex items-center justify-center" | |
| style={{ | |
| left: `${(designParams.x / pdfSize.width) * 100}%`, | |
| top: `${(designParams.y / pdfSize.height) * 100}%`, | |
| transform: 'translate(-50%, -50%)', | |
| fontSize: `${(designParams.font_size / pdfSize.width) * imageRef.current.offsetWidth}px`, | |
| color: designParams.name_color, | |
| fontFamily: 'Helvetica, Arial, sans-serif', // Approximate PDF font | |
| fontWeight: 'bold', | |
| whiteSpace: 'nowrap', | |
| lineHeight: 1, | |
| // No transition for instant responsiveness | |
| }} | |
| > | |
| {previewName} | |
| </div> | |
| )} | |
| {designParams.x !== null && designParams.y !== null && ( | |
| <div className="absolute top-2 right-2 bg-black/70 text-white text-xs px-3 py-1 rounded"> | |
| Position: {Math.round(designParams.x)}, {Math.round(designParams.y)} | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="text-gray-400 text-center"> | |
| <p className="text-lg font-medium">Preview Area</p> | |
| <p className="text-sm">Upload files to see a preview</p> | |
| </div> | |
| )} | |
| </motion.div> | |
| </div> | |
| ); | |
| }; | |
| export default DesignStep; | |