2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

こんにちは!前回のエピソードでは、コース詳細ページ(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ダッシュボードにログイン。
    • 「Table Editor」→「New Table」でlessonsテーブルを作成。
    • 以下のカラムを定義:
カラム名 タイプ 説明
id uuid 主キー(自動生成)
course_id uuid コースID(外部キー)
title text レッスンタイトル
description text レッスン説明
video_path text ビデオファイルのパス
created_at timestamptz 作成日時(自動生成)
updated_at timestamptz 更新日時(自動生成)
  • 外部キー制約を追加:

    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))。
  1. テストデータの挿入
    SupabaseのSQL Editorでテストデータを追加:
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_idcoursesテーブルから取得してください。video_pathは後でSupabase Storageにアップロードするパスを仮に設定。


ステップ2: Supabase Storageの設定

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

  1. ストレージバケットの作成

    • Supabaseダッシュボードの「Storage」セクションで新しいバケット(例: course-videos)を作成。
    • 「Public」設定を無効にし、認証済みユーザーのみアクセス可能に設定。
  2. ビデオファイルのアップロード

    • テスト用のビデオファイル(例: react-basics.mp4typescript-intro.mp4)を準備。
    • ダッシュボードの「Storage」→course-videosバケットに手動でアップロード。
    • アップロード後、ファイルパス(例: videos/react-basics.mp4)をlessonsテーブルのvideo_pathに反映。
  3. 署名付きURL生成関数の追加
    src/lib/supabase.tsに署名付きURLを生成する関数を追加:

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のインストール
    必要なパッケージをインストール:
npm install react-player
  1. CDPの更新
    src/app/courses/[id]/page.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. 開発サーバーを起動:
npm run dev
  1. http://localhost:3000/courses/[course-id]にアクセスし、以下の点を確認:
    • コースの詳細(タイトル、説明、価格、画像)が正しく表示される。
    • レッスン一覧が表示され、各レッスンのタイトルと説明が正しい。
    • ビデオプレーヤーが表示され、ビデオが再生可能(コントロール付き)。
    • モバイルデバイスでもビデオがレスポンシブに表示される。
  2. 署名付きURLの有効期限(1時間)後にページをリロードし、ビデオが再生できないことを確認(セキュリティ確認)。

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


まとめと次のステップ

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

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


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

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?