File Manager Implementation for Website

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

File Manager Implementation on Website

File Manager in admin panel — not just file upload. It's a full-featured UI for working with the file system or cloud storage: folder navigation, renaming, moving, preview, bulk operations, integration with content editor.

Architecture

System components:

  1. Storage backend — where files physically live: local FS, S3, GCS, Cloudflare R2
  2. API layer — endpoints for CRUD operations on files and folders
  3. UI component — React with drag & drop, preview, file selection
  4. CDN — distribution of public files, image optimization

Storage Abstraction

Storage is abstracted behind an interface — so you can switch from local to S3 without changing the 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       // relative path from root
  type: 'file' | 'folder'
  size?: number
  mimeType?: string
  url?: string       // public URL if available
  thumbnailUrl?: string
  lastModified?: Date
}

S3 Adapter

// 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[]> {
    // Normalize prefix: 'images/' or '' for root
    const normalizedPrefix = prefix ? prefix.replace(/^\//, '') + '/' : ''

    const result = await this.s3.send(new ListObjectsV2Command({
      Bucket: this.bucket,
      Prefix: normalizedPrefix,
      Delimiter: '/',  // only one level, not recursively
    }))

    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) // remove the prefix itself
      .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 Routes

// 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 — file upload
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 })

  // Restrictions
  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)

  // Optimize images
  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')
  }

  // Deduplication by hash
  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 Component

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

interface FileManagerProps {
  onSelect?: (file: FileEntry) => void // for inserting into editor
  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, // click opens file, not dialog
  })

  const handleDelete = async (paths: string[]) => {
    if (!confirm(`Delete ${paths.length} file(s)?`)) 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 navigation */}
      <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])}
          >
            Delete ({selected.size})
          </button>
        )}
        <label className="ml-2 cursor-pointer px-3 py-1 bg-blue-500 text-white rounded text-sm">
          Upload
          <input type="file" multiple className="hidden" onChange={e => onDrop([...e.target.files!])} />
        </label>
      </div>

      {/* Drag & drop area */}
      {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">Drop files here</p>
        </div>
      )}

      {/* Files grid */}
      <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">
          Uploading files...
        </div>
      )}
    </div>
  )
}

Timeline

Storage abstraction + S3 adapter + basic APIs: 3–4 days. React UI with drag & drop, preview, folder navigation: 4–5 days. Integration with content editor, permissions, audit log: +2–3 days. Total: 9–12 days.