Spaces:
Running
Running
| /** | |
| * File Upload Component with Drag & Drop and Preview | |
| * For image/file upload interfaces | |
| */ | |
| const { useState, useRef, useCallback, useEffect } = window.PreactLib || {}; | |
| export function FileUpload({ | |
| accept = 'image/*', | |
| multiple = true, | |
| maxFiles = null, | |
| onFilesSelected, | |
| className = '' | |
| }) { | |
| const html = window.html; | |
| const [isDragOver, setIsDragOver] = useState(false); | |
| const inputRef = useRef(null); | |
| const handleFiles = useCallback((files) => { | |
| const fileArray = Array.from(files); | |
| if (onFilesSelected) { | |
| onFilesSelected(fileArray); | |
| } | |
| }, [onFilesSelected]); | |
| const onDragOver = useCallback((e) => { | |
| e.preventDefault(); | |
| setIsDragOver(true); | |
| }, []); | |
| const onDragLeave = useCallback((e) => { | |
| e.preventDefault(); | |
| setIsDragOver(false); | |
| }, []); | |
| const onDrop = useCallback((e) => { | |
| e.preventDefault(); | |
| setIsDragOver(false); | |
| handleFiles(e.dataTransfer.files); | |
| }, [handleFiles]); | |
| const onClick = () => { | |
| inputRef.current?.click(); | |
| }; | |
| const onInputChange = (e) => { | |
| handleFiles(e.target.files); | |
| // Reset input so same file can be selected again | |
| e.target.value = ''; | |
| }; | |
| return html` | |
| <div | |
| class="border-2 border-dashed rounded-3 p-5 text-center ${isDragOver ? 'border-primary bg-primary bg-opacity-10' : 'border-secondary'} ${className}" | |
| onDragOver=${onDragOver} | |
| onDragLeave=${onDragLeave} | |
| onDrop=${onDrop} | |
| onClick=${onClick} | |
| style="cursor: pointer; transition: all 0.2s ease;" | |
| > | |
| <input | |
| ref=${inputRef} | |
| type="file" | |
| accept=${accept} | |
| multiple=${multiple} | |
| onChange=${onInputChange} | |
| style="display: none;" | |
| /> | |
| <i class="bi bi-cloud-upload display-4 text-primary mb-3"></i> | |
| <h5 class="mb-2">Drag & Drop files here</h5> | |
| <p class="text-muted mb-0">or click to browse</p> | |
| ${accept ? html`<p class="text-muted small mt-2">Accepted: ${accept}</p>` : ''} | |
| ${multiple && maxFiles ? html`<p class="text-muted small">Max ${maxFiles} files</p>` : ''} | |
| </div> | |
| `; | |
| } | |
| // === File Preview Card === | |
| export function FilePreviewCard({ | |
| file, | |
| index, | |
| onRemove, | |
| onDragStart, | |
| onDrop, | |
| onDragOver, | |
| isDragging = false | |
| }) { | |
| const html = window.html; | |
| const [previewUrl, setPreviewUrl] = useState(null); | |
| useEffect(() => { | |
| if (file && file.type?.startsWith('image/')) { | |
| const url = URL.createObjectURL(file); | |
| setPreviewUrl(url); | |
| return () => URL.revokeObjectURL(url); | |
| } | |
| }, [file]); | |
| const cardClass = isDragging | |
| ? 'preview-card dragging' | |
| : 'preview-card'; | |
| return html` | |
| <div | |
| class="${cardClass}" | |
| draggable="true" | |
| data-index=${index} | |
| onDragStart=${(e) => onDragStart?.(e, index)} | |
| onDragOver=${(e) => onDragOver?.(e, index)} | |
| onDrop=${(e) => onDrop?.(e, index)} | |
| > | |
| ${previewUrl ? html` | |
| <img src=${previewUrl} class="preview-img" alt=${file.name} /> | |
| ` : html` | |
| <div class="preview-img d-flex align-items-center justify-content-center bg-secondary"> | |
| <i class="bi bi-file-earmark display-4"></i> | |
| </div> | |
| `} | |
| <div class="preview-overlay"> | |
| ${file.name} | |
| </div> | |
| <button | |
| class="remove-btn" | |
| onClick=${(e) => { | |
| e.stopPropagation(); | |
| onRemove?.(index); | |
| }} | |
| > | |
| × | |
| </button> | |
| </div> | |
| `; | |
| } | |
| // === File Preview Grid === | |
| export function FilePreviewGrid({ | |
| files = [], | |
| onFilesChange, | |
| sortable = true, | |
| className = '' | |
| }) { | |
| const html = window.html; | |
| const [draggedIndex, setDraggedIndex] = useState(null); | |
| if (files.length === 0) return null; | |
| const removeFile = (index) => { | |
| const newFiles = files.filter((_, i) => i !== index); | |
| onFilesChange?.(newFiles); | |
| }; | |
| const handleDragStart = (e, index) => { | |
| if (!sortable) return; | |
| setDraggedIndex(index); | |
| e.target.classList.add('dragging'); | |
| }; | |
| const handleDragOver = (e, index) => { | |
| if (!sortable || draggedIndex === null) return; | |
| e.preventDefault(); | |
| if (draggedIndex !== index) { | |
| e.currentTarget.classList.add('drag-over'); | |
| } | |
| }; | |
| const handleDragLeave = (e) => { | |
| e.currentTarget.classList.remove('drag-over'); | |
| }; | |
| const handleDrop = (e, dropIndex) => { | |
| if (!sortable || draggedIndex === null) return; | |
| e.preventDefault(); | |
| e.currentTarget.classList.remove('drag-over'); | |
| if (draggedIndex !== dropIndex) { | |
| const newFiles = [...files]; | |
| const [draggedFile] = newFiles.splice(draggedIndex, 1); | |
| newFiles.splice(dropIndex, 0, draggedFile); | |
| onFilesChange?.(newFiles); | |
| } | |
| setDraggedIndex(null); | |
| }; | |
| const handleDragEnd = (e) => { | |
| e.target.classList.remove('dragging'); | |
| setDraggedIndex(null); | |
| document.querySelectorAll('.drag-over').forEach(el => { | |
| el.classList.remove('drag-over'); | |
| }); | |
| }; | |
| return html` | |
| <div class="row g-3 ${className}"> | |
| ${files.map((file, index) => html` | |
| <div class="col-6 col-md-4 col-lg-3"> | |
| <${FilePreviewCard} | |
| file=${file} | |
| index=${index} | |
| onRemove=${removeFile} | |
| onDragStart=${handleDragStart} | |
| onDragOver=${handleDragOver} | |
| onDrop=${handleDrop} | |
| isDragging=${draggedIndex === index} | |
| /> | |
| </div> | |
| `)} | |
| </div> | |
| `; | |
| } | |
| // === Complete Upload Interface === | |
| export function UploadInterface({ | |
| accept = 'image/*', | |
| multiple = true, | |
| maxFiles = null, | |
| onUpload, | |
| className = '' | |
| }) { | |
| const html = window.html; | |
| const [files, setFiles] = useState([]); | |
| const [isUploading, setIsUploading] = useState(false); | |
| const [status, setStatus] = useState(null); | |
| const handleFilesSelected = (newFiles) => { | |
| if (maxFiles && files.length + newFiles.length > maxFiles) { | |
| setStatus({ | |
| type: 'danger', | |
| message: `Maximum ${maxFiles} files allowed` | |
| }); | |
| return; | |
| } | |
| setFiles([...files, ...newFiles]); | |
| setStatus(null); | |
| }; | |
| const handleUpload = async () => { | |
| if (files.length === 0) return; | |
| setIsUploading(true); | |
| setStatus({ type: 'info', message: `Uploading ${files.length} files...` }); | |
| try { | |
| if (onUpload) { | |
| await onUpload(files); | |
| } | |
| setStatus({ type: 'success', message: 'Upload successful!' }); | |
| setFiles([]); | |
| } catch (error) { | |
| setStatus({ type: 'danger', message: `Upload failed: ${error.message}` }); | |
| } finally { | |
| setIsUploading(false); | |
| } | |
| }; | |
| return html` | |
| <div class="${className}"> | |
| <${FileUpload} | |
| accept=${accept} | |
| multiple=${multiple} | |
| onFilesSelected=${handleFilesSelected} | |
| className="mb-4" | |
| /> | |
| ${files.length > 0 ? html` | |
| <> | |
| <h6 class="text-white-50 border-bottom border-secondary pb-2 mb-3"> | |
| Preview ${sortable ? html`<span class="small">(Drag to reorder)</span>` : ''} | |
| </h6> | |
| <${FilePreviewGrid} | |
| files=${files} | |
| onFilesChange=${setFiles} | |
| sortable=${sortable} | |
| className="mb-4" | |
| /> | |
| </> | |
| ` : ''} | |
| ${status ? html` | |
| <div class="alert alert-${status.type} alert-dismissible fade show" role="alert"> | |
| ${status.message} | |
| <button type="button" class="btn-close" data-bs-dismiss="alert"></button> | |
| </div> | |
| ` : ''} | |
| ${files.length > 0 ? html` | |
| <${Button} | |
| variant="success" | |
| size="lg" | |
| className="w-100 py-3" | |
| onClick=${handleUpload} | |
| loading=${isUploading} | |
| > | |
| ${isUploading ? 'Uploading...' : `Upload ${files.length} File${files.length > 1 ? 's' : ''}`} | |
| </${Button}> | |
| ` : ''} | |
| </div> | |
| `; | |
| } | |
| export default FileUpload; | |