สัปดาห์ที่ 10

Supabase Storage: อัปโหลดไฟล์และจัดการรูปภาพ

CLO3
📖 ทฤษฎี

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
        
เคล็ดลับ — ตั้งชื่อ Path ให้ชัดเจน

ใช้ 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

หมายเหตุ — ขนาดไฟล์ที่ Supabase อนุญาต

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: false (ค่า default)

ถ้าไม่ตั้ง 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 เพิ่มเอง

หมายเหตุ — Public Bucket ไม่ได้หมายความว่าอัปโหลดได้เสรี

การตั้ง Bucket เป็น Public แค่เปิดให้ดาวน์โหลดไฟล์ได้โดยไม่ต้อง Auth แต่การอัปโหลด แก้ไข และลบยังต้องมี Policy ควบคุมอยู่ ถ้าไม่สร้าง INSERT Policy ผู้ใช้จะอัปโหลดไม่ได้ แม้ Bucket จะเป็น Public

💻 โค้ดตัวอย่าง
src/lib/storage.js — upload & getPublicUrl JavaScript
// 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;
}
src/components/ImageUpload.jsx — Preview & Upload JSX
// 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>
  );
}
src/pages/AdminPage.jsx — อัปเดต image_url ใน DB JavaScript
// 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

ต่อยอดจาก Lab 8 — เพิ่มฟีเจอร์ Admin อัปโหลดรูปภาพให้แต่ละบริการ

1
ตั้งค่า Storage Bucket

สร้าง 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'
Storage Policy — ตั้งค่าใน SQL Editor JavaScript
// ถ้าต้องการตั้งค่าผ่าน 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 ที่ต้องการ

2
สร้าง Admin Page

สร้างหน้า Admin ที่แสดงรายการบริการทั้งหมดและป้องกันด้วยการตรวจสอบ email

  • สร้างไฟล์ src/pages/AdminPage.jsx — ใช้ useQuery ดึง services ทั้งหมด แสดงเป็นรายการพร้อมชื่อบริการและปุ่ม "เปลี่ยนรูป" สำหรับแต่ละรายการ
  • เพิ่ม route /admin ใน src/router.js ห่อ AdminPage ด้วย ProtectedRoute และตรวจสอบ email เพิ่มเติม: ถ้า user.email !== 'admin@bookeasy.com' ให้แสดงข้อความ "ไม่มีสิทธิ์เข้าถึง"
src/pages/AdminPage.jsx JSX
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

3
เพิ่ม ImageUpload Component

สร้าง ImageUpload Component ตาม snippet 2 และนำไปใช้ใน AdminPage เพื่ออัปโหลดและอัปเดต image_url

  • สร้างไฟล์ src/components/ImageUpload.jsx ตาม snippet 2 ด้านบน Component รับ serviceId และ callback onUploaded(url)
  • เพิ่มในไฟล์ src/lib/storage.js function uploadServiceImage และ getImageUrl ตาม snippet 1
  • ใน AdminPage.jsx เพิ่ม <ImageUpload> ให้แต่ละบริการ เมื่อ callback onUploaded ถูกเรียก ให้เรียก updateServiceImage แล้ว invalidateQueries({ queryKey: ['services'] })
src/pages/AdminPage.jsx — เพิ่ม ImageUpload JSX
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

4
แสดงรูปใน ServiceCard

แก้ไข 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
  • เพิ่ม alt attribute ให้รูปภาพเพื่อ Accessibility
src/components/ServiceCard.jsx — เพิ่มรูปภาพ JSX
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 โดยไม่ยืด