1. Supabase Storage — Buckets, Objects และ Policies
Supabase Storage คือบริการจัดเก็บไฟล์ (File Storage) ที่มาพร้อม Supabase ช่วยให้เก็บรูปภาพ วิดีโอ เอกสาร หรือไฟล์ใดก็ได้บน Cloud โดยไม่ต้องตั้งค่า S3 หรือ Storage Server เอง ภายในใช้ Amazon S3 ต่อพ่วงกับ PostgreSQL จึงได้ประสิทธิภาพและความปลอดภัยระดับ Production
โครงสร้างของ Supabase Storage มี 2 ชั้น: Bucket คือ "ถัง" หรือ Container สำหรับจัดกลุ่มไฟล์
เช่น bucket ชื่อ service-images สำหรับรูปบริการ หรือ avatars สำหรับรูปโปรไฟล์
ภายใน Bucket จะเก็บ Object ซึ่งคือตัวไฟล์จริง ๆ พร้อม metadata เช่น ชนิด ขนาด และ path
Bucket มี 2 ประเภท: Public Bucket ที่ไฟล์ทุกชิ้นมี URL สาธารณะสามารถเข้าถึงได้โดยไม่ต้อง Login เหมาะสำหรับรูปสินค้าหรือ Content ที่ต้องการให้ทุกคนเห็น และ Private Bucket ที่ต้องใช้ Signed URL (มีอายุจำกัด) เพื่อเข้าถึงไฟล์ เหมาะสำหรับไฟล์ส่วนตัวหรือเอกสารสำคัญ
Supabase Project
└── Storage
├── Bucket: service-images (Public)
│ ├── services/1.jpg
│ ├── services/2.png
│ └── services/3.webp
├── Bucket: avatars (Public)
│ ├── users/abc123.jpg
│ └── users/def456.png
└── Bucket: documents (Private)
└── contracts/2024-001.pdf
ใช้ Pattern folder/filename.ext เช่น services/42.jpg หรือ avatars/user_abc.png
เพื่อให้จัดการและดูใน Dashboard ได้ง่าย
หลีกเลี่ยงการใช้ชื่อไฟล์ภาษาไทยหรืออักขระพิเศษเพราะอาจเกิดปัญหาใน URL
2. Upload ไฟล์จาก Browser
การอัปโหลดไฟล์จาก Browser เริ่มต้นที่ <input type="file">
ซึ่งเปิด File Picker ให้ผู้ใช้เลือกไฟล์จากเครื่อง
ใช้ accept="image/*" เพื่อกรองให้เลือกได้เฉพาะรูปภาพ
หรือ accept=".pdf,.doc" สำหรับเอกสาร
เพิ่ม attribute multiple ถ้าต้องการอัปโหลดหลายไฟล์พร้อมกัน
เมื่อผู้ใช้เลือกไฟล์แล้ว event change จะถูก trigger
และดึงข้อมูลไฟล์ได้จาก event.target.files[0]
ซึ่งคือ File object ที่มี property สำคัญได้แก่:
name (ชื่อไฟล์ต้นฉบับ), size (ขนาดเป็น byte),
type (MIME type เช่น image/jpeg), และ lastModified
ก่อนอัปโหลดควรตรวจสอบไฟล์ก่อนเสมอ:
ตรวจ ประเภทไฟล์ ด้วย file.type.startsWith('image/')
ตรวจ ขนาดไฟล์ เช่น file.size <= 5 * 1024 * 1024 (ไม่เกิน 5MB)
การตรวจสอบฝั่ง Frontend ช่วย UX แต่ต้องมี Storage Policy ฝั่ง Supabase ด้วย
Object URL จาก URL.createObjectURL(file)
ใช้แสดง Preview รูปได้ทันทีโดยไม่ต้องอัปโหลดก่อน
เป็น URL ชั่วคราวที่ชี้ไปยัง binary ใน memory ของ Browser
ควรเรียก URL.revokeObjectURL(url) เมื่อไม่ใช้แล้วเพื่อคืน memory
Free tier ของ Supabase รองรับไฟล์สูงสุด 50MB ต่อไฟล์ และ Storage รวมสูงสุด 1GB ต่อ Project ถ้าต้องการ Limit เพิ่มเติมสามารถกำหนดได้ใน Storage Policy
3. supabase.storage.from(bucket).upload(path, file)
API หลักสำหรับอัปโหลดคือ supabase.storage.from(bucket).upload(path, file, options)
โดย bucket คือชื่อ Bucket ที่สร้างไว้, path คือ path ภายใน Bucket
เช่น 'services/42.jpg', และ file คือ File object จาก input
options ที่ควรรู้: upsert: true
คือให้ทับไฟล์เดิมถ้า path ซ้ำกัน (ค่า default คือ false — จะ error ถ้า path ซ้ำ)
contentType กำหนด MIME type ของไฟล์ (ควรส่ง file.type ไป)
cacheControl กำหนด Cache header เช่น '3600' (1 ชั่วโมง)
API คืน { data, error } เหมือนกับ Supabase API ทั้งหมด
เมื่อสำเร็จ data.path จะเป็น path ของไฟล์ที่อัปโหลด
ซึ่งสามารถนำไปใช้กับ getPublicUrl() ในขั้นตอนถัดไป
ถ้าเกิด error data จะเป็น null
const { data, error } = await supabase.storage
.from('service-images') // ชื่อ Bucket
.upload(
'services/42.jpg', // path ใน Bucket
file, // File object
{ upsert: true, // ทับไฟล์เดิมถ้ามี
contentType: file.type }
);
// data.path === 'services/42.jpg' ← เก็บไว้ใช้กับ getPublicUrl
ถ้าไม่ตั้ง upsert: true และมีไฟล์ที่ path เดิมอยู่แล้ว
Supabase จะคืน error The resource already exists
สำหรับฟีเจอร์เปลี่ยนรูปบริการ ควรตั้ง upsert: true เสมอ
หรือลบไฟล์เก่าก่อนด้วย .remove([path])
4. getPublicUrl() — URL สำหรับแสดงรูป
หลังอัปโหลดสำเร็จ ต้องการ URL จริงเพื่อแสดงรูปภาพใน <img src="...">
ใช้ supabase.storage.from(bucket).getPublicUrl(path)
ซึ่ง ไม่ใช่ async (ไม่ต้อง await) เพราะคำนวณ URL จาก path โดยตรงโดยไม่ต้องเรียก API
getPublicUrl() คืน { data: { publicUrl } }
โดย publicUrl จะมีรูปแบบประมาณ
https://<project-id>.supabase.co/storage/v1/object/public/<bucket>/<path>
URL นี้ใช้งานได้เฉพาะ Bucket ที่ตั้งเป็น Public เท่านั้น
ถ้า Bucket เป็น Private ต้องใช้ createSignedUrl(path, expiresIn) แทน
หลังได้ publicUrl แล้ว ขั้นตอนต่อไปคือเก็บ URL นั้นลงในฐานข้อมูล
เช่น อัปเดต column image_url ใน services table
เพื่อให้ Component อื่นสามารถ query ข้อมูลและแสดงรูปได้โดยอัตโนมัติ
getPublicUrl (synchronous — ไม่ต้อง await)
─────────────────────────────────────────────
const { data } = supabase.storage
.from('service-images')
.getPublicUrl('services/42.jpg');
console.log(data.publicUrl);
// https://abc.supabase.co/storage/v1/object/public/service-images/services/42.jpg
createSignedUrl (async — สำหรับ Private Bucket)
─────────────────────────────────────────────────
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl('contracts/001.pdf', 3600); // หมดอายุใน 1 ชม.
5. Storage RLS Policies — ควบคุมสิทธิ์อัปโหลดและดาวน์โหลด
Supabase Storage ใช้ระบบ Policy คล้ายกับ Row Level Security ของ Table
แต่ทำงานระดับไฟล์แทนที่จะเป็นระดับแถวข้อมูล
Policy จะกำหนดว่าใครทำอะไรกับไฟล์ได้บ้าง:
SELECT (ดาวน์โหลด/ดู), INSERT (อัปโหลด), UPDATE (อัปเดต),
DELETE (ลบ)
รูปแบบ Policy ที่ใช้บ่อยที่สุดสำหรับแอปทั่วไป: Public Read ให้ทุกคนดูรูปภาพได้โดยไม่ต้อง Login Authenticated Insert ให้เฉพาะผู้ใช้ที่ Login แล้วอัปโหลดได้ สร้าง Policy ได้ใน Supabase Dashboard → Storage → เลือก Bucket → Policies หรือใช้ SQL ผ่าน Storage schema โดยตรง
ใน Supabase Dashboard จะมี Policy Template พร้อมใช้ เช่น "Allow public read access" และ "Allow authenticated users to upload" ซึ่งตั้งค่าได้ภายใน 2-3 คลิก โดยไม่ต้องเขียน SQL เอง แต่ถ้าต้องการควบคุมละเอียดเช่น "อัปโหลดได้เฉพาะ folder ของตัวเอง" จำเป็นต้องเขียน Policy condition เพิ่มเอง
การตั้ง Bucket เป็น Public แค่เปิดให้ดาวน์โหลดไฟล์ได้โดยไม่ต้อง Auth แต่การอัปโหลด แก้ไข และลบยังต้องมี Policy ควบคุมอยู่ ถ้าไม่สร้าง INSERT Policy ผู้ใช้จะอัปโหลดไม่ได้ แม้ Bucket จะเป็น Public
// snippet 1: สร้าง bucket และ upload ไฟล์
import { supabase } from '../lib/supabase';
// upload รูปภาพบริการ
async function uploadServiceImage(file, serviceId) {
const ext = file.name.split('.').pop();
const path = `services/${serviceId}.${ext}`;
const { data, error } = await supabase.storage
.from('service-images') // ชื่อ bucket
.upload(path, file, {
upsert: true, // ทับไฟล์เดิมถ้ามี
contentType: file.type,
});
if (error) throw error;
return data.path;
}
// ดึง public URL
function getImageUrl(path) {
const { data } = supabase.storage
.from('service-images')
.getPublicUrl(path);
return data.publicUrl;
}
// snippet 2: Image Upload Component
import { useState } from 'react';
import { uploadServiceImage, getImageUrl } from '../lib/storage';
function ImageUpload({ serviceId, onUploaded }) {
const [uploading, setUploading] = useState(false);
const [preview, setPreview] = useState(null);
async function handleFileChange(e) {
const file = e.target.files[0];
if (!file) return;
// แสดง preview ก่อน upload
setPreview(URL.createObjectURL(file));
setUploading(true);
try {
const path = await uploadServiceImage(file, serviceId);
const url = getImageUrl(path);
onUploaded(url);
} catch (err) {
alert('อัปโหลดล้มเหลว: ' + err.message);
} finally {
setUploading(false);
}
}
return (
<div>
{preview && <img src={preview} alt="preview" style={{ width: 200 }} />}
<input type="file" accept="image/*" onChange={handleFileChange} />
{uploading && <p>กำลังอัปโหลด...</p>}
</div>
);
}
// snippet 3: อัปเดต image_url ใน services table หลัง upload
async function updateServiceImage(serviceId, imageUrl) {
const { error } = await supabase
.from('services')
.update({ image_url: imageUrl })
.eq('id', serviceId);
if (error) throw error;
return true;
}
// ใช้ใน Admin page
async function handleImageUploaded(url) {
await updateServiceImage(currentServiceId, url);
queryClient.invalidateQueries({ queryKey: ['services'] });
}
🧪 ปฏิบัติการ Lab 9 — BookEasy: อัปโหลดรูปภาพบริการ
เพิ่มฟีเจอร์ Admin สำหรับอัปโหลดรูปภาพให้แต่ละบริการใน BookEasy ตั้งแต่สร้าง Storage Bucket, Admin Page ไปจนถึง ImageUpload Component และแสดงรูปใน ServiceCard
ต่อยอดจาก Lab 8 — เพิ่มฟีเจอร์ Admin อัปโหลดรูปภาพให้แต่ละบริการ
สร้าง Bucket และตั้งค่า Policy ใน Supabase Dashboard เพื่อให้ Admin อัปโหลดรูปภาพบริการได้
- เปิด Supabase Dashboard → Storage → คลิก "New bucket"
- ตั้งชื่อ Bucket ว่า
service-imagesและเลือก Public bucket → คลิก "Save" - ไปที่ Bucket
service-images→ คลิก "Policies" → "New policy" - เลือก template "Allow access to authenticated users only" สำหรับ INSERT
- ตรวจสอบว่า Policy condition ใช้
auth.role() = 'authenticated'
// ถ้าต้องการตั้งค่าผ่าน Dashboard UI:
// Storage → service-images → Policies → New policy
// เลือก: "Give users access to a folder only they can access"
// หรือสร้าง custom policy ด้วย condition ด้านล่าง
// Policy สำหรับ Public read (ทุกคนดูรูปได้)
// Operation: SELECT
// Target roles: public
// USING expression: true
// Policy สำหรับ Authenticated upload (เฉพาะ user ที่ Login)
// Operation: INSERT
// Target roles: authenticated
// WITH CHECK expression: true
Supabase Dashboard แสดง Bucket service-images ที่มีสถานะ Public
มี Policy สำหรับ SELECT (public) และ INSERT (authenticated)
ทดสอบอัปโหลดไฟล์ผ่าน Dashboard → Storage → service-images → Upload → อัปโหลดสำเร็จ
คลิก "Get URL" และเปิด URL ในเบราว์เซอร์เห็นรูปได้
ถ้าอัปโหลดแล้วได้ error new row violates row-level security policy
แสดงว่า INSERT Policy ยังไม่ถูกต้อง ให้ตรวจสอบว่า target role เป็น authenticated
และ WITH CHECK expression เป็น true หรือ condition ที่ต้องการ
สร้างหน้า Admin ที่แสดงรายการบริการทั้งหมดและป้องกันด้วยการตรวจสอบ email
-
สร้างไฟล์
src/pages/AdminPage.jsx— ใช้useQueryดึง services ทั้งหมด แสดงเป็นรายการพร้อมชื่อบริการและปุ่ม "เปลี่ยนรูป" สำหรับแต่ละรายการ -
เพิ่ม route
/adminในsrc/router.jsห่อAdminPageด้วยProtectedRouteและตรวจสอบ email เพิ่มเติม: ถ้าuser.email !== 'admin@bookeasy.com'ให้แสดงข้อความ "ไม่มีสิทธิ์เข้าถึง"
import { useQuery } from '@tanstack/react-query';
import { supabase } from '../lib/supabase';
import { useAuth } from '../contexts/AuthContext';
export function AdminPage() {
const { user } = useAuth();
const { data: services } = useQuery({
queryKey: ['services'],
queryFn: async () => {
const { data, error } = await supabase.from('services').select('*');
if (error) throw error;
return data;
},
});
if (user?.email !== 'admin@bookeasy.com') {
return <p>ไม่มีสิทธิ์เข้าถึงหน้านี้</p>;
}
return (
<div>
<h1>Admin — จัดการรูปภาพบริการ</h1>
{services?.map(service => (
<div key={service.id}>
<span>{service.name}</span>
{/* ImageUpload Component จะเพิ่มใน Task 3 */}
</div>
))}
</div>
);
}
Login ด้วย admin@bookeasy.com แล้วเปิด /admin → เห็นรายการบริการทั้งหมด
Login ด้วย email อื่น แล้วเปิด /admin → เห็นข้อความ "ไม่มีสิทธิ์เข้าถึง"
ไม่ Login แล้วเปิด /admin → redirect ไป /login
สร้าง account admin@bookeasy.com ใน Supabase Dashboard → Authentication → Users → "Add user"
หรือสมัครผ่านหน้า Register ก่อน แล้วตรวจสอบว่า email ตรงกันใน AdminPage
สร้าง ImageUpload Component ตาม snippet 2 และนำไปใช้ใน AdminPage เพื่ออัปโหลดและอัปเดต image_url
-
สร้างไฟล์
src/components/ImageUpload.jsxตาม snippet 2 ด้านบน Component รับserviceIdและ callbackonUploaded(url) -
เพิ่มในไฟล์
src/lib/storage.jsfunctionuploadServiceImageและgetImageUrlตาม snippet 1 -
ใน
AdminPage.jsxเพิ่ม<ImageUpload>ให้แต่ละบริการ เมื่อ callbackonUploadedถูกเรียก ให้เรียกupdateServiceImageแล้วinvalidateQueries({ queryKey: ['services'] })
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { supabase } from '../lib/supabase';
import { useAuth } from '../contexts/AuthContext';
import { ImageUpload } from '../components/ImageUpload';
async function updateServiceImage(serviceId, imageUrl) {
const { error } = await supabase
.from('services')
.update({ image_url: imageUrl })
.eq('id', serviceId);
if (error) throw error;
}
export function AdminPage() {
const { user } = useAuth();
const queryClient = useQueryClient();
const { data: services } = useQuery({
queryKey: ['services'],
queryFn: async () => {
const { data, error } = await supabase.from('services').select('*');
if (error) throw error;
return data;
},
});
if (user?.email !== 'admin@bookeasy.com') {
return <p>ไม่มีสิทธิ์เข้าถึงหน้านี้</p>;
}
async function handleImageUploaded(serviceId, url) {
await updateServiceImage(serviceId, url);
queryClient.invalidateQueries({ queryKey: ['services'] });
}
return (
<div>
<h1>Admin — จัดการรูปภาพบริการ</h1>
{services?.map(service => (
<div key={service.id} style={{ marginBottom: '1rem' }}>
<strong>{service.name}</strong>
{service.image_url && (
<img src={service.image_url} alt={service.name} style={{ width: 100, display: 'block' }} />
)}
<ImageUpload
serviceId={service.id}
onUploaded={(url) => handleImageUploaded(service.id, url)}
/>
</div>
))}
</div>
);
}
เปิด /admin → แต่ละบริการมีปุ่มเลือกไฟล์
เลือกรูปภาพ → เห็น preview ทันที → รอสักครู่ → ข้อความ "กำลังอัปโหลด..." หายไป
เปิด Supabase Dashboard → Storage → service-images → เห็นไฟล์ที่อัปโหลด
เปิด Supabase Table Editor → services → เห็น image_url อัปเดตแล้ว
ถ้าเกิด error column "image_url" does not exist ต้องเพิ่ม column ก่อน:
เปิด Supabase → Table Editor → services → "Add column" ชื่อ image_url ประเภท text
แล้วกด Save
แก้ไข ServiceCard.jsx ให้แสดงรูปภาพเมื่อมี image_url หรือแสดง Placeholder สีเมื่อยังไม่มีรูป
-
เปิด
src/components/ServiceCard.jsxเพิ่ม conditional rendering: ถ้าservice.image_urlมีค่า แสดง<img src={service.image_url} />ถ้าไม่มี แสดง<div>สี placeholder แทน - ตั้งขนาดรูปให้
width: 100%และheight: 200pxพร้อมobject-fit: cover - เพิ่ม
altattribute ให้รูปภาพเพื่อ Accessibility
export function ServiceCard({ service }) {
const imgStyle = {
width: '100%',
height: '200px',
objectFit: 'cover',
display: 'block',
};
const placeholderStyle = {
...imgStyle,
background: 'linear-gradient(135deg, #e2e8f0, #cbd5e1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#94a3b8',
fontSize: '2rem',
};
return (
<div className="service-card">
{service.image_url ? (
<img
src={service.image_url}
alt={service.name}
style={imgStyle}
/>
) : (
<div style={placeholderStyle}>🏷️</div>
)}
<div className="service-info">
<h3>{service.name}</h3>
<p>{service.description}</p>
<span className="price">฿{service.price}</span>
</div>
</div>
);
}
เปิดหน้าหลัก → บริการที่มี image_url แสดงรูปภาพจริง
บริการที่ยังไม่มีรูปแสดง Placeholder สีเทา
อัปโหลดรูปใน Admin Page → กลับมาหน้าหลัก → รูปแสดงทันที (query invalidate ทำงาน)
รูปภาพไม่ล้น Card และขนาดสม่ำเสมอทุกการ์ด
ถ้ารูปดูยืดหรือผิดสัดส่วน ให้ตรวจสอบว่าใช้ object-fit: cover ครบถ้วน
และ Container มีความสูงกำหนดไว้ชัดเจน
object-fit: cover จะ crop รูปให้พอดีกับ Container โดยไม่ยืด