こんにちは!前回のエピソードでは、Stripeを使ってコース購入機能を実装し、ユーザーがコースを購入してコンテンツにアクセスできるようにしました。今回は、Supabase Realtimeを活用してリアルタイムコメント機能を実装します。comments
テーブルを設計し、ユーザーがレッスンにコメントを投稿・閲覧できるUIを構築、Row-Level Security(RLS)でコメントを保護します。これで、インタラクティブな学習体験がさらに向上します!
このエピソードのゴール
- Supabaseに
comments
テーブルを設計し、lessons
と関連付ける。 - Supabase Realtimeでコメントをリアルタイムに同期。
- コメント投稿・表示用のUIコンポーネントを構築。
- RLSでコメントを保護。
必要なもの
- 前回のプロジェクト(
next-lms
)がセットアップ済み。 - Supabaseプロジェクト(
courses
,lessons
,purchases
テーブル設定済み)。 -
@supabase/auth-helpers-nextjs
(既存)。 - 基本的なTypeScript、React、Next.jsの知識。
ステップ1: comments
テーブルの設計
レッスンごとのコメントを保存するテーブルを作成します。
-
Supabaseダッシュボードでテーブル作成:
- Supabaseダッシュボードにログイン。
- 「Table Editor」→「New Table」で
comments
テーブルを作成。 - 以下のカラムを定義:
カラム名 | タイプ | 説明 |
---|---|---|
id | uuid | 主キー(自動生成) |
lesson_id | uuid | レッスンID(外部キー) |
user_id | uuid | コメント投稿者のID |
content | text | コメント内容 |
created_at | timestamptz | 作成日時(自動生成) |
-
外部キー制約を追加:
ALTER TABLE comments ADD CONSTRAINT fk_lesson FOREIGN KEY (lesson_id) REFERENCES lessons (id) ON DELETE CASCADE; ALTER TABLE comments ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE;
-
「Enable Row Level Security (RLS)」を有効化し、ポリシーを設定:
-
Select Policy: 誰でも閲覧可能(
true
)。 -
Insert Policy: 認証済みユーザーのみ(
auth.uid() = user_id
)。 -
Update/Delete Policy: 自分のコメントのみ(
auth.uid() = user_id
)。
-
Select Policy: 誰でも閲覧可能(
-
テストデータの挿入:
SupabaseのSQL Editorでテストデータを追加:
INSERT INTO comments (lesson_id, user_id, content)
VALUES
('既存のレッスンID', 'あなたのユーザーID', 'このレッスンはとても分かりやすいです!'),
('既存のレッスンID', 'あなたのユーザーID', '次のレッスンが楽しみです!');
注意: lesson_id
はlessons
テーブル、user_id
はSupabase AuthのユーザーIDを確認してください。
ステップ2: Supabase Realtimeの設定
Supabase Realtimeでコメントの変更をリアルタイムに同期します。
-
Supabaseクライアントの更新:
src/lib/supabase.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 interface Comment {
id: string;
lesson_id: string;
user_id: string;
content: string;
created_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);
if (error) throw error;
return data.signedUrl;
}
export async function getCommentsByLessonId(lessonId: string) {
const { data, error } = await supabase
.from('comments')
.select('*, users!inner(email)')
.eq('lesson_id', lessonId)
.order('created_at', { ascending: true });
if (error) throw error;
return data as (Comment & { users: { email: string } })[];
}
このコードは:
-
getCommentsByLessonId
: レッスンIDに基づくコメントを取得(ユーザー情報を結合)。 - Supabase Realtimeはクライアントサイドで設定(後述)。
ステップ3: コメントコンポーネントの構築
リアルタイムコメント用のクライアントサイドコンポーネントを構築します。
-
コメントコンポーネントの作成:
src/components/CommentSection.tsx
を作成:
'use client';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { supabase } from '@/lib/supabase';
import { Comment } from '@/lib/supabase';
type CommentForm = {
content: string;
};
export default function CommentSection({ lessonId, userId }: { lessonId: string; userId?: string }) {
const [comments, setComments] = useState<(Comment & { users: { email: string } })[]>([]);
const { register, handleSubmit, reset, formState: { errors } } = useForm<CommentForm>();
useEffect(() => {
// 初期コメントの取得
const fetchComments = async () => {
const { data } = await supabase
.from('comments')
.select('*, users!inner(email)')
.eq('lesson_id', lessonId)
.order('created_at', { ascending: true });
setComments(data || []);
};
fetchComments();
// リアルタイムサブスクリプション
const channel = supabase
.channel(`comments:lesson:${lessonId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'comments',
filter: `lesson_id=eq.${lessonId}`,
},
(payload) => {
if (payload.eventType === 'INSERT') {
setComments((prev) => [...prev, payload.new as Comment & { users: { email: string } }]);
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [lessonId]);
const onSubmit = async (data: CommentForm) => {
if (!userId) {
alert('コメントするにはログインしてください');
return;
}
const { error } = await supabase
.from('comments')
.insert({
lesson_id: lessonId,
user_id: userId,
content: data.content,
});
if (error) {
alert(error.message);
} else {
reset();
}
};
return (
<div className="mt-6">
<h3 className="text-xl font-semibold mb-4">コメント</h3>
{userId ? (
<form onSubmit={handleSubmit(onSubmit)} className="mb-6">
<textarea
{...register('content', { required: 'コメントを入力してください' })}
className="w-full border p-2 rounded"
rows={4}
placeholder="コメントを入力..."
/>
{errors.content && <p className="text-red-500 text-sm">{errors.content.message}</p>}
<button
type="submit"
className="mt-2 bg-primary text-white px-4 py-2 rounded hover:bg-opacity-90"
>
投稿
</button>
</form>
) : (
<p className="text-gray-600 mb-4">
コメントするには<a href="/login" className="text-primary hover:underline">ログイン</a>してください。
</p>
)}
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id} className="border-l-4 pl-4 py-2">
<p className="text-sm text-gray-500">{comment.users.email} - {new Date(comment.created_at).toLocaleString()}</p>
<p>{comment.content}</p>
</div>
))}
</div>
</div>
);
}
このコードは:
- Supabase Realtimeでコメントの追加を監視し、即座にUIを更新。
- React Hook Formでコメント投稿フォームを構築。
- 認証済みユーザーのみ投稿可能。
ステップ4: CDPへのコメント統合
コース詳細ページ(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';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';
import CommentSection from '@/components/CommentSection';
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, searchParams }: { params: { id: string }, searchParams: { success?: string, canceled?: string } }) {
const course = await getCourseById(params.id);
const lessons = await getLessonsByCourseId(params.id);
const supabase = supabaseServer();
const { data: { user } } = await supabase.auth.getUser();
if (!course) {
notFound();
}
const { data: purchase } = await supabase
.from('purchases')
.select('*')
.eq('user_id', user?.id || '')
.eq('course_id', course.id)
.single();
const isPurchased = purchase && purchase.status === 'completed';
const lessonsWithSignedUrls = isPurchased
? await Promise.all(
lessons.map(async (lesson) => ({
...lesson,
signedUrl: await getSignedVideoUrl(lesson.video_path),
}))
)
: [];
if (searchParams.success) {
await supabase
.from('purchases')
.update({ status: 'completed' })
.eq('course_id', course.id)
.eq('user_id', user?.id || '');
}
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>
{isPurchased ? (
<p className="text-green-500 mb-6">このコースは購入済みです!</p>
) : (
<a
href={`/checkout?courseId=${course.id}`}
className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 mb-6 inline-block"
>
このコースを購入
</a>
)}
{searchParams.success && <p className="text-green-500 mb-6">購入が完了しました!</p>}
{searchParams.canceled && <p className="text-red-500 mb-6">購入がキャンセルされました。</p>}
<h2 className="text-2xl font-semibold mb-4">レッスン一覧</h2>
{isPurchased ? (
<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"
/>
<CommentSection lessonId={lesson.id} userId={user?.id} />
</div>
))}
</div>
) : (
<p className="text-gray-600">コースを購入するとレッスンを視聴できます。</p>
)}
</div>
</main>
);
}
このコードは:
- 各レッスンに
<CommentSection>
を追加。 - 購入済みユーザーのみコメント機能を利用可能。
ステップ5: 動作確認
- Supabaseダッシュボードで以下を確認:
-
comments
テーブルとRLSポリシーが正しい。 - テストデータが
comments
テーブルに追加済み。
-
- 開発サーバーを起動:
npm run dev
-
http://localhost:3000/courses/[course-id]
にアクセスし、以下の点を確認:- 購入済みコースの場合、レッスン下にコメントセクションが表示される。
- 認証済みユーザーがコメントを投稿でき、即座にUIに反映される(リアルタイム)。
- 未認証ユーザーに「ログインしてください」メッセージが表示される。
- コメントに投稿者のメールアドレスと投稿日時が表示される。
- 別のブラウザで同じレッスンにアクセスし、コメントがリアルタイムに同期される。
- Supabaseの
comments
テーブルで、投稿データ(lesson_id
,user_id
,content
)が正しいことを確認。
エラーがあれば、SupabaseのRLS設定やリアルタイムサブスクリプションのログを確認してください。
まとめと次のステップ
このエピソードでは、Supabase Realtimeを使ってリアルタイムコメント機能を実装しました。comments
テーブルを設計し、コメント投稿・表示用のUIを構築、RLSでセキュリティを確保しました。これで、ユーザーはレッスンについてインタラクティブに交流できるようになりました!
次回のエピソードでは、LMSのパフォーマンスを最適化し、Core Web Vitalsのスコアを向上させます。Lighthouseでの分析、LCPやCLSの改善、React Server Componentsの活用を行いますので、引き続きお楽しみに!
この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです!次回のエピソードもお楽しみに!