2
2

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)を構築する | エピソード7: リアルタイムコメント機能の統合

Posted at

こんにちは!前回のエピソードでは、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テーブルの設計

レッスンごとのコメントを保存するテーブルを作成します。

  1. 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)。
  1. テストデータの挿入
    SupabaseのSQL Editorでテストデータを追加:
INSERT INTO comments (lesson_id, user_id, content)
VALUES
  ('既存のレッスンID', 'あなたのユーザーID', 'このレッスンはとても分かりやすいです!'),
  ('既存のレッスンID', 'あなたのユーザーID', '次のレッスンが楽しみです!');

注意: lesson_idlessonsテーブル、user_idはSupabase AuthのユーザーIDを確認してください。


ステップ2: Supabase Realtimeの設定

Supabase Realtimeでコメントの変更をリアルタイムに同期します。

  1. 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: コメントコンポーネントの構築

リアルタイムコメント用のクライアントサイドコンポーネントを構築します。

  1. コメントコンポーネントの作成
    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: 動作確認

  1. Supabaseダッシュボードで以下を確認:
    • commentsテーブルとRLSポリシーが正しい。
    • テストデータがcommentsテーブルに追加済み。
  2. 開発サーバーを起動:
npm run dev
  1. http://localhost:3000/courses/[course-id]にアクセスし、以下の点を確認:
    • 購入済みコースの場合、レッスン下にコメントセクションが表示される。
    • 認証済みユーザーがコメントを投稿でき、即座にUIに反映される(リアルタイム)。
    • 未認証ユーザーに「ログインしてください」メッセージが表示される。
    • コメントに投稿者のメールアドレスと投稿日時が表示される。
    • 別のブラウザで同じレッスンにアクセスし、コメントがリアルタイムに同期される。
  2. Supabaseのcommentsテーブルで、投稿データ(lesson_id, user_id, content)が正しいことを確認。

エラーがあれば、SupabaseのRLS設定やリアルタイムサブスクリプションのログを確認してください。


まとめと次のステップ

このエピソードでは、Supabase Realtimeを使ってリアルタイムコメント機能を実装しました。commentsテーブルを設計し、コメント投稿・表示用のUIを構築、RLSでセキュリティを確保しました。これで、ユーザーはレッスンについてインタラクティブに交流できるようになりました!

次回のエピソードでは、LMSのパフォーマンスを最適化し、Core Web Vitalsのスコアを向上させます。Lighthouseでの分析、LCPやCLSの改善、React Server Componentsの活用を行いますので、引き続きお楽しみに!


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

2
2
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
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?