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:
- Storage backend — where files physically live: local FS, S3, GCS, Cloudflare R2
- API layer — endpoints for CRUD operations on files and folders
- UI component — React with drag & drop, preview, file selection
- 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.







