Case Study: File Management¶
Tổng quan¶
Xây dựng hệ thống quản lý file với upload, preview, và transformations.
Yêu cầu¶
- User file upload (images, documents)
- Image preview và thumbnails
- Access control (private/public files)
- File organization (folders)
- Download và sharing
Kết quả mong đợi¶
- File manager hoàn chỉnh
- Secure file access
- Image transformations
- Responsive design
Kiến trúc¶
┌─────────────────────────────────────────────────────────────┐
│ FILE MANAGEMENT ARCHITECTURE │
├─────────────────────────────────────────────────────────────┤
│ │
│ User │
│ │ │
│ ▼ │
│ Next.js App │
│ │ │
│ ├──▶ Upload Flow │
│ │ │ │
│ │ ├── Client: Compress image (if image) │
│ │ ├── Client: Upload to Supabase Storage │
│ │ └── Client: Save metadata to database │
│ │ │
│ ├──▶ View Flow │
│ │ │ │
│ │ ├── Public: Get public URL │
│ │ └── Private: Get signed URL │
│ │ │
│ └──▶ Transform Flow │
│ │ │
│ └── Supabase Image Transformations │
│ ├── Resize │
│ ├── Quality │
│ └── Format │
│ │
│ Storage Buckets │
│ ├── avatars (public) │
│ ├── documents (private) │
│ └── uploads (mixed) │
│ │
└─────────────────────────────────────────────────────────────┘
Database Schema¶
-- supabase/migrations/001_files.sql
-- Folders
CREATE TABLE public.folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Files metadata
CREATE TABLE public.files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
storage_path TEXT NOT NULL, -- Path in Supabase Storage
bucket TEXT NOT NULL DEFAULT 'uploads',
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
is_public BOOLEAN DEFAULT false,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_files_user ON files(user_id);
CREATE INDEX idx_files_folder ON files(folder_id);
CREATE INDEX idx_folders_user ON folders(user_id);
CREATE INDEX idx_folders_parent ON folders(parent_id);
-- Enable RLS
ALTER TABLE folders ENABLE ROW LEVEL SECURITY;
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
-- Folder policies
CREATE POLICY "Users can manage own folders"
ON folders FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- File policies
CREATE POLICY "Users can view own files"
ON files FOR SELECT
USING (user_id = auth.uid() OR is_public = true);
CREATE POLICY "Users can manage own files"
ON files FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
Storage Bucket Setup¶
-- Create buckets (run in Supabase Dashboard or migration)
INSERT INTO storage.buckets (id, name, public)
VALUES
('avatars', 'avatars', true),
('documents', 'documents', false),
('uploads', 'uploads', false);
-- Storage policies
-- Avatars: Public read, authenticated write
CREATE POLICY "Public can view avatars"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');
CREATE POLICY "Users can upload own avatar"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Documents: Owner only
CREATE POLICY "Users can manage own documents"
ON storage.objects FOR ALL
USING (
bucket_id = 'documents' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Uploads: Owner only (with public flag in files table)
CREATE POLICY "Users can manage own uploads"
ON storage.objects FOR ALL
USING (
bucket_id = 'uploads' AND
auth.uid()::text = (storage.foldername(name))[1]
);
Implementation¶
Project Structure¶
file-manager/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── files/
│ ├── page.tsx # File browser
│ └── [id]/
│ └── page.tsx # File detail
├── components/
│ ├── files/
│ │ ├── FileUploader.tsx
│ │ ├── FileGrid.tsx
│ │ ├── FileCard.tsx
│ │ ├── FolderTree.tsx
│ │ └── ImagePreview.tsx
│ └── ui/
│ └── DropZone.tsx
├── hooks/
│ ├── use-files.ts
│ └── use-upload.ts
└── lib/
├── supabase/
└── utils/
└── file-helpers.ts
File Uploader Component¶
// components/files/FileUploader.tsx
'use client';
import { useState, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client';
import { compressImage } from '@/lib/utils/file-helpers';
interface FileUploaderProps {
folderId?: string;
bucket?: string;
onUploadComplete?: (file: any) => void;
}
export function FileUploader({
folderId,
bucket = 'uploads',
onUploadComplete
}: FileUploaderProps) {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState('');
const supabase = createClient();
const handleUpload = useCallback(async (file: File) => {
setUploading(true);
setProgress(0);
setError('');
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
// Compress if image and > 1MB
let fileToUpload = file;
if (file.type.startsWith('image/') && file.size > 1024 * 1024) {
fileToUpload = await compressImage(file, {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
});
}
// Generate unique filename
const ext = file.name.split('.').pop();
const fileName = `${user.id}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
// Upload to storage
const { data: uploadData, error: uploadError } = await supabase.storage
.from(bucket)
.upload(fileName, fileToUpload, {
cacheControl: '3600',
upsert: false,
});
if (uploadError) throw uploadError;
setProgress(50);
// Save metadata to database
const { data: fileRecord, error: dbError } = await supabase
.from('files')
.insert({
name: fileName.split('/').pop(),
original_name: file.name,
mime_type: file.type,
size: fileToUpload.size,
storage_path: uploadData.path,
bucket,
folder_id: folderId || null,
metadata: {
width: file.type.startsWith('image/') ? await getImageDimensions(file) : null,
},
})
.select()
.single();
if (dbError) throw dbError;
setProgress(100);
onUploadComplete?.(fileRecord);
} catch (err: any) {
setError(err.message);
console.error('Upload error:', err);
} finally {
setUploading(false);
}
}, [bucket, folderId, onUploadComplete, supabase]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
files.forEach(handleUpload);
}, [handleUpload]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
files.forEach(handleUpload);
}, [handleUpload]);
return (
<div
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
className="border-2 border-dashed rounded-lg p-8 text-center hover:border-blue-500 transition-colors"
>
<input
type="file"
id="file-upload"
multiple
onChange={handleFileSelect}
className="hidden"
/>
<label htmlFor="file-upload" className="cursor-pointer">
<div className="space-y-2">
<div className="text-4xl">📁</div>
<p className="text-lg font-medium">
Drop files here or click to upload
</p>
<p className="text-sm text-gray-500">
Images, documents, PDFs (max 50MB)
</p>
</div>
</label>
{uploading && (
<div className="mt-4">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-sm text-gray-500 mt-1">
Uploading... {progress}%
</p>
</div>
)}
{error && (
<p className="mt-4 text-red-600 text-sm">{error}</p>
)}
</div>
);
}
// Helper: Get image dimensions
async function getImageDimensions(file: File): Promise<{ width: number; height: number } | null> {
return new Promise((resolve) => {
if (!file.type.startsWith('image/')) {
resolve(null);
return;
}
const img = new Image();
img.onload = () => resolve({ width: img.width, height: img.height });
img.onerror = () => resolve(null);
img.src = URL.createObjectURL(file);
});
}
Image Compression Utility¶
// lib/utils/file-helpers.ts
import imageCompression from 'browser-image-compression';
interface CompressOptions {
maxSizeMB: number;
maxWidthOrHeight: number;
}
export async function compressImage(
file: File,
options: CompressOptions
): Promise<File> {
try {
const compressed = await imageCompression(file, {
maxSizeMB: options.maxSizeMB,
maxWidthOrHeight: options.maxWidthOrHeight,
useWebWorker: true,
});
return new File([compressed], file.name, {
type: compressed.type,
lastModified: Date.now(),
});
} catch (error) {
console.error('Compression failed:', error);
return file; // Return original if compression fails
}
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export function getFileIcon(mimeType: string): string {
if (mimeType.startsWith('image/')) return '🖼️';
if (mimeType.startsWith('video/')) return '🎬';
if (mimeType.startsWith('audio/')) return '🎵';
if (mimeType === 'application/pdf') return '📕';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
if (mimeType.includes('document') || mimeType.includes('word')) return '📄';
return '📎';
}
File Grid Component¶
// components/files/FileGrid.tsx
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { formatFileSize, getFileIcon } from '@/lib/utils/file-helpers';
import { ImagePreview } from './ImagePreview';
interface FileItem {
id: string;
name: string;
original_name: string;
mime_type: string;
size: number;
storage_path: string;
bucket: string;
is_public: boolean;
created_at: string;
}
interface FileGridProps {
files: FileItem[];
onDelete?: (id: string) => void;
}
export function FileGrid({ files, onDelete }: FileGridProps) {
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
const supabase = createClient();
const getFileUrl = async (file: FileItem) => {
if (file.is_public) {
const { data } = supabase.storage
.from(file.bucket)
.getPublicUrl(file.storage_path);
return data.publicUrl;
}
// Private file: get signed URL
const { data, error } = await supabase.storage
.from(file.bucket)
.createSignedUrl(file.storage_path, 3600); // 1 hour
if (error) throw error;
return data.signedUrl;
};
const handleDownload = async (file: FileItem) => {
try {
const { data, error } = await supabase.storage
.from(file.bucket)
.download(file.storage_path);
if (error) throw error;
// Create download link
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = file.original_name;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('Download error:', error);
}
};
const handleDelete = async (file: FileItem) => {
if (!confirm('Are you sure you want to delete this file?')) return;
try {
// Delete from storage
const { error: storageError } = await supabase.storage
.from(file.bucket)
.remove([file.storage_path]);
if (storageError) throw storageError;
// Delete from database
const { error: dbError } = await supabase
.from('files')
.delete()
.eq('id', file.id);
if (dbError) throw dbError;
onDelete?.(file.id);
} catch (error) {
console.error('Delete error:', error);
}
};
return (
<>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{files.map((file) => (
<FileCard
key={file.id}
file={file}
onPreview={() => setSelectedFile(file)}
onDownload={() => handleDownload(file)}
onDelete={() => handleDelete(file)}
getFileUrl={getFileUrl}
/>
))}
</div>
{selectedFile && selectedFile.mime_type.startsWith('image/') && (
<ImagePreview
file={selectedFile}
getFileUrl={getFileUrl}
onClose={() => setSelectedFile(null)}
/>
)}
</>
);
}
interface FileCardProps {
file: FileItem;
onPreview: () => void;
onDownload: () => void;
onDelete: () => void;
getFileUrl: (file: FileItem) => Promise<string>;
}
function FileCard({
file,
onPreview,
onDownload,
onDelete,
getFileUrl
}: FileCardProps) {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const isImage = file.mime_type.startsWith('image/');
// Load thumbnail for images
useState(() => {
if (isImage) {
getFileUrl(file).then(setThumbnailUrl);
}
});
return (
<div className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow">
{/* Thumbnail */}
<div
className="aspect-square bg-gray-100 flex items-center justify-center cursor-pointer"
onClick={onPreview}
>
{isImage && thumbnailUrl ? (
<img
src={`${thumbnailUrl}?width=200&height=200&resize=cover`}
alt={file.original_name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-4xl">{getFileIcon(file.mime_type)}</span>
)}
</div>
{/* Info */}
<div className="p-2">
<p className="text-sm font-medium truncate" title={file.original_name}>
{file.original_name}
</p>
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
{/* Actions */}
<div className="flex gap-2 mt-2">
<button
onClick={onDownload}
className="text-xs text-blue-600 hover:underline"
>
Download
</button>
<button
onClick={onDelete}
className="text-xs text-red-600 hover:underline"
>
Delete
</button>
</div>
</div>
</div>
);
}
Image Preview with Transformations¶
// components/files/ImagePreview.tsx
'use client';
import { useState, useEffect } from 'react';
interface ImagePreviewProps {
file: {
storage_path: string;
bucket: string;
original_name: string;
};
getFileUrl: (file: any) => Promise<string>;
onClose: () => void;
}
export function ImagePreview({ file, getFileUrl, onClose }: ImagePreviewProps) {
const [url, setUrl] = useState<string | null>(null);
const [transform, setTransform] = useState({
width: 800,
quality: 80,
format: 'webp',
});
useEffect(() => {
getFileUrl(file).then(setUrl);
}, [file, getFileUrl]);
const transformedUrl = url
? `${url}?width=${transform.width}&quality=${transform.quality}&format=${transform.format}`
: null;
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-white rounded-lg max-w-4xl w-full mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-medium">{file.original_name}</h3>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
✕
</button>
</div>
{/* Image */}
<div className="p-4">
{transformedUrl && (
<img
src={transformedUrl}
alt={file.original_name}
className="max-h-[60vh] mx-auto"
/>
)}
</div>
{/* Transform Controls */}
<div className="p-4 bg-gray-50 border-t">
<div className="flex gap-4 flex-wrap">
<div>
<label className="text-sm font-medium">Width</label>
<select
value={transform.width}
onChange={(e) => setTransform({ ...transform, width: parseInt(e.target.value) })}
className="ml-2 border rounded px-2 py-1"
>
<option value={400}>400px</option>
<option value={800}>800px</option>
<option value={1200}>1200px</option>
<option value={1920}>1920px</option>
</select>
</div>
<div>
<label className="text-sm font-medium">Quality</label>
<select
value={transform.quality}
onChange={(e) => setTransform({ ...transform, quality: parseInt(e.target.value) })}
className="ml-2 border rounded px-2 py-1"
>
<option value={60}>60%</option>
<option value={80}>80%</option>
<option value={100}>100%</option>
</select>
</div>
<div>
<label className="text-sm font-medium">Format</label>
<select
value={transform.format}
onChange={(e) => setTransform({ ...transform, format: e.target.value })}
className="ml-2 border rounded px-2 py-1"
>
<option value="webp">WebP</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
</select>
</div>
</div>
</div>
</div>
</div>
);
}
File Browser Page¶
// app/files/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { FileUploader } from '@/components/files/FileUploader';
import { FileGrid } from '@/components/files/FileGrid';
import { FolderTree } from '@/components/files/FolderTree';
export default async function FilesPage({
searchParams
}: {
searchParams: { folder?: string }
}) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect('/login');
const folderId = searchParams.folder || null;
// Fetch folders and files
const [{ data: folders }, { data: files }] = await Promise.all([
supabase
.from('folders')
.select('*')
.eq('user_id', user.id)
.order('name'),
supabase
.from('files')
.select('*')
.eq('user_id', user.id)
.eq('folder_id', folderId)
.order('created_at', { ascending: false }),
]);
return (
<div className="flex h-screen">
{/* Sidebar */}
<div className="w-64 border-r p-4">
<FolderTree folders={folders || []} currentFolderId={folderId} />
</div>
{/* Main content */}
<div className="flex-1 p-6 overflow-auto">
<h1 className="text-2xl font-bold mb-6">Files</h1>
<FileUploader folderId={folderId || undefined} />
<div className="mt-8">
<FileGrid files={files || []} />
</div>
</div>
</div>
);
}
Kết quả¶
Features hoàn thành¶
- File upload với compression
- Image thumbnails và preview
- Image transformations (resize, quality, format)
- Folder organization
- Private/public access control
Performance¶
- Image compression: Reduce size by 60-80%
- Thumbnail generation: On-demand via Supabase
- Caching: 1 hour for transformed images
Chi phí¶
- Supabase Pro: $25/month
- Storage: 100GB included
- Image transformations: Included
- Total: $25/month
Lessons Learned¶
What Worked¶
- Client-side compression trước upload
- Supabase Image Transformations = no server needed
- Signed URLs cho private files
- RLS on storage.objects
Challenges¶
- Large file uploads (> 50MB) cần chunked upload
- Video processing không available
- Storage policies syntax khác với table RLS
Best Practices¶
// 1. Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
throw new Error('File type not allowed');
}
// 2. Limit file size
const maxSize = 50 * 1024 * 1024; // 50MB
if (file.size > maxSize) {
throw new Error('File too large');
}
// 3. Use user-scoped paths
const path = `${user.id}/${filename}`; // Prevents path traversal
// 4. Clean up on delete
await supabase.storage.from(bucket).remove([path]);
await supabase.from('files').delete().eq('id', fileId);