こんにちは!前回のエピソードでは、コース詳細ページ(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
テーブルと関連付けます。
-
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)
)。
-
Select Policy: 誰でも閲覧可能(
-
テストデータの挿入:
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_id
はcourses
テーブルから取得してください。video_path
は後でSupabase Storageにアップロードするパスを仮に設定。
ステップ2: Supabase Storageの設定
ビデオファイルを保存するために、Supabase Storageを設定します。
-
ストレージバケットの作成:
- Supabaseダッシュボードの「Storage」セクションで新しいバケット(例:
course-videos
)を作成。 - 「Public」設定を無効にし、認証済みユーザーのみアクセス可能に設定。
- Supabaseダッシュボードの「Storage」セクションで新しいバケット(例:
-
ビデオファイルのアップロード:
- テスト用のビデオファイル(例:
react-basics.mp4
、typescript-intro.mp4
)を準備。 - ダッシュボードの「Storage」→
course-videos
バケットに手動でアップロード。 - アップロード後、ファイルパス(例:
videos/react-basics.mp4
)をlessons
テーブルのvideo_path
に反映。
- テスト用のビデオファイル(例:
-
署名付き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にレッスンビデオを統合します。
-
React Playerのインストール:
必要なパッケージをインストール:
npm install react-player
-
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: 動作確認
- Supabaseダッシュボードで以下を確認:
-
lessons
テーブルとテストデータが正しい。 -
course-videos
バケットにビデオファイルがアップロード済み。 - RLSポリシーが適切に設定されている。
-
- 開発サーバーを起動:
npm run dev
-
http://localhost:3000/courses/[course-id]
にアクセスし、以下の点を確認:- コースの詳細(タイトル、説明、価格、画像)が正しく表示される。
- レッスン一覧が表示され、各レッスンのタイトルと説明が正しい。
- ビデオプレーヤーが表示され、ビデオが再生可能(コントロール付き)。
- モバイルデバイスでもビデオがレスポンシブに表示される。
- 署名付きURLの有効期限(1時間)後にページをリロードし、ビデオが再生できないことを確認(セキュリティ確認)。
エラーがあれば、Supabase Storageの設定やvideo_path
を確認してください。
まとめと次のステップ
このエピソードでは、Supabase Storageを使ってビデオレッスンを管理・配信しました。lessons
テーブルを設計し、React Playerでビデオを再生、署名付きURLでコンテンツを保護しました。これで、ユーザーがコース内のレッスンを安全に視聴できるようになりました!
次回のエピソードでは、Stripeを使ってコース購入機能を実装します。チェックアウトページと決済処理を構築し、購入情報をSupabaseに保存しますので、引き続きお楽しみに!
この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです!次回のエピソードもお楽しみに!