Реалізація мультизавантаження файлів (Bulk Upload) на сайт

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

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

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

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

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація мультизавантаження файлів (Bulk Upload) на сайт
Середня
~2-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

Реалізація мультизагрузки файлів на сайт

Мультизагрузка — вибір та відправка кількох файлів одночасно. Використовується в інтернет-магазинах (завантаження фото товару), CMS (медіатека), особистих кабінетах (документи), поштових клієнтах (вкладення). Реалізація включає UI вибору файлів, очередь завантаження з прогресом, валідацію на клієнті та серверну обробку.

HTML: атрибут multiple

<input type="file" multiple accept="image/*,.pdf,.doc,.docx" />

Атрибут multiple дозволяє вибір кількох файлів. accept фільтрує діалог вибору файла — але це не захист: користувач може вибрати будь-який файл, обійшовши фільтр.

React-компонент завантаження

// components/BulkUpload.tsx
import { useRef, useState, useCallback } from 'react'

interface UploadFile {
  id: string
  file: File
  status: 'pending' | 'uploading' | 'done' | 'error'
  progress: number
  error?: string
  url?: string
}

interface BulkUploadProps {
  accept?: string
  maxFiles?: number
  maxSizeBytes?: number
  onUploadComplete?: (urls: string[]) => void
}

export function BulkUpload({
  accept = 'image/*',
  maxFiles = 10,
  maxSizeBytes = 10 * 1024 * 1024,
  onUploadComplete,
}: BulkUploadProps) {
  const inputRef = useRef<HTMLInputElement>(null)
  const [files, setFiles] = useState<UploadFile[]>([])

  const addFiles = useCallback((incoming: FileList | File[]) => {
    const arr = Array.from(incoming)

    const validated = arr
      .filter(f => {
        if (f.size > maxSizeBytes) {
          alert(`${f.name}: файл занадто великий (макс. ${maxSizeBytes / 1024 / 1024} MB)`)
          return false
        }
        return true
      })
      .slice(0, maxFiles - files.length)

    setFiles(prev => [
      ...prev,
      ...validated.map(file => ({
        id: crypto.randomUUID(),
        file,
        status: 'pending' as const,
        progress: 0,
      })),
    ])
  }, [files.length, maxFiles, maxSizeBytes])

  const uploadFile = useCallback(async (uploadFile: UploadFile) => {
    const formData = new FormData()
    formData.append('file', uploadFile.file)

    setFiles(prev => prev.map(f =>
      f.id === uploadFile.id ? { ...f, status: 'uploading' } : f
    ))

    try {
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest()
        xhr.open('POST', '/api/upload')
        xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector<HTMLMetaElement>('meta[name=csrf-token]')?.content ?? '')

        xhr.upload.onprogress = (e) => {
          if (e.lengthComputable) {
            const progress = Math.round((e.loaded / e.total) * 100)
            setFiles(prev => prev.map(f =>
              f.id === uploadFile.id ? { ...f, progress } : f
            ))
          }
        }

        xhr.onload = () => {
          if (xhr.status >= 200 && xhr.status < 300) {
            const { url } = JSON.parse(xhr.responseText)
            setFiles(prev => prev.map(f =>
              f.id === uploadFile.id ? { ...f, status: 'done', progress: 100, url } : f
            ))
            resolve()
          } else {
            reject(new Error(`HTTP ${xhr.status}`))
          }
        }

        xhr.onerror = () => reject(new Error('Network error'))
        xhr.send(formData)
      })
    } catch (e) {
      setFiles(prev => prev.map(f =>
        f.id === uploadFile.id
          ? { ...f, status: 'error', error: (e as Error).message }
          : f
      ))
    }
  }, [])

  const uploadAll = useCallback(async () => {
    const pending = files.filter(f => f.status === 'pending')
    const CONCURRENCY = 3
    for (let i = 0; i < pending.length; i += CONCURRENCY) {
      await Promise.all(pending.slice(i, i + CONCURRENCY).map(uploadFile))
    }

    const urls = files.filter(f => f.status === 'done' && f.url).map(f => f.url!)
    onUploadComplete?.(urls)
  }, [files, uploadFile, onUploadComplete])

  const removeFile = (id: string) => {
    setFiles(prev => prev.filter(f => f.id !== id))
  }

  return (
    <div>
      <button onClick={() => inputRef.current?.click()}>
        Вибрати файли
      </button>
      <input
        ref={inputRef}
        type="file"
        multiple
        accept={accept}
        className="hidden"
        onChange={e => e.target.files && addFiles(e.target.files)}
      />

      {files.length > 0 && (
        <ul className="mt-4 space-y-2">
          {files.map(f => (
            <li key={f.id} className="flex items-center gap-3">
              <span className="truncate flex-1">{f.file.name}</span>
              <span className="text-sm text-muted">
                {(f.file.size / 1024).toFixed(1)} KB
              </span>
              {f.status === 'uploading' && (
                <div className="w-24 bg-gray-200 rounded-full h-2">
                  <div
                    className="bg-blue-500 h-2 rounded-full transition-all"
                    style={{ width: `${f.progress}%` }}
                  />
                </div>
              )}
              {f.status === 'done' && <span className="text-green-600">✓</span>}
              {f.status === 'error' && (
                <span className="text-red-600 text-sm">{f.error}</span>
              )}
              <button
                onClick={() => removeFile(f.id)}
                disabled={f.status === 'uploading'}
                aria-label={`Видалити ${f.file.name}`}
              >
                ✕
              </button>
            </li>
          ))}
        </ul>
      )}

      {files.some(f => f.status === 'pending') && (
        <button onClick={uploadAll} className="mt-4 btn-primary">
          Завантажити {files.filter(f => f.status === 'pending').length} файлів
        </button>
      )}
    </div>
  )
}

