Реалізація Drag-and-Drop завантаження файлів на сайт

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Drag-and-Drop завантаження файлів на сайт
Середня
від 1 робочого дня до 3 робочих днів
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація Drag-and-Drop завантаження файлів на веб-сайт

Drag-and-drop завантаження — перетягування файлів із файлового менеджера прямо в браузер. Зменшує кількість кліків і сприймається як ознака якісного інтерфейсу в завданнях із частим завантаженням файлів: менеджери документів, CMS, особисті кабінети з вкладеннями.

Браузерний Drag and Drop API

// hooks/useDragAndDrop.ts
import { useState, useCallback, DragEvent } from 'react'

interface UseDragAndDropOptions {
  onDrop: (files: File[]) => void
  accept?: string[] // MIME-типи: ['image/jpeg', 'image/png']
  disabled?: boolean
}

export function useDragAndDrop({ onDrop, accept, disabled }: UseDragAndDropOptions) {
  const [isDragOver, setIsDragOver] = useState(false)
  const [isDragActive, setIsDragActive] = useState(false)

  const handleDragEnter = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    if (disabled) return
    setIsDragActive(true)
    // Перевіряємо, що перетаскуються файли
    if (e.dataTransfer.items?.length > 0) {
      setIsDragOver(true)
    }
  }, [disabled])

  const handleDragLeave = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    // Перевіряємо, що ушли з зони повністю (не на дочірній елемент)
    if (e.currentTarget.contains(e.relatedTarget as Node)) return
    setIsDragOver(false)
    setIsDragActive(false)
  }, [])

  const handleDragOver = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.dataTransfer.dropEffect = 'copy'
  }, [])

  const handleDrop = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragOver(false)
    setIsDragActive(false)

    if (disabled) return

    const droppedFiles = Array.from(e.dataTransfer.files)

    const filtered = accept
      ? droppedFiles.filter(f => accept.some(mime => {
          if (mime.endsWith('/*')) {
            return f.type.startsWith(mime.replace('/*', '/'))
          }
          return f.type === mime
        }))
      : droppedFiles

    if (filtered.length > 0) {
      onDrop(filtered)
    }
  }, [onDrop, accept, disabled])

  return {
    isDragOver,
    isDragActive,
    dropZoneProps: {
      onDragEnter: handleDragEnter,
      onDragLeave: handleDragLeave,
      onDragOver: handleDragOver,
      onDrop: handleDrop,
    },
  }
}

Компонент зони скидання

// components/DropZone.tsx
import { useRef } from 'react'
import { useDragAndDrop } from '@/hooks/useDragAndDrop'
import { cn } from '@/lib/utils'

interface DropZoneProps {
  onFiles: (files: File[]) => void
  accept?: string[]
  maxFiles?: number
  disabled?: boolean
  children?: React.ReactNode
}

export function DropZone({ onFiles, accept, maxFiles, disabled, children }: DropZoneProps) {
  const inputRef = useRef<HTMLInputElement>(null)

  const { isDragOver, isDragActive, dropZoneProps } = useDragAndDrop({
    onDrop: onFiles,
    accept,
    disabled,
  })

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      onFiles(Array.from(e.target.files).slice(0, maxFiles))
      e.target.value = '' // скидаємо, щоб можна було вибрати той же файл знову
    }
  }

  return (
    <div
      {...dropZoneProps}
      onClick={() => !disabled && inputRef.current?.click()}
      role="button"
      tabIndex={disabled ? -1 : 0}
      aria-disabled={disabled}
      onKeyDown={e => e.key === 'Enter' && !disabled && inputRef.current?.click()}
      className={cn(
        'relative border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
        'focus:outline-none focus:ring-2 focus:ring-primary',
        isDragOver && 'border-primary bg-primary/5',
        isDragActive && 'border-primary',
        !isDragOver && 'border-muted-foreground/30 hover:border-muted-foreground/60',
        disabled && 'opacity-50 cursor-not-allowed',
      )}
    >
      {/* Overlay при перетаскуванні */}
      {isDragOver && (
        <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-primary/10">
          <p className="text-lg font-medium text-primary">Відпустіть для завантаження</p>
        </div>
      )}

      {children ?? (
        <div className="flex flex-col items-center gap-3 pointer-events-none">
          <UploadIcon className="w-10 h-10 text-muted-foreground" />
          <div>
            <p className="font-medium">Перетягніть файли або натисніть для вибору</p>
            <p className="text-sm text-muted-foreground mt-1">
              {accept?.join(', ') ?? 'Будь-які файли'} · до {maxFiles ?? 10} файлів
            </p>
          </div>
        </div>
      )}

      <input
        ref={inputRef}
        type="file"
        multiple
        accept={accept?.join(',')}
        className="sr-only"
        onChange={handleInputChange}
        disabled={disabled}
        aria-label="Вибір файлів"
      />
    </div>
  )
}

Попередній перегляд зображень до завантаження

// hooks/useFilePreview.ts
import { useState, useEffect } from 'react'

