All files / src/features/flyer BulkImporter.tsx

100% Statements 35/35
96.29% Branches 26/27
100% Functions 13/13
100% Lines 33/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147                        1x 44x 44x     44x 20x 9x   20x     20x 20x 9x         44x   7x   7x   9x   1x     7x 6x 6x 6x             44x 7x 6x     7x     44x 2x 1x 1x     44x         44x 44x       44x                                                                         18x                                       1x                          
// src/features/flyer/BulkImporter.tsx
import React, { useCallback, useState, useEffect } from 'react';
import { UploadIcon } from '../../components/icons/UploadIcon';
import { useDragAndDrop } from '../../hooks/useDragAndDrop';
import { XMarkIcon } from '../../components/icons/XMarkIcon';
import { DocumentTextIcon } from '../../components/icons/DocumentTextIcon';
 
interface BulkImporterProps {
  onFilesChange: (files: File[]) => void;
  isProcessing: boolean;
}
 
export const BulkImporter: React.FC<BulkImporterProps> = ({ onFilesChange, isProcessing }) => {
  const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
  const [previewUrls, setPreviewUrls] = useState<string[]>([]);
 
  // Effect to create and revoke object URLs for image previews
  useEffect(() => {
    const newUrls = selectedFiles.map((file) =>
      file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
    );
    setPreviewUrls(newUrls);
 
    // Cleanup function to revoke URLs when component unmounts or files change
    return () => {
      newUrls.forEach((url) => {
        if (url) URL.revokeObjectURL(url);
      });
    };
  }, [selectedFiles]);
 
  const handleFiles = useCallback(
    (files: FileList) => {
      Eif (files && files.length > 0 && !isProcessing) {
        // Prevent duplicates by checking file names and sizes
        const newFiles = Array.from(files).filter(
          (newFile) =>
            !selectedFiles.some(
              (existingFile) =>
                existingFile.name === newFile.name && existingFile.size === newFile.size,
            ),
        );
        if (newFiles.length > 0) {
          const updatedFiles = [...selectedFiles, ...newFiles];
          setSelectedFiles(updatedFiles);
          onFilesChange(updatedFiles); // Call parent callback directly
        }
      }
    },
    [isProcessing, selectedFiles, onFilesChange],
  );
 
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      handleFiles(e.target.files);
    }
    // Reset input value to allow selecting the same file again
    e.target.value = '';
  };
 
  const removeFile = (index: number) => {
    const updatedFiles = selectedFiles.filter((_, i) => i !== index);
    setSelectedFiles(updatedFiles);
    onFilesChange(updatedFiles); // Also notify parent on removal
  };
 
  const { isDragging, dropzoneProps } = useDragAndDrop<HTMLLabelElement>({
    onFilesDropped: handleFiles,
    disabled: isProcessing,
  });
 
  const borderColor = isDragging ? 'border-brand-primary' : 'border-gray-300 dark:border-gray-600';
  const bgColor = isDragging
    ? 'bg-brand-light/50 dark:bg-brand-dark/20'
    : 'bg-gray-50 dark:bg-gray-800';
 
  return (
    <div className="w-full">
      <label
        htmlFor="bulk-file-upload"
        {...dropzoneProps}
        className={`flex flex-col items-center justify-center w-full h-48 border-2 ${borderColor} ${bgColor} border-dashed rounded-lg transition-colors duration-300 ${isProcessing ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700'}`}
      >
        <div className="flex flex-col items-center justify-center pt-5 pb-6 text-center">
          <UploadIcon className="w-10 h-10 mb-3 text-gray-400" />
          {isProcessing ? (
            <p className="mb-2 text-sm text-gray-600 dark:text-gray-300 font-semibold">
              Processing, please wait...
            </p>
          ) : (
            <>
              <p className="mb-2 text-sm text-gray-500 dark:text-gray-400">
                <span className="font-semibold text-brand-primary">Click to upload</span> or drag
                and drop
              </p>
              <p className="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, WEBP, or PDF</p>
            </>
          )}
        </div>
        <input
          id="bulk-file-upload"
          type="file"
          className="absolute w-px h-px p-0 -m-px overflow-hidden clip-rect-0 whitespace-nowrap border-0"
          accept="image/png, image/jpeg, image/webp, application/pdf"
          onChange={handleFileChange}
          disabled={isProcessing}
          multiple
        />
      </label>
 
      {selectedFiles.length > 0 && (
        <div className="mt-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
          {selectedFiles.map((file, index) => (
            <div
              key={index}
              className="relative group border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
            >
              {previewUrls[index] ? (
                <img
                  src={previewUrls[index]}
                  alt={file.name}
                  className="h-32 w-full object-cover"
                />
              ) : (
                <div className="h-32 w-full flex items-center justify-center bg-gray-100 dark:bg-gray-700">
                  <DocumentTextIcon className="w-12 h-12 text-gray-400" />
                </div>
              )}
              <div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-xs p-1 truncate">
                {file.name}
              </div>
              <button
                type="button"
                onClick={() => removeFile(index)}
                className="absolute top-1 right-1 bg-red-600 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
                aria-label={`Remove ${file.name}`}
              >
                <XMarkIcon className="w-4 h-4" />
              </button>
            </div>
          ))}
        </div>
      )}
    </div>
  );
};