Реалізація File Manager на сайті

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

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

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація File Manager на сайті
Складна
~1-2 тижні
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • 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

Реалізація File Manager на сайті

File Manager в admin-панелі — це не просто загрузка файлів. Це повноцінний UI для роботи з файловою системою або хмарним хранилищем: навігація по папках, переименування, перемішення, превю, масові операції, інтеграція з редактором контенту.

Архітектура

Компоненти системи:

  1. Storage backend — де фізично лежать файли: локальна ФС, S3, GCS, Cloudflare R2
  2. API слой — endpoints для CRUD-операцій над файлами та папками
  3. UI компонент — React з drag & drop, превю, вибором файлів
  4. CDN — роздача публічних файлів, оптимізація зображень

Storage Abstraction

Хранилище абстрагуємо за інтерфейсом — щоб можна було переключитися з локального на S3 без змін в API:

// lib/storage/types.ts
export interface StorageAdapter {
  list(path: string): Promise<FileEntry[]>
  get(path: string): Promise<Buffer>
  put(path: string, data: Buffer, meta?: FileMeta): Promise<string> // повертає публічний URL
  delete(path: string): Promise<void>
  move(from: string, to: string): Promise<void>
  exists(path: string): Promise<boolean>
  getSignedUrl(path: string, expiresIn?: number): Promise<string>
}

export interface FileEntry {
  name: string
  path: string       // відносний шлях від root
  type: 'file' | 'folder'
  size?: number
  mimeType?: string
  url?: string       // публічний URL якщо є
  thumbnailUrl?: string
  lastModified?: Date
}

S3 адаптер

