Bỏ qua

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);

Tài liệu tham khảo