Supabase Storage & Realtime — File Storage và WebSocket
Storage — Lưu trữ file
Tổng quan
Supabase Storage là giải pháp lưu trữ file mạnh mẽ, có khả năng mở rộng với kiểm soát truy cập chi tiết và phân phối tối ưu qua CDN toàn cầu. File được lưu trữ trên hạ tầng S3, metadata được quản lý trong PostgreSQL (bảng storage.objects), và RLS có thể áp dụng trực tiếp cho storage objects -- cùng cơ chế bảo mật với database.
File Size Limits — Giới hạn dung lượng upload
Supabase Storage hỗ trợ nhiều phương thức upload với giới hạn dung lượng khác nhau:
| Phương thức Upload | Giới hạn | Protocol | Use case |
|---|---|---|---|
| Standard Upload | 50 MB | HTTP multipart/form-data | Ảnh, documents, file nhỏ |
| TUS Resumable Upload | 5 GB | TUS protocol (chunked) | Video, file nén lớn, backup |
| Signed Upload | 5 GB | HTTP PUT với signed URL | Cho phép upload không cần auth |
| S3-Compatible Upload | 5 GB | S3 multipart upload | Tích hợp công cụ S3 hiện có |
Ba loại Bucket
Supabase cung cấp 3 loại bucket, mỗi loại phục vụ mục đích khác nhau:
| Loại Bucket | Công dụng | Đặc điểm |
|---|---|---|
| File Buckets | Lưu ảnh, video, document, general-purpose files | Direct URL access với RLS, CDN toàn cầu, image transformation |
| Analytics Buckets | Lưu dữ liệu trong Apache Iceberg tables | Dành cho data lakes, logs, Supabase Pipelines; query từ Postgres qua foreign tables |
| Vector Buckets | Lưu embeddings cho similarity search | Dùng HNSW indexing, distance metrics, metadata filtering |
Thao tác với Bucket
Các thao tác quản lý bucket cơ bản:
// Tạo bucket mới
const { data, error } = await supabase.storage.createBucket('avatars', {
public: true, // bucket public
fileSizeLimit: 5242880 // giới hạn 5MB
})
// Liệt kê tất cả buckets
const { data: buckets, error } = await supabase.storage.listBuckets()
// Lấy thông tin bucket
const { data, error } = await supabase.storage.getBucket('avatars')
// Cập nhật bucket (ví dụ: đổi giới hạn file size)
const { data, error } = await supabase.storage.updateBucket('avatars', {
fileSizeLimit: 10485760 // tăng lên 10MB
})
// Xóa bucket (phải empty trước)
const { data, error } = await supabase.storage.deleteBucket('avatars')
// Xóa toàn bộ file trong bucket (empty)
const { data, error } = await supabase.storage.emptyBucket('avatars')
Upload & Download File
Đây là các thao tác cốt lõi khi làm việc với Storage:
// Upload file với upsert
const avatarFile = event.target.files[0]
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${userId}/profile.png`, avatarFile, {
cacheControl: '3600', // cache 1 giờ trên CDN
upsert: true, // ghi đè nếu file đã tồn tại
contentType: 'image/png' // set content type rõ ràng
})
// Upload với progress tracking
const { data, error } = await supabase.storage
.from('videos')
.upload('tutorial.mp4', file, {
onProgress: (progress) => {
console.log(`Uploaded: ${progress.percent}%`)
}
})
// Download file
const { data, error } = await supabase.storage
.from('avatars')
.download(`${userId}/profile.png`)
// Get public URL (không hết hạn)
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/profile.png`)
// data.publicUrl -> https://[project].supabase.co/storage/v1/object/public/avatars/...
// List files trong thư mục
const { data, error } = await supabase.storage
.from('avatars')
.list(userId, {
limit: 100,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' }
})
// Di chuyển / đổi tên file
const { data, error } = await supabase.storage
.from('avatars')
.move(`${userId}/old.png`, `${userId}/new.png`)
// Copy file
const { data, error } = await supabase.storage
.from('avatars')
.copy(`${userId}/profile.png`, `backup/${userId}/profile.png`)
// Xóa file
const { data, error } = await supabase.storage
.from('avatars')
.remove([`${userId}/profile.png`, `${userId}/banner.png`])
S3-Compatible API
Supabase Storage tương thích S3 protocol, cho phép dùng bất kỳ S3 client SDK hoặc CLI tool nào để truy cập:
# Dùng AWS CLI với Supabase làm S3 endpoint
aws s3 ls s3://avatars/ --endpoint-url https://[PROJECT_REF].supabase.co/storage/v1/s3
# Dùng MinIO client
mc alias set supabase https://[PROJECT_REF].supabase.co/storage/v1/s3 \
[ACCESS_KEY] [SECRET_KEY]
mc ls supabase/avatars/
Điều này cho phép tích hợp với các công cụ xử lý media, backup automation, và hệ thống hiện có đã dùng S3.
CDN & Image Transformation
Toàn bộ file được phục vụ qua CDN toàn cầu với 285+ điểm hiện diện. Đặc biệt, Supabase hỗ trợ transform ảnh on-the-fly qua URL parameters:
# URL format đầy đủ cho image transformation
# Base URL pattern:
https://<project>.supabase.co/storage/v1/render/image/public/bucket/avatar.png
# Resize theo width (giữ aspect ratio)
?width=500
# Resize theo height (giữ aspect ratio)
?height=300
# Resize khớp khung với các chế độ
?width=500&height=500&resize=cover # Cắt để lấp đầy khung (có thể mất phần rìa)
?width=500&height=500&resize=contain # Giữ trọn ảnh trong khung (có thể có khoảng trống)
?width=500&height=500&resize=fill # Bóp méo để lấp đầy khung (không giữ aspect ratio)
# Thay đổi chất lượng ảnh (0-100, mặc định 80)
?width=500&quality=85
# Chuyển đổi định dạng ảnh
?width=500&format=webp # WebP — nhẹ hơn JPEG ~30%, trình duyệt hiện đại đều hỗ trợ
?width=500&format=avif # AVIF — nhẹ hơn WebP ~20%, hỗ trợ HDR
?width=500&format=png # PNG — lossless, dùng cho ảnh có transparency
?width=500&format=jpg # JPEG — tương thích rộng nhất
# Kết hợp nhiều tham số — ví dụ thực tế:
/storage/v1/render/image/public/bucket/photo.jpg
?width=400
&height=400
&resize=cover
&quality=80
&format=webp
# Kết quả: Ảnh 400x400px, crop center, chất lượng 80%, định dạng WebP
# File size giảm từ ~2MB (ảnh gốc) xuống còn ~15KB
imgproxy service. Ảnh gốc được lưu một lần, các biến thể (thumbnail, webp, avif) được tạo on-the-fly khi request đầu tiên và cache trên CDN edge. Không tốn thêm storage. Format webp recommended cho web, avif cho ứng dụng modern. resize=cover phổ biến nhất cho avatar/thumbnail.Signed URLs — Truy cập có thời hạn
Với file private, bạn có thể tạo URL tạm thời cho phép truy cập trong khoảng thời gian giới hạn:
// Tạo signed URL (hết hạn sau 60 giây)
const { data, error } = await supabase.storage
.from('private-docs')
.createSignedUrl('contract-2024.pdf', 60)
// data.signedUrl -> URL có ?token=xxx, hết hạn sau 60s
// Tạo signed URL cho upload (cho phép upload mà không cần auth)
const { data, error } = await supabase.storage
.from('submissions')
.createSignedUploadUrl(`${taskId}/submission.pdf`)
// Signed URL cho nhiều file cùng lúc
const { data, error } = await supabase.storage
.from('exports')
.createSignedUrls(['report-q1.pdf', 'report-q2.pdf'], 300)
Row Level Security cho Storage
RLS có thể áp dụng cho storage objects giống như database tables. Điều này cho phép kiểm soát chính xác ai được xem, upload, hay xóa file nào.
-- Pattern 1: User chỉ truy cập được file trong thư mục của mình
-- File được tổ chức theo cấu trúc: {userId}/filename.png
-- (storage.foldername(name))[1] trả về phần đầu tiên của path = userId
CREATE POLICY "Users can view own avatars"
ON storage.objects FOR SELECT
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "Users can upload own avatars"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "Users can update own avatars"
ON storage.objects FOR UPDATE
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "Users can delete own avatars"
ON storage.objects FOR DELETE
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- Pattern 2: Public read + authenticated write
CREATE POLICY "Public read access"
ON storage.objects FOR SELECT
USING (bucket_id = 'public-assets');
CREATE POLICY "Authenticated users can upload"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'public-assets'
AND auth.role() = 'authenticated'
);
-- Pattern 3: Admin-only access (dùng service role)
CREATE POLICY "Admin full access"
ON storage.objects FOR ALL
USING (
bucket_id = 'admin-files'
AND auth.jwt()->>'role' = 'admin' -- Custom claim từ JWT
);
Hàm storage.foldername(name) trả về mảng các phần của path, [1] là thư mục gốc đầu tiên -- đây là convention phổ biến để tổ chức file theo {userId}/....
storage.objects hoạt động giống hệt RLS trên database tables. storage.foldername(name) là helper function trả về mảng path segments -- dùng để parse userId từ file path. Luôn tạo policy cho cả 4 operations (SELECT, INSERT, UPDATE, DELETE) để bảo mật toàn diện.TUS Resumable Uploads
Hỗ trợ upload file lớn với TUS protocol (pause, resume, retry):
// Dùng Uppy với TUS
import Uppy from '@uppy/core'
import Tus from '@uppy/tus'
const uppy = new Uppy()
.use(Tus, {
endpoint: `https://[PROJECT_REF].supabase.co/storage/v1/upload/resumable`,
headers: {
authorization: `Bearer ${supabaseKey}`,
'x-upsert': 'true'
},
chunkSize: 6 * 1024 * 1024, // 6MB chunks
retryDelays: [0, 1000, 3000, 5000] // retry delays
})
TUS đặc biệt hữu ích khi upload video, file nén lớn, hoặc trong môi trường mạng không ổn định.
Multi-Tenancy Patterns
Khi xây dựng ứng dụng multi-tenant (SaaS), bạn có thể tổ chức storage theo nhiều cách:
// Pattern 1: Phân tách theo thư mục (đơn giản nhất)
const path = `${tenantId}/${userId}/${fileName}`
// Pattern 2: Mỗi tenant một bucket riêng
const bucketName = `tenant-${tenantId}`
await supabase.storage.createBucket(bucketName, { public: false })
// Pattern 3: Bucket dùng chung + RLS policy cho tenant
// Policy trong SQL:
// CREATE POLICY "Tenant isolation"
// ON storage.objects FOR SELECT
// USING (
// bucket_id = 'documents'
// AND (storage.foldername(name))[1] = (
// SELECT tenant_id::text FROM profiles WHERE id = auth.uid()
// )
// );
Pattern 1 phù hợp với hầu hết ứng dụng. Pattern 2 khi cần isolation cao. Pattern 3 linh hoạt nhất nhưng phức tạp hơn về policy.
Pricing Storage
- Free: 1 GB file storage, 5 GB băng thông
- Pro: 100 GB file storage + $0.0213/GB vượt, 250 GB băng thông + $0.09/GB vượt
- Billing dựa trên: Storage (GB stored/tháng) + Egress (GB transferred ra ngoài)
Realtime — WebSocket Subscriptions
Tổng quan
Supabase Realtime cung cấp WebSocket subscriptions phân phối toàn cầu với 3 chế độ hoạt động:
| Chế độ | Mô tả | Use case điển hình |
|---|---|---|
| Broadcast | Gửi message low-latency giữa các client, ephemeral | Chat typing indicators, cursor tracking, game events |
| Presence | Theo dõi và đồng bộ trạng thái người dùng | Online status, active participants, collaborative editing |
| Postgres Changes | Lắng nghe thay đổi database realtime (CDC) | Live dashboards, real-time feeds, thông báo |
Channel Lifecycle — Vòng đời của một Channel
Mỗi WebSocket channel trong Supabase Realtime trải qua các trạng thái sau:
subscribe() gọi
|
v
[JOINING] --> Gửi Join request đến Realtime server
|
v
[JOINED] --> Channel được tạo, sẵn sàng nhận events
| (Lúc này mới có thể subscribe Postgres Changes, Broadcast, Presence)
|
v
[SUBSCRIBED] --> Client đã subscribe và đang nhận events
| (Presence.track() chỉ hoạt động ở trạng thái này)
|
v
[CLOSED] --> Channel bị đóng do:
| - unsubscribe() được gọi -> chuyển về [CLOSED] sạch
| - Lỗi mạng, timeout -> tự động reconnect -> [JOINING]
| - Server đóng channel -> tự động reconnect -> [JOINING]
|
v
[CLOSED] cuối cùng -> cleanup resources
// Theo dõi trạng thái channel
const channel = supabase.channel('room-1')
channel.subscribe((status) => {
switch (status) {
case 'SUBSCRIBED':
console.log('Channel ready, có thể track presence và gửi broadcast')
channel.track({ user: 'Alice', online_at: new Date().toISOString() })
break
case 'CHANNEL_ERROR':
console.error('Channel error, sẽ tự động retry...')
break
case 'TIMED_OUT':
console.warn('Channel timeout, đang reconnect...')
break
case 'CLOSED':
console.log('Channel đóng hoàn toàn')
break
}
})
SUBSCRIBED để bắt đầu track presence/send broadcast. Khi reconnect thành công, channel tự động re-subscribe tất cả listeners đã đăng ký trước đó. Không cần code reconnect logic thủ công.Postgres Changes (CDC)
Đây là chế độ mạnh mẽ nhất -- subscribe vào các thay đổi INSERT, UPDATE, DELETE trên bảng cụ thể:
// Tạo channel và subscribe lắng nghe INSERT
const channel = supabase.channel('db-changes')
channel.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
(payload) => {
console.log('New record:', payload.new)
console.log('Event type:', payload.eventType)
// payload.new chứa toàn bộ row mới
}
).subscribe()
// Lắng nghe UPDATE với filter server-side
channel.on('postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'messages',
filter: 'room_id=eq.123' // filter ngay trên server
},
(payload) => {
console.log('Old data:', payload.old) // dữ liệu cũ
console.log('New data:', payload.new) // dữ liệu mới
}
)
// Lắng nghe DELETE
channel.on('postgres_changes',
{
event: 'DELETE',
schema: 'public',
table: 'messages',
filter: 'room_id=eq.123'
},
(payload) => {
console.log('Deleted record id:', payload.old.id)
}
)
// Lắng nghe tất cả events: dùng '*'
channel.on('postgres_changes',
{ event: '*', schema: 'public', table: 'messages' },
(payload) => {
// payload.eventType -> 'INSERT' | 'UPDATE' | 'DELETE'
}
)
Payload chi tiết cho từng event type:
| Event | payload.new | payload.old | payload.eventType | Ghi chú |
|---|---|---|---|---|
| INSERT | Row mới (full) | {} (object rỗng) | 'INSERT' | payload.new chứa tất cả columns của row |
| UPDATE | Row sau khi update (full) | Row trước khi update (full) | 'UPDATE' | So sánh new và old để biết column nào thay đổi |
| DELETE | {} (object rỗng) | Row bị xóa | 'DELETE' | Trong RLS mode, payload.old chỉ chứa id (primary key) |
* (tất cả) | Tùy event | Tùy event | 'INSERT' / 'UPDATE' / 'DELETE' | Dùng chung một handler cho mọi thay đổi |
payload.old trong DELETE event chỉ chứa primary key (id). Điều này là do RLS filter được áp dụng ở database level, và sau khi row bị xóa, hệ thống không thể đọc full data của row đó (vì nó không còn tồn tại để kiểm tra RLS policy).Server-Side Filtering
Filter được thực thi trên server, giúp giảm đáng kể băng thông:
// Filter đơn
filter: 'room_id=eq.123'
// Filter kết hợp
filter: 'room_id=eq.123 AND user_id=eq.456'
// Filter với nhiều điều kiện
filter: 'status=in.(active,pending)'
// Filter IS NULL
filter: 'deleted_at=is.null'
Không gửi toàn bộ data về client rồi lọc -- server chỉ gửi những events khớp filter.
Enable Realtime cho bảng
Để Postgres Changes hoạt động, cần enable Realtime cho bảng:
-- Enable cho một bảng cụ thể
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
-- Enable cho nhiều bảng cùng lúc
ALTER PUBLICATION supabase_realtime ADD TABLE messages, notifications;
-- Enable cho toàn bộ schema (ít dùng vì gây nhiễu)
ALTER PUBLICATION supabase_realtime ADD TABLE public.*;
-- Kiểm tra bảng nào đã được enable
SELECT * FROM pg_publication_tables
WHERE pubname = 'supabase_realtime';
-- Kiểm tra trạng thái publication
SELECT * FROM pg_publication
WHERE pubname = 'supabase_realtime';
-- Xóa bảng khỏi publication (disable realtime)
ALTER PUBLICATION supabase_realtime DROP TABLE old_table;
supabase_realtime để xác định bảng nào được replicate qua WAL. Chỉ các bảng trong publication này mới có thể subscribe Postgres Changes. Việc này giúp kiểm soát chính xác bảng nào được expose qua realtime, tránh lộ dữ liệu nhạy cảm.Broadcast
Broadcast gửi message tạm thời (ephemeral) giữa các client. Message không được lưu trữ -- nếu client offline, họ sẽ bỏ lỡ message. Phù hợp cho dữ liệu không cần persistence:
const channel = supabase.channel('room-1', {
config: {
broadcast: { self: false } // không nhận lại message của chính mình
}
})
// Gửi message
channel.send({
type: 'broadcast',
event: 'cursor-move',
payload: { x: 100, y: 200, userId: 'user-123' }
})
// Gửi typing indicator
channel.send({
type: 'broadcast',
event: 'typing',
payload: { userId: 'user-123', isTyping: true }
})
// Nhận message
channel.on('broadcast', { event: 'cursor-move' }, (payload) => {
console.log('Cursor moved:', payload)
})
channel.on('broadcast', { event: 'typing' }, (payload) => {
showTypingIndicator(payload.userId, payload.isTyping)
})
channel.subscribe()
Presence
Presence tự động theo dõi và đồng bộ trạng thái người dùng. Khi một user disconnect (đóng tab, mất mạng), Presence tự động timeout và xóa user đó khỏi danh sách:
const channel = supabase.channel('room-1')
// Lắng nghe thay đổi presence
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('Online users:', Object.keys(state).length)
// state có dạng:
// {
// 'user-123': [{ online_at: '2024-01-01T00:00:00Z', name: 'Vinh' }],
// 'user-456': [{ online_at: '2024-01-01T00:00:01Z', name: 'Alice' }]
// }
})
channel.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log('User joined:', key, newPresences)
// key: 'user-123'
// newPresences: [{ online_at: '...', name: 'Vinh' }]
})
channel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log('User left:', key)
// key: 'user-123'
// leftPresences: [{ online_at: '...', name: 'Vinh' }]
})
// Subscribe và track presence
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// Bắt đầu track — server sẽ sync state cho tất cả clients khác
await channel.track({
user_id: 'user-123',
name: 'Vinh',
online_at: new Date().toISOString(),
cursor: { x: 0, y: 0 }
})
}
})
// Cập nhật presence state (ví dụ: khi di chuyển cursor)
document.addEventListener('mousemove', (e) => {
channel.track({
user_id: 'user-123',
name: 'Vinh',
online_at: new Date().toISOString(),
cursor: { x: e.clientX, y: e.clientY }
})
})
Presence API đầy đủ:
| Method | Mô tả |
|---|---|
channel.presenceState() | Trả về toàn bộ state hiện tại của tất cả users trong channel |
channel.track(data) | Đăng ký / cập nhật presence của user hiện tại |
channel.untrack() | Hủy presence tracking (user được coi là offline) |
on('presence', { event: 'sync' }) | Fire khi state được đồng bộ đầy đủ (initial + định kỳ) |
on('presence', { event: 'join' }) | Fire khi có user mới tham gia channel |
on('presence', { event: 'leave' }) | Fire khi user rời channel (timeout hoặc untrack) |
sync event gửi toàn bộ state (không phải delta), đảm bảo client luôn có state chính xác ngay cả khi bỏ lỡ events trước đó. Không cần gửi message thủ công.Authorization qua RLS
Realtime tôn trọng RLS policies -- user chỉ nhận được events từ dữ liệu họ được phép xem:
-- Nếu bảng có RLS policy:
CREATE POLICY "Users can only see own messages"
ON messages FOR SELECT
USING (auth.uid() = user_id);
-- Khi subscribe Postgres Changes, user chỉ nhận events
-- từ messages của chính họ (tự động lọc bởi RLS)
Điều này đảm bảo bảo mật ngay cả với realtime subscriptions.
Rate Limiting & Limits
Supabase Realtime áp dụng giới hạn theo plan để đảm bảo chất lượng dịch vụ:
| Plan | Concurrent Clients | Messages / Client / Giây | Broadcast Msg / Giây | Presence Track / Giây |
|---|---|---|---|---|
| Free | 200 | 2 msg/s | 5 msg/s | 5 track/s |
| Pro | 500 | 5 msg/s | 10 msg/s | 10 track/s |
| Team | 1,000 | 10 msg/s | 20 msg/s | 20 track/s |
| Enterprise | Custom | Custom | Custom | Custom |
Chi tiết từng loại rate limit:
| Loại Limit | Áp dụng cho | Khi vượt quá | Cách tối ưu |
|---|---|---|---|
| Concurrent Clients | Số WebSocket connections đồng thời | Connection mới bị từ chối | Dùng filter server-side để giảm unnecessary subscriptions |
| Messages/Client/Second | Tổng số message gửi qua WebSocket từ 1 client (broadcast + presence track) | Message bị throttle/drop | Batch updates, debounce cursor tracking |
| Broadcast Msg/Second | Tổng số broadcast messages trong channel | Message bị drop | Gửi ít thường xuyên hơn, gom nhóm events |
| Presence Track/Second | Số lần gọi track() | Track bị bỏ qua | Chỉ track khi state thực sự thay đổi |
Concurrent Clients là giới hạn WebSocket connections đồng thời, không phải số users. Mỗi tab browser = 1 connection. Messages/Client/Second áp dụng cho tổng broadcast + presence track — nếu cần gửi nhiều hơn, xem xét batch updates hoặc nâng cấp plan.So sánh 3 chế độ
| Tiêu chí | Postgres Changes | Broadcast | Presence |
|---|---|---|---|
| Dữ liệu | Persistent (từ DB) | Ephemeral (tạm thời) | Stateful (đồng bộ) |
| Persistence | Lưu trong Postgres | Không lưu | Không lưu, nhưng sync |
| Client offline | Nhận lại khi reconnect | Bỏ lỡ message | Tự động leave/join |
| Filter | Server-side (WAL) | Client phải tự filter | Không cần |
| Use case | Live feed, dashboard | Chat typing, game events | Online status, collab |
Bài tập
Bài tập 1: Avatar Upload với Preview và Progress
Yêu cầu:
- Cho phép người dùng chọn ảnh từ máy tính
- Hiển thị preview ảnh trước khi upload
- Hiển thị thanh tiến trình upload (progress bar)
- Upload lên Supabase Storage vào thư mục
{userId}/avatar.png - Sau khi upload, hiển thị ảnh từ CDN với image transformation (resize + crop)
Gợi ý triển khai:
<script setup>
const supabase = useSupabaseClient()
const user = useSupabaseUser()
const file = ref(null)
const previewUrl = ref(null)
const progress = ref(0)
const uploadedUrl = ref(null)
function onFileChange(event) {
file.value = event.target.files[0]
previewUrl.value = URL.createObjectURL(file.value)
}
async function uploadAvatar() {
if (!file.value) return
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${user.value.id}/avatar.png`, file.value, {
upsert: true,
contentType: file.value.type
})
if (error) throw error
// URL ảnh đã resize qua CDN
uploadedUrl.value = supabase.storage
.from('avatars')
.getPublicUrl(`${user.value.id}/avatar.png`).data.publicUrl
+ '?width=200&height=200&resize=cover'
}
</script>
Bài tập 2: Image Gallery với CDN Transform
Yêu cầu:
- Hiển thị danh sách ảnh (grid layout) từ một bucket
- Mỗi ảnh hiển thị thumbnail 300x300 qua CDN transform
- Khi click vào ảnh, hiển thị ảnh full-size trong modal/lightbox
- Hỗ trợ lazy loading cho thumbnails
- Có nút tải ảnh với các kích thước khác nhau (origin, 800px, 400px)
Bài tập 3: Real-Time Chat với Message Subscription
Yêu cầu:
- Tạo một chat room đơn giản với danh sách messages
- Subscribe Postgres Changes để nhận messages mới real-time
- Tự động scroll xuống khi có message mới
- Hiển thị thời gian gửi, tên người gửi
- Xử lý optimistic UI khi gửi message
Gợi ý triển khai:
// Subscribe messages real-time
const channel = supabase.channel('chat-room')
channel.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`
}, (payload) => {
messages.value.push(payload.new)
nextTick(() => scrollToBottom())
}).subscribe()
// Gửi message mới
async function sendMessage(content) {
const { data, error } = await supabase
.from('messages')
.insert({
room_id: roomId,
user_id: user.value.id,
content: content
})
.select()
.single()
}
Bài tập 4: Collaborative Cursor Tracking với Presence
Yêu cầu:
- Sử dụng Presence để theo dõi vị trí chuột của tất cả users trong cùng channel
- Hiển thị cursor của các users khác với màu sắc khác nhau
- Hiển thị danh sách users đang online
- Khi user thoát, tự động xóa cursor của họ
- Hiển thị typing indicator khi user đang gõ phím (dùng Broadcast)