Реализация 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 слой — эндпоинты для 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> // returns public 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>
  )
}

Создание папок и переименование

// app/api/files/folder/route.ts
export async function POST(request: Request) {
  const { path } = await request.json()
  // В S3 «папки» — объекты с ключом path/ и нулевым телом
  await storage.put(path + '/.keep', Buffer.from(''), { mimeType: 'text/plain' })
  return Response.json({ success: true })
}

// app/api/files/move/route.ts
export async function POST(request: Request) {
  const { from, to } = await request.json()
  await storage.move(from, to)
  return Response.json({ success: true })
}

Интеграция с редактором контента

// В редакторе TipTap — кнопка «Вставить изображение»
const [showFileManager, setShowFileManager] = useState(false)

<Dialog open={showFileManager} onOpenChange={setShowFileManager}>
  <DialogContent className="max-w-4xl h-[600px]">
    <FileManager
      mode="select"
      onSelect={(file) => {
        editor.chain().focus().setImage({ src: file.url, alt: file.name }).run()
        setShowFileManager(false)
      }}
    />
  </DialogContent>
</Dialog>

Права доступа и аудит

// Все файловые операции логируем
await db.fileAuditLog.create({
  data: {
    userId: session.user.id,
    action: 'upload',
    path: filePath,
    size: file.size,
    ip: request.headers.get('x-forwarded-for') ?? 'unknown',
    createdAt: new Date(),
  },
})

Роли: viewer — только чтение. editor — загрузка/переименование. admin — удаление, создание папок.

Сроки

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