{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"c-file-upload-8","type":"registry:block","title":"Image upload with sorting.","description":"Image upload with sorting.","dependencies":["cn","sonner"],"registryDependencies":["@reui/alert","button","card","progress","@reui/sortable"],"files":[{"path":"c-file-upload-8.tsx","type":"registry:block","content":"\"use client\"\n\nimport { useCallback, useEffect, useState } from \"react\"\nimport {\n  Alert,\n  AlertDescription,\n  AlertTitle,\n} from \"@/components/reui/alert\"\nimport {\n  Sortable,\n  SortableItem,\n  SortableItemHandle,\n} from \"@/components/reui/sortable\"\nimport { cn } from \"cn\"\nimport { toast } from \"sonner\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { Card, CardContent } from \"@/components/ui/card\"\nimport { Progress } from \"@/components/ui/progress\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\ninterface ImageFile {\n  id: string\n  file: File\n  preview: string\n  progress: number\n  status: \"uploading\" | \"completed\" | \"error\"\n  error?: string\n}\n\ntype SortableImage = {\n  id: string\n  src: string\n  alt: string\n  type: \"default\" | \"uploaded\"\n}\n\ninterface ImageUploadProps {\n  maxFiles?: number\n  maxSize?: number\n  accept?: string\n  className?: string\n  onImagesChange?: (images: ImageFile[]) => void\n  onUploadComplete?: (images: ImageFile[]) => void\n}\n\nexport function Pattern({\n  maxFiles = 5, // Changed to 5 as per UI reference\n  maxSize = 10 * 1024 * 1024, // 10MB as per UI reference\n  accept = \"image/*\",\n  className,\n  onImagesChange,\n  onUploadComplete,\n}: ImageUploadProps) {\n  const [images, setImages] = useState<ImageFile[]>([])\n  const [isDragging, setIsDragging] = useState(false)\n  const [errors, setErrors] = useState<string[]>([])\n  const [allImages, setAllImages] = useState<SortableImage[]>([\n    {\n      id: \"default-1\",\n      src: \"https://picsum.photos/1000/800?grayscale&random=6\",\n      alt: \"Product view 1\",\n      type: \"default\",\n    },\n    {\n      id: \"default-2\",\n      src: \"https://picsum.photos/1000/800?grayscale&random=7\",\n      alt: \"Product view 2\",\n      type: \"default\",\n    },\n    {\n      id: \"default-3\",\n      src: \"https://picsum.photos/1000/800?grayscale&random=8\",\n      alt: \"Product view 3\",\n      type: \"default\",\n    },\n    {\n      id: \"default-4\",\n      src: \"https://picsum.photos/1000/800?grayscale&random=9\",\n      alt: \"Product view 4\",\n      type: \"default\",\n    },\n    {\n      id: \"default-5\",\n      src: \"https://picsum.photos/1000/800?grayscale&random=10\",\n      alt: \"Product view 5\",\n      type: \"default\",\n    },\n  ])\n\n  // Helper function to create SortableImage from ImageFile\n  const createSortableImage = useCallback(\n    (imageFile: ImageFile): SortableImage => ({\n      id: imageFile.id,\n      src: imageFile.preview,\n      alt: imageFile.file.name,\n      type: \"uploaded\",\n    }),\n    []\n  )\n\n  // Ensure arrays never contain undefined items\n  useEffect(() => {\n    setAllImages((prev) => prev.filter((item) => item && item.id))\n    setImages((prev) => prev.filter((item) => item && item.id))\n  }, [])\n\n  const validateFile = (file: File): string | null => {\n    if (!file.type.startsWith(\"image/\")) {\n      return \"File must be an image\"\n    }\n    if (file.size > maxSize) {\n      return `File size must be less than ${(maxSize / 1024 / 1024).toFixed(1)}MB`\n    }\n    if (images.length >= maxFiles) {\n      return `Maximum ${maxFiles} files allowed`\n    }\n    return null\n  }\n\n  const addImages = useCallback(\n    (files: FileList | File[]) => {\n      const newImages: ImageFile[] = []\n      const newErrors: string[] = []\n\n      Array.from(files).forEach((file) => {\n        const error = validateFile(file)\n        if (error) {\n          newErrors.push(`${file.name}: ${error}`)\n          return\n        }\n\n        const imageFile: ImageFile = {\n          id: `${Date.now()}-${Math.random()}`,\n          file,\n          preview: URL.createObjectURL(file),\n          progress: 0,\n          status: \"uploading\",\n        }\n\n        newImages.push(imageFile)\n      })\n\n      if (newErrors.length > 0) {\n        setErrors((prev) => [...prev, ...newErrors])\n      }\n\n      if (newImages.length > 0) {\n        const updatedImages = [...images, ...newImages]\n        setImages(updatedImages)\n        onImagesChange?.(updatedImages)\n\n        // Add new images to allImages for sorting\n        const newSortableImages = newImages.map(createSortableImage)\n        setAllImages((prev) => [...prev, ...newSortableImages])\n\n        // Simulate upload progress\n        newImages.forEach((imageFile) => {\n          simulateUpload(imageFile)\n        })\n      }\n    },\n    [images, maxSize, maxFiles, onImagesChange, createSortableImage]\n  )\n\n  const simulateUpload = (imageFile: ImageFile) => {\n    let progress = 0\n    const interval = setInterval(() => {\n      progress += Math.random() * 20\n      if (progress >= 100) {\n        progress = 100\n        clearInterval(interval)\n\n        setImages((prev) =>\n          prev.map((img) =>\n            img.id === imageFile.id\n              ? { ...img, progress: 100, status: \"completed\" as const }\n              : img\n          )\n        )\n\n        // Check if all uploads are complete\n        const updatedImages = images.map((img) =>\n          img.id === imageFile.id\n            ? { ...img, progress: 100, status: \"completed\" as const }\n            : img\n        )\n\n        if (updatedImages.every((img) => img.status === \"completed\")) {\n          onUploadComplete?.(updatedImages)\n        }\n      } else {\n        setImages((prev) =>\n          prev.map((img) =>\n            img.id === imageFile.id ? { ...img, progress } : img\n          )\n        )\n      }\n    }, 100)\n  }\n\n  const removeImage = useCallback(\n    (id: string) => {\n      // Remove from allImages\n      setAllImages((prev) => prev.filter((img) => img.id !== id))\n\n      // If it's an uploaded image, also remove from images array and revoke URL\n      const uploadedImage = images.find((img) => img.id === id)\n      if (uploadedImage) {\n        URL.revokeObjectURL(uploadedImage.preview)\n        setImages((prev) => prev.filter((img) => img.id !== id))\n      }\n    },\n    [images]\n  )\n\n  const handleDragEnter = useCallback((e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n    setIsDragging(true)\n  }, [])\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n    setIsDragging(false)\n  }, [])\n\n  const handleDragOver = useCallback((e: React.DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n  }, [])\n\n  const handleDrop = useCallback(\n    (e: React.DragEvent) => {\n      e.preventDefault()\n      e.stopPropagation()\n      setIsDragging(false)\n\n      const files = e.dataTransfer.files\n      if (files.length > 0) {\n        addImages(files)\n      }\n    },\n    [addImages]\n  )\n\n  const openFileDialog = useCallback(() => {\n    const input = document.createElement(\"input\")\n    input.type = \"file\"\n    input.multiple = true\n    input.accept = accept\n    input.onchange = (e) => {\n      const target = e.target as HTMLInputElement\n      if (target.files) {\n        addImages(target.files)\n      }\n    }\n    input.click()\n  }, [accept, addImages])\n\n  const formatBytes = (bytes: number): string => {\n    if (bytes === 0) return \"0 Bytes\"\n    const k = 1024\n    const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"]\n    const i = Math.floor(Math.log(bytes) / Math.log(k))\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + \" \" + sizes[i]\n  }\n\n  return (\n    <div className={cn(\"w-full max-w-4xl\", className)}>\n      {/* Instructions */}\n      <div className=\"mb-4 text-center\">\n        <p className=\"text-muted-foreground text-sm\">\n          Upload up to {maxFiles} images (JPG, PNG, GIF, WebP, max{\" \"}\n          {formatBytes(maxSize)} each). <br />\n          Drag and drop images to reorder.\n          {images.length > 0 && ` ${images.length}/${maxFiles} uploaded.`}\n        </p>\n      </div>\n\n      {/* Image Grid with Sortable */}\n      <div className=\"mb-6\">\n        {/* Combined Images Sortable */}\n        <Sortable\n          value={allImages.map((item) => item.id)}\n          onValueChange={(newItemIds) => {\n            // Reconstruct the allImages array based on the new order\n            const newAllImages = newItemIds\n              .map((itemId) => {\n                // First try to find in allImages (default images)\n                const existingImage = allImages.find((img) => img.id === itemId)\n                if (existingImage) return existingImage\n\n                // If not found, it's a newly uploaded image\n                const uploadedImage = images.find((img) => img.id === itemId)\n                if (uploadedImage) {\n                  return createSortableImage(uploadedImage)\n                }\n                return null\n              })\n              .filter((item): item is SortableImage => item !== null)\n\n            setAllImages(newAllImages)\n\n            toast.success(\"Images reordered successfully!\", {\n              duration: 3000,\n            })\n          }}\n          getItemValue={(item) => item}\n          strategy=\"grid\"\n          className=\"grid auto-rows-fr grid-cols-5 gap-2.5\"\n        >\n          {allImages.map((item) => (\n            <SortableItem key={item.id} value={item.id}>\n              <div className=\"bg-accent/50 group/item border-border hover:bg-accent/70 relative flex shrink-0 items-center justify-center rounded-md border shadow-none transition-all duration-200 hover:z-10 data-[dragging=true]:z-50\">\n                <img\n                  src={item.src}\n                  className=\"pointer-events-none h-[120px] w-full rounded-md object-cover\"\n                  alt={item.alt}\n                />\n\n                {/* Drag Handle */}\n                <SortableItemHandle className=\"absolute start-2 top-2 cursor-grab opacity-0 group-hover/item:opacity-100 active:cursor-grabbing\">\n                  <Button\n                    variant=\"outline\"\n                    size=\"icon\"\n                    className=\"size-6 rounded-full dark:bg-zinc-800 hover:dark:bg-zinc-700\"\n                  >\n                    <IconPlaceholder\n                      lucide=\"GripVerticalIcon\"\n                      tabler=\"IconGripVertical\"\n                      hugeicons=\"DragDropVerticalIcon\"\n                      phosphor=\"DotsSixVerticalIcon\"\n                      remixicon=\"RiDraggable\"\n                      className=\"size-3.5\"\n                    />\n                  </Button>\n                </SortableItemHandle>\n\n                {/* Remove Button Overlay */}\n                <Button\n                  onClick={() => removeImage(item.id)}\n                  variant=\"outline\"\n                  size=\"icon\"\n                  className=\"absolute end-2 top-2 size-6 rounded-full opacity-0 shadow-sm group-hover/item:opacity-100 dark:bg-zinc-800 hover:dark:bg-zinc-700\"\n                >\n                  <IconPlaceholder\n                    lucide=\"XIcon\"\n                    tabler=\"IconX\"\n                    hugeicons=\"MultiplicationSignIcon\"\n                    phosphor=\"XIcon\"\n                    remixicon=\"RiCloseLine\"\n                    className=\"size-3.5\"\n                  />\n                </Button>\n              </div>\n            </SortableItem>\n          ))}\n        </Sortable>\n      </div>\n\n      {/* Upload Area */}\n      <Card\n        className={cn(\n          \"rounded-md border-dashed shadow-none transition-colors\",\n          isDragging\n            ? \"border-primary bg-primary/5\"\n            : \"border-muted-foreground/25 hover:border-muted-foreground/50\"\n        )}\n        onDragEnter={handleDragEnter}\n        onDragLeave={handleDragLeave}\n        onDragOver={handleDragOver}\n        onDrop={handleDrop}\n      >\n        <CardContent className=\"text-center\">\n          <div className=\"border-border mx-auto mb-3 flex size-[32px] items-center justify-center rounded-full border\">\n            <IconPlaceholder\n              lucide=\"CloudUploadIcon\"\n              tabler=\"IconCloudUpload\"\n              hugeicons=\"CloudUploadIcon\"\n              phosphor=\"CloudArrowUpIcon\"\n              remixicon=\"RiUploadCloud2Line\"\n              className=\"size-4\"\n            />\n          </div>\n          <h3 className=\"text-foreground mb-0.5 text-sm font-medium\">\n            Choose a file or drag & drop here.\n          </h3>\n          <span className=\"text-secondary-foreground mb-3 block text-xs font-normal\">\n            JPEG, PNG, up to {formatBytes(maxSize)}.\n          </span>\n          <Button size=\"sm\" onClick={openFileDialog}>\n            Browse File\n          </Button>\n        </CardContent>\n      </Card>\n\n      {/* Upload Progress Cards */}\n      {images.length > 0 && (\n        <div className=\"mt-6 space-y-3\">\n          {images.map((imageFile) => (\n            <Card key={imageFile.id} className=\"rounded-md shadow-none\">\n              <CardContent className=\"flex items-center gap-2 p-2.5\">\n                <div className=\"border-border flex size-[32px] shrink-0 items-center justify-center rounded-md border\">\n                  <IconPlaceholder\n                    lucide=\"ImageIcon\"\n                    tabler=\"IconPhoto\"\n                    hugeicons=\"ImageIcon\"\n                    phosphor=\"ImageIcon\"\n                    remixicon=\"RiImageLine\"\n                    className=\"text-muted-foreground size-4\"\n                  />\n                </div>\n                <div className=\"flex w-full flex-col gap-1.5\">\n                  <div className=\"-mt-2 flex w-full items-center justify-between gap-2.5\">\n                    <div className=\"flex items-center gap-2.5\">\n                      <span className=\"text-foreground text-xs leading-none font-medium\">\n                        {imageFile.file.name}\n                      </span>\n                      <span className=\"text-muted-foreground text-xs leading-none font-normal\">\n                        {formatBytes(imageFile.file.size)}\n                      </span>\n                      {imageFile.status === \"uploading\" && (\n                        <p className=\"text-muted-foreground text-xs\">\n                          Uploading... {Math.round(imageFile.progress)}%\n                        </p>\n                      )}\n                    </div>\n                    <Button\n                      onClick={() => removeImage(imageFile.id)}\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      className=\"size-6\"\n                    >\n                      <IconPlaceholder\n                        lucide=\"CircleXIcon\"\n                        tabler=\"IconCircleX\"\n                        hugeicons=\"CancelCircleIcon\"\n                        phosphor=\"XCircleIcon\"\n                        remixicon=\"RiCloseCircleLine\"\n                        className=\"size-3.5\"\n                      />\n                    </Button>\n                  </div>\n\n                  <Progress\n                    value={imageFile.progress}\n                    className={cn(\n                      \"h-1 transition-all duration-300\",\n                      \"[&>div]:bg-zinc-950 dark:[&>div]:bg-zinc-50\"\n                    )}\n                  />\n                </div>\n              </CardContent>\n            </Card>\n          ))}\n        </div>\n      )}\n\n      {/* Error Messages */}\n      {errors.length > 0 && (\n        <Alert variant=\"destructive\" className=\"mt-5\">\n          <IconPlaceholder\n            lucide=\"CircleAlertIcon\"\n            tabler=\"IconAlertCircle\"\n            hugeicons=\"AlertCircleIcon\"\n            phosphor=\"WarningCircleIcon\"\n            remixicon=\"RiErrorWarningLine\"\n          />\n          <AlertTitle>File upload error(s)</AlertTitle>\n          <AlertDescription>\n            {errors.map((error, index) => (\n              <p key={index} className=\"last:mb-0\">\n                {error}\n              </p>\n            ))}\n          </AlertDescription>\n        </Alert>\n      )}\n    </div>\n  )\n}","target":"components/examples/c-file-upload-8.tsx"}],"meta":{"order":8,"gridSize":1}}