export function useFilePreview(file: File | null): string | null {
  const [preview, setPreview] = useState<string | null>(null)

  useEffect(() => {
    if (!file || !file.type.startsWith('image/')) {
      setPreview(null)
      return
    }

    const url = URL.createObjectURL(file)
    setPreview(url)

    return () => URL.revokeObjectURL(url) // звільняємо пам'ять
  }, [file])

  return preview
}
// Компонент карточки файлу з попереднім переглядом
function FileCard({ uploadFile, onRemove }: { uploadFile: UploadFile; onRemove: () => void }) {
  const preview = useFilePreview(
    uploadFile.file.type.startsWith('image/') ? uploadFile.file : null
  )

  return (
    <div className="flex items-center gap-3 p-3 border rounded-lg">
      {preview ? (
        <img
          src={preview}
          alt={uploadFile.file.name}
          className="w-12 h-12 object-cover rounded"
        />
      ) : (
        <FileIcon mimeType={uploadFile.file.type} className="w-12 h-12" />
      )}

      <div className="flex-1 min-w-0">
        <p className="truncate text-sm font-medium">{uploadFile.file.name}</p>
        <p className="text-xs text-muted-foreground">
          {formatFileSize(uploadFile.file.size)}
        </p>
        {uploadFile.status === 'uploading' && (
          <div className="mt-1 w-full bg-gray-200 rounded-full h-1.5">
            <div
              className="bg-primary h-1.5 rounded-full transition-all duration-300"
              style={{ width: `${uploadFile.progress}%` }}
            />
          </div>
        )}
      </div>

      <button
        onClick={onRemove}
        disabled={uploadFile.status === 'uploading'}
        className="text-muted-foreground hover:text-destructive"
        aria-label={`Видалити ${uploadFile.file.name}`}
      >
        ✕
      </button>
    </div>
  )
}

function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}

Drag-and-drop поверх всієї сторінки

Деякі додатки (поштові клієнти, файлові менеджери) приймають файли у будь-якому місці сторінки:

// hooks/useGlobalDrop.ts
import { useEffect, useState } from 'react'

export function useGlobalDrop(onDrop: (files: File[]) => void) {
  const [isActive, setIsActive] = useState(false)
  let dragCounter = 0

  useEffect(() => {
    const handleDragEnter = (e: DragEvent) => {
      if (!e.dataTransfer?.types.includes('Files')) return
      dragCounter++
      setIsActive(true)
    }

    const handleDragLeave = () => {
      dragCounter--
      if (dragCounter === 0) setIsActive(false)
    }

    const handleDrop = (e: DragEvent) => {
      e.preventDefault()
      dragCounter = 0
      setIsActive(false)
      if (e.dataTransfer?.files.length) {
        onDrop(Array.from(e.dataTransfer.files))
      }
    }

    const handleDragOver = (e: DragEvent) => e.preventDefault()

    document.addEventListener('dragenter', handleDragEnter)
    document.addEventListener('dragleave', handleDragLeave)
    document.addEventListener('dragover', handleDragOver)
    document.addEventListener('drop', handleDrop)

    return () => {
      document.removeEventListener('dragenter', handleDragEnter)
      document.removeEventListener('dragleave', handleDragLeave)
      document.removeEventListener('dragover', handleDragOver)
      document.removeEventListener('drop', handleDrop)
    }
  }, [onDrop])

  return isActive
}
function App() {
  const [files, setFiles] = useState<File[]>([])
  const isGlobalDragActive = useGlobalDrop(newFiles => setFiles(prev => [...prev, ...newFiles]))

  return (
    <div>
      {isGlobalDragActive && (
        <div className="fixed inset-0 z-50 bg-primary/20 border-4 border-dashed border-primary flex items-center justify-center pointer-events-none">
          <p className="text-2xl font-bold text-primary">Відпустіть файли</p>
        </div>
      )}
      {/* решта UI */}
    </div>
  )
}

react-dropzone: готова бібліотека

Якщо потрібно швидко, без написання хуків з нуля:

npm install react-dropzone
import { useDropzone } from 'react-dropzone'

function MediaUploader({ onFiles }: { onFiles: (files: File[]) => void }) {
  const { getRootProps, getInputProps, isDragActive, acceptedFiles } = useDropzone({
    onDrop: onFiles,
    accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] },
    maxFiles: 20,
    maxSize: 10 * 1024 * 1024,
  })

  return (
    <div
      {...getRootProps()}
      className={cn('dropzone', isDragActive && 'dropzone--active')}
    >
      <input {...getInputProps()} />
      {isDragActive ? (
        <p>Відпустіть файли...</p>
      ) : (
        <p>Перетягніть зображення або натисніть для вибору</p>
      )}
    </div>
  )
}

Терміни

Кастомний хук + компонент DropZone з попереднім переглядом + індикатор прогресу — 1.5–2 дні. З глобальним drag-over оверлеєм, сортуванням файлів, повторними спробами завантаження та інтеграцією з presigned S3 URL — 3–4 дні.