Next.jsとSupabaseでオンライ学習プラットフォーム（LMS）を構築する | エピソード5: ビデオレッスンの統合とSupabase Storage

こんにちは！前回のエピソードでは、コース詳細ページ（CDP）を構築し、SEOを最適化しました。今回は、Supabase Storageを使ってビデオレッスンを管理・配信します。`lessons`テーブルを設計し、React Playerでビデオを再生、署名付きURL（Signed URLs）でコンテンツを保護します。これで、ユーザーがコース内のレッスンビデオを安全に視聴できる機能が完成します！

## このエピソードのゴール

- Supabaseに`lessons`テーブルを設計し、`courses`と関連付ける。
- Supabase Storageでビデオファイルを管理。
- React Playerを使ってCDPでビデオを再生。
- 署名付きURLでビデオコンテンツを保護。

## 必要なもの

- 前回のプロジェクト（`next-lms`）がセットアップ済み。
- Supabaseプロジェクト（`courses`テーブルと認証設定済み）。
- `react-player`パッケージ。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: `lessons`テーブルの設計

Supabaseにレッスン情報を保存するテーブルを作成し、`courses`テーブルと関連付けます。

1. **Supabaseダッシュボードでテーブル作成**：
   - [Supabaseダッシュボード](https://supabase.com/)にログイン。
   - 「Table Editor」→「New Table」で`lessons`テーブルを作成。
   - 以下のカラムを定義：

| カラム名        | タイプ       | 説明                     |
|-----------------|--------------|--------------------------|
| id              | uuid         | 主キー（自動生成）       |
| course_id       | uuid         | コースID（外部キー）     |
| title           | text         | レッスンタイトル         |
| description     | text         | レッスン説明             |
| video_path      | text         | ビデオファイルのパス     |
| created_at      | timestamptz  | 作成日時（自動生成）     |
| updated_at      | timestamptz  | 更新日時（自動生成）     |

   - 外部キー制約を追加：
     ```sql
     ALTER TABLE lessons
     ADD CONSTRAINT fk_course
     FOREIGN KEY (course_id)
     REFERENCES courses (id)
     ON DELETE CASCADE;
     ```

   - 「Enable Row Level Security (RLS)」を有効化し、ポリシーを設定：
     - **Select Policy**: 誰でも閲覧可能（`true`）。
     - **Insert/Update Policy**: 認証済みユーザーのみ（`auth.uid() = (SELECT instructor_id FROM courses WHERE id = course_id)`）。

2. **テストデータの挿入**：
   SupabaseのSQL Editorでテストデータを追加：

```sql
INSERT INTO lessons (course_id, title, description, video_path)
VALUES
  ('既存のコースID', 'Reactの基礎', 'Reactコンポーネントの基本を学びます。', 'videos/react-basics.mp4'),
  ('既存のコースID', 'TypeScript入門', 'TypeScriptの型システムを理解します。', 'videos/typescript-intro.mp4');
```

**注意**: `course_id`は`courses`テーブルから取得してください。`video_path`は後でSupabase Storageにアップロードするパスを仮に設定。

---

## ステップ2: Supabase Storageの設定

ビデオファイルを保存するために、Supabase Storageを設定します。

1. **ストレージバケットの作成**：
   - Supabaseダッシュボードの「Storage」セクションで新しいバケット（例: `course-videos`）を作成。
   - 「Public」設定を無効にし、認証済みユーザーのみアクセス可能に設定。

2. **ビデオファイルのアップロード**：
   - テスト用のビデオファイル（例: `react-basics.mp4`、`typescript-intro.mp4`）を準備。
   - ダッシュボードの「Storage」→`course-videos`バケットに手動でアップロード。
   - アップロード後、ファイルパス（例: `videos/react-basics.mp4`）を`lessons`テーブルの`video_path`に反映。

3. **署名付きURL生成関数の追加**：
   `src/lib/supabase.ts`に署名付きURLを生成する関数を追加：

```ts
import { createClient } from '@supabase/supabase-js';
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseKey);
export const supabaseServer = () =>
  createServerComponentClient({ cookies }, { supabaseUrl, supabaseKey });

export interface Course {
  id: string;
  title: string;
  description: string;
  price: number;
  instructor_id: string;
  created_at: string;
  updated_at: string;
  image_url?: string;
}

export interface Lesson {
  id: string;
  course_id: string;
  title: string;
  description: string;
  video_path: string;
  created_at: string;
  updated_at: string;
}

export async function getCourses(page: number = 1, limit: number = 10) {
  const start = (page - 1) * limit;
  const end = start + limit - 1;

  const { data, error } = await supabase
    .from('courses')
    .select('*')
    .range(start, end)
    .order('created_at', { ascending: false });

  if (error) throw error;
  return data as Course[];
}

export async function getCoursesCount() {
  const { count, error } = await supabase
    .from('courses')
    .select('*', { count: 'exact', head: true });

  if (error) throw error;
  return count || 0;
}

export async function getCourseById(id: string) {
  const { data, error } = await supabase
    .from('courses')
    .select('*')
    .eq('id', id)
    .single();

  if (error) throw error;
  return data as Course;
}

export async function getAllCourseIds() {
  const { data, error } = await supabase
    .from('courses')
    .select('id');

  if (error) throw error;
  return data.map((item) => ({ id: item.id }));
}

export async function getLessonsByCourseId(courseId: string) {
  const { data, error } = await supabase
    .from('lessons')
    .select('*')
    .eq('course_id', courseId)
    .order('created_at', { ascending: true });

  if (error) throw error;
  return data as Lesson[];
}

export async function getSignedVideoUrl(videoPath: string) {
  const { data, error } = await supabase.storage
    .from('course-videos')
    .createSignedUrl(videoPath, 3600); // 1時間有効

  if (error) throw error;
  return data.signedUrl;
}
```

このコードは：
- `getLessonsByCourseId`: コースIDに基づくレッスン一覧を取得。
- `getSignedVideoUrl`: ビデオファイルの署名付きURLを生成（有効期限1時間）。

---

## ステップ3: ビデオ再生機能の統合

React Playerを使って、CDPにレッスンビデオを統合します。

1. **React Playerのインストール**：
   必要なパッケージをインストール：

```bash
npm install react-player
```

2. **CDPの更新**：
   `src/app/courses/[id]/page.tsx`を更新してレッスンとビデオを表示：

```tsx
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getCourseById, getLessonsByCourseId, getSignedVideoUrl, Course, Lesson } from '@/lib/supabase';
import { Metadata } from 'next';
import ReactPlayer from 'react-player';

export async function generateStaticParams() {
  const courses = await getAllCourseIds();
  return courses.map((course) => ({
    id: course.id,
  }));
}

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const course = await getCourseById(params.id);
  if (!course) return {};

  return {
    title: `${course.title} | Next.js LMS`,
    description: course.description,
    openGraph: {
      title: course.title,
      description: course.description,
      images: [{ url: course.image_url || 'https://via.placeholder.com/600x400' }],
    },
    twitter: {
      card: 'summary_large_image',
      title: course.title,
      description: course.description,
      images: [course.image_url || 'https://via.placeholder.com/600x400'],
    },
  };
}

export default async function CourseDetailPage({ params }: { params: { id: string } }) {
  const course = await getCourseById(params.id);
  const lessons = await getLessonsByCourseId(params.id);

  if (!course) {
    notFound();
  }

  const lessonsWithSignedUrls = await Promise.all(
    lessons.map(async (lesson) => ({
      ...lesson,
      signedUrl: await getSignedVideoUrl(lesson.video_path),
    }))
  );

  return (
    <main className="container mx-auto p-4">
      <div className="bg-white rounded-lg shadow-lg p-6">
        <Image
          src={course.image_url || 'https://via.placeholder.com/600x400'}
          alt={course.title}
          width={600}
          height={400}
          className="w-full h-64 object-cover rounded-lg mb-6"
          priority
        />
        <h1 className="text-3xl font-bold mb-4">{course.title}</h1>
        <p className="text-gray-600 mb-4">{course.description}</p>
        <p className="text-2xl font-semibold text-primary mb-6">{course.price.toLocaleString()} 円</p>
        <a
          href="/checkout"
          className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 mb-6 inline-block"
        >
          このコースを購入
        </a>
        <h2 className="text-2xl font-semibold mb-4">レッスン一覧</h2>
        <div className="space-y-6">
          {lessonsWithSignedUrls.map((lesson) => (
            <div key={lesson.id} className="border rounded-lg p-4">
              <h3 className="text-xl font-semibold">{lesson.title}</h3>
              <p className="text-gray-600 mb-4">{lesson.description}</p>
              <ReactPlayer
                url={lesson.signedUrl}
                controls
                width="100%"
                height="auto"
                className="rounded-lg"
              />
            </div>
          ))}
        </div>
      </div>
    </main>
  );
}
```

このコードは：
- コースのレッスン一覧を取得し、各レッスンのビデオに署名付きURLを付与。
- React Playerでビデオを再生（コントロール付き）。
- Tailwind CSSでレスポンシブなレイアウトを適用。

---

## ステップ4: 動作確認

1. Supabaseダッシュボードで以下を確認：
   - `lessons`テーブルとテストデータが正しい。
   - `course-videos`バケットにビデオファイルがアップロード済み。
   - RLSポリシーが適切に設定されている。
2. 開発サーバーを起動：

```bash
npm run dev
```

3. `http://localhost:3000/courses/[course-id]`にアクセスし、以下の点を確認：
   - コースの詳細（タイトル、説明、価格、画像）が正しく表示される。
   - レッスン一覧が表示され、各レッスンのタイトルと説明が正しい。
   - ビデオプレーヤーが表示され、ビデオが再生可能（コントロール付き）。
   - モバイルデバイスでもビデオがレスポンシブに表示される。
4. 署名付きURLの有効期限（1時間）後にページをリロードし、ビデオが再生できないことを確認（セキュリティ確認）。

エラーがあれば、Supabase Storageの設定や`video_path`を確認してください。

---

## まとめと次のステップ

このエピソードでは、Supabase Storageを使ってビデオレッスンを管理・配信しました。`lessons`テーブルを設計し、React Playerでビデオを再生、署名付きURLでコンテンツを保護しました。これで、ユーザーがコース内のレッスンを安全に視聴できるようになりました！

次回のエピソードでは、Stripeを使ってコース購入機能を実装します。チェックアウトページと決済処理を構築し、購入情報をSupabaseに保存しますので、引き続きお楽しみに！

---

この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです！次回のエピソードもお楽しみに！