Серверна обробка: Laravel

// routes/api.php
Route::post('/upload', [UploadController::class, 'store'])
    ->middleware(['auth', 'throttle:60,1']);
// app/Http/Controllers/UploadController.php
class UploadController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $request->validate([
            'file'      => ['required', 'file', 'max:10240'],
            'file'      => ['mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx'],
        ]);

        $file = $request->file('file');
        $filename = Str::uuid() . '.' . $file->getClientOriginalExtension();

        $mimeType = $file->getMimeType();
        $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
        if (!in_array($mimeType, $allowedMimes)) {
            return response()->json(['error' => 'Недопустимий тип файла'], 422);
        }

        $path = $file->storeAs('uploads/' . date('Y/m'), $filename, 's3');

        if (str_starts_with($mimeType, 'image/')) {
            $this->processImage($file, $path);
        }

        return response()->json([
            'url'  => Storage::url($path),
            'name' => $filename,
            'size' => $file->getSize(),
        ]);
    }

    private function processImage(UploadedFile $file, string $storagePath): void
    {
        $image = Image::make($file->getPathname())
            ->orientate()
            ->resize(2000, 2000, fn($c) => $c->aspectRatio()->upsize())
            ->encode('webp', 85);

        Storage::put(
            str_replace('.' . pathinfo($storagePath, PATHINFO_EXTENSION), '.webp', $storagePath),
            $image->encoded
        );
    }
}

Завантаження через presigned URL

Для великих обсягів presigned S3 URL'и уникають вузьких місць сервера:

public function presign(Request $request): JsonResponse
{
    $request->validate([
        'filename'  => 'required|string|max:255',
        'mime_type' => 'required|string|in:image/jpeg,image/png,image/webp,application/pdf',
        'size'      => 'required|integer|max:' . 10 * 1024 * 1024,
    ]);

    $ext      = pathinfo($request->filename, PATHINFO_EXTENSION);
    $key      = 'uploads/' . date('Y/m') . '/' . Str::uuid() . '.' . $ext;
    $client   = Storage::disk('s3')->getClient();

    $cmd = $client->getCommand('PutObject', [
        'Bucket'       => config('filesystems.disks.s3.bucket'),
        'Key'          => $key,
        'ContentType'  => $request->mime_type,
        'ACL'          => 'private',
    ]);

    $presignedUrl = (string) $client->createPresignedRequest($cmd, '+15 minutes')->getUri();

    return response()->json([
        'upload_url' => $presignedUrl,
        'public_url' => Storage::url($key),
        'key'        => $key,
    ]);
}
async function uploadToS3(file: File): Promise<string> {
  const { upload_url, public_url } = await fetch('/api/upload/presign', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filename: file.name,
      mime_type: file.type,
      size: file.size,
    }),
  }).then(r => r.json())

  await fetch(upload_url, {
    method: 'PUT',
    body: file,
    headers: { 'Content-Type': file.type },
  })

  return public_url
}

Терміни

React-компонент з очередю та прогресом + серверний endpoint — 2 дні. З presigned S3 URL'ами, WebP-конвертацією, throttle та повторними спробами — 3–4 дні.