// lib/storage/s3-adapter.ts
import {
  S3Client, ListObjectsV2Command, GetObjectCommand,
  PutObjectCommand, DeleteObjectCommand, CopyObjectCommand,
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

export class S3StorageAdapter implements StorageAdapter {
  private s3: S3Client
  private bucket: string
  private cdnUrl: string

  constructor(config: { region: string; bucket: string; cdnUrl: string }) {
    this.s3 = new S3Client({ region: config.region })
    this.bucket = config.bucket
    this.cdnUrl = config.cdnUrl
  }

  async list(prefix: string): Promise<FileEntry[]> {
    // Нормалізуємо prefix: 'images/' або '' для root
    const normalizedPrefix = prefix ? prefix.replace(/^\//, '') + '/' : ''

    const result = await this.s3.send(new ListObjectsV2Command({
      Bucket: this.bucket,
      Prefix: normalizedPrefix,
      Delimiter: '/',  // тільки один рівень, не рекурсивно
    }))

    const folders: FileEntry[] = (result.CommonPrefixes ?? []).map(p => ({
      name: p.Prefix!.replace(normalizedPrefix, '').replace('/', ''),
      path: '/' + p.Prefix!.replace(/\/$/, ''),
      type: 'folder',
    }))

    const files: FileEntry[] = (result.Contents ?? [])
      .filter(obj => obj.Key !== normalizedPrefix) // видаляємо сам prefix
      .map(obj => ({
        name: obj.Key!.replace(normalizedPrefix, ''),
        path: '/' + obj.Key!,
        type: 'file',
        size: obj.Size,
        mimeType: this.guessMimeType(obj.Key!),
        url: `${this.cdnUrl}/${obj.Key}`,
        thumbnailUrl: this.isImage(obj.Key!) ? `${this.cdnUrl}/${obj.Key}?w=200&h=200&fit=cover` : undefined,
        lastModified: obj.LastModified,
      }))

    return [...folders, ...files]
  }

  async put(path: string, data: Buffer, meta: FileMeta = {}): Promise<string> {
    const key = path.replace(/^\//, '')
    await this.s3.send(new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      Body: data,
      ContentType: meta.mimeType ?? 'application/octet-stream',
      CacheControl: this.isImage(key) ? 'public, max-age=31536000, immutable' : 'public, max-age=3600',
      Metadata: meta.custom ?? {},
    }))
    return `${this.cdnUrl}/${key}`
  }

  async move(from: string, to: string): Promise<void> {
    const fromKey = from.replace(/^\//, '')
    const toKey = to.replace(/^\//, '')
    await this.s3.send(new CopyObjectCommand({
      Bucket: this.bucket, CopySource: `${this.bucket}/${fromKey}`, Key: toKey,
    }))
    await this.delete(from)
  }

  async getSignedUrl(path: string, expiresIn = 3600): Promise<string> {
    const key = path.replace(/^\//, '')
    return getSignedUrl(this.s3, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn })
  }

  private isImage(key: string): boolean {
    return /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(key)
  }

  private guessMimeType(key: string): string {
    if (/\.pdf$/i.test(key)) return 'application/pdf'
    if (/\.(jpg|jpeg)$/i.test(key)) return 'image/jpeg'
    if (/\.png$/i.test(key)) return 'image/png'
    if (/\.webp$/i.test(key)) return 'image/webp'
    if (/\.mp4$/i.test(key)) return 'video/mp4'
    return 'application/octet-stream'
  }
}

API роути

// app/api/files/route.ts
import { storage } from '@/lib/storage'
import { requireRole } from '@/lib/auth'
import sharp from 'sharp'

// GET /api/files?path=/images
export async function GET(request: Request) {
  await requireRole(request, 'editor')
  const { searchParams } = new URL(request.url)
  const path = searchParams.get('path') ?? '/'
  const files = await storage.list(path)
  return Response.json(files)
}

// POST /api/files — загрузка файлу
export async function POST(request: Request) {
  await requireRole(request, 'editor')
  const form = await request.formData()
  const file = form.get('file') as File
  const folder = (form.get('folder') as string) ?? '/'

  if (!file) return new Response('No file', { status: 400 })

  // Обмеження
  const MAX_SIZE = 50 * 1024 * 1024 // 50 MB
  if (file.size > MAX_SIZE) return new Response('Too large', { status: 413 })

  let buffer = Buffer.from(await file.arrayBuffer())
  let mimeType = file.type
  let fileName = sanitizeFileName(file.name)

  // Оптимізуємо зображення
  if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
    buffer = await sharp(buffer)
      .resize(3840, 3840, { fit: 'inside', withoutEnlargement: true })
      .webp({ quality: 85 })
      .toBuffer()
    mimeType = 'image/webp'
    fileName = fileName.replace(/\.[^.]+$/, '.webp')
  }

  // Дедупліцирование за хешем
  const hash = crypto.createHash('md5').update(buffer).digest('hex').slice(0, 8)
  const ext = fileName.split('.').pop()
  const uniqueName = `${fileName.replace(`.${ext}`, '')}-${hash}.${ext}`

  const path = `${folder}/${uniqueName}`.replace(/\/+/g, '/')
  const url = await storage.put(path, buffer, { mimeType })

  return Response.json({ path, url, name: uniqueName })
}

// DELETE /api/files
export async function DELETE(request: Request) {
  await requireRole(request, 'editor')
  const { path } = await request.json()
  await storage.delete(path)
  return Response.json({ success: true })
}

UI компонент

// components/FileManager/index.tsx
import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import useSWR from 'swr'

interface FileManagerProps {
  onSelect?: (file: FileEntry) => void // для вставки в редактор
  mode?: 'browse' | 'select'
}

export function FileManager({ onSelect, mode = 'browse' }: FileManagerProps) {
  const [currentPath, setCurrentPath] = useState('/')
  const [selected, setSelected] = useState<Set<string>>(new Set())
  const [uploading, setUploading] = useState(false)

  const { data: files, mutate } = useSWR<FileEntry[]>(
    `/api/files?path=${encodeURIComponent(currentPath)}`,
    (url) => fetch(url).then(r => r.json())
  )

  const onDrop = useCallback(async (acceptedFiles: File[]) => {
    setUploading(true)
    for (const file of acceptedFiles) {
      const form = new FormData()
      form.append('file', file)
      form.append('folder', currentPath)
      await fetch('/api/files', { method: 'POST', body: form })
    }
    await mutate()
    setUploading(false)
  }, [currentPath, mutate])

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    noClick: true, // клік відкриває файл, не діалог
  })

  const handleDelete = async (paths: string[]) => {
    if (!confirm(`Видалити ${paths.length} файл(и)?`)) return
    for (const path of paths) {
      await fetch('/api/files', {
        method: 'DELETE',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ path }),
      })
    }
    setSelected(new Set())
    await mutate()
  }

  return (
    <div className="flex flex-col h-full" {...getRootProps()}>
      <input {...getInputProps()} />

      {/* Breadcrumb навігація */}
      <div className="flex items-center gap-2 p-3 border-b text-sm">
        <BreadcrumbNav path={currentPath} onNavigate={setCurrentPath} />
        {selected.size > 0 && (
          <button
            className="ml-auto text-red-500 hover:text-red-700"
            onClick={() => handleDelete([...selected])}
          >
            Видалити ({selected.size})
          </button>
        )}
        <label className="ml-2 cursor-pointer px-3 py-1 bg-blue-500 text-white rounded text-sm">
          Загрузити
          <input type="file" multiple className="hidden" onChange={e => onDrop([...e.target.files!])} />
        </label>
      </div>

      {/* Область drag & drop */}
      {isDragActive && (
        <div className="absolute inset-0 bg-blue-50 border-2 border-dashed border-blue-400 z-50 flex items-center justify-center">
          <p className="text-blue-600 text-lg">Перетащіть файли сюди</p>
        </div>
      )}

      {/* Сітка файлів */}
      <div className="flex-1 overflow-auto p-4 grid grid-cols-4 gap-3 content-start">
        {files?.map(file => (
          <FileCard
            key={file.path}
            file={file}
            selected={selected.has(file.path)}
            onToggleSelect={(path) => {
              setSelected(prev => {
                const next = new Set(prev)
                next.has(path) ? next.delete(path) : next.add(path)
                return next
              })
            }}
            onOpen={(file) => {
              if (file.type === 'folder') setCurrentPath(file.path)
              else if (mode === 'select') onSelect?.(file)
            }}
          />
        ))}
      </div>

      {uploading && (
        <div className="p-2 bg-blue-50 text-center text-sm text-blue-600">
          Загружаємо файли...
        </div>
      )}
    </div>
  )
}

Терміни

Storage abstraction + S3 адаптер + базові API: 3–4 дні. React UI з drag & drop, превю, навігацією по папках: 4–5 днів. Інтеграція з редактором контенту, права, аудит лог: +2–3 дні. Всього: 9–12 днів.