Реализация File Manager на сайте
File Manager в admin-панели — это не просто загрузка файлов. Это полноценный UI для работы с файловой системой или облачным хранилищем: навигация по папкам, переименование, перемещение, превью, массовые операции, интеграция с редактором контента.
Архитектура
Компоненты системы:
- Storage backend — где физически лежат файлы: локальная ФС, S3, GCS, Cloudflare R2
- API слой — эндпоинты для CRUD-операций над файлами и папками
- UI компонент — React с drag & drop, превью, выбором файлов
- 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 дней.







