3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(フロントエンド実装編⑯)~コメント一覧表示モーダル作成~

3
Last updated at Posted at 2026-04-23

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その37)

0. 初めに

こんにちは!

Webアプリケーションの作り方をゼロから解説しています!

前回までで作成・編集モーダルシリーズが完了しました。

今回は、コメント一覧表示モーダルの作成に取り組みたいと思います!

現状は、コメント数が2件以上だと「もっと見る...」をクリックしたときに枠が広がって全て見られるようにしていますね。
image.png
image.png

しかし、コメント数が多い場合(100件とか1000件とか)にとんでもなく下まで伸びてしまって大変です(しかも、一度開いたら閉じられない)。

そこで、「もっと見る...」をクリックするとコメント一覧を表示するモーダルが開かれるようにして、スクロールするたびに少しずつ(例えば10件ずつ)取得するようにしてみたいと思います。

いわゆる無限スクロールというやつです。

1. ブランチ運用

例によって、developブランチを最新化させて、新しいブランチを切って作業をします。

ブランチ名は、feature/frontend/comment-list-modalとかにしましょう。

2. コメント一覧表示モーダル作成

まずは、モーダルを作成して、無限スクロールによるページネーションを実現させましょう!

2.1 コントローラー修正

お察しかとは存じますが、バリバリにバックエンドを修正します。(笑)

コメントコントローラーにコメント一覧を表示するためのindex()メソッドを追加しましょう!

app/Http/Controllers/CommentController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\CommentController.php
use Illuminate\Http\JsonResponse; // 追加

public function index(Lab $lab, Request $request): JsonResponse
    {
        // 取得上限数を定義する
        $limit = min((int) $request->input('limit', 20), 50);
        // カーソル(どこまで進んでいるか)を定義
        $cursor = $request->input('cursor');

        // コメントのクエリを作成
        $query = $lab->comments()
        ->with('user')
        ->latest()
        ->orderBy('id', 'desc');

        // データの開始位置をカーソルで指定
        // 念のため、created_at と id の組み合わせでカーソルを処理
        if ($cursor) {
            $cursorComment = Comment::find($cursor);
            if ($cursorComment) {
                $query->where(function ($q) use ($cursorComment) {
                    $q->where('created_at', '<', $cursorComment->created_at)
                    ->orWhere(function ($q2) use ($cursorComment) {
                        $q2->where('created_at', '=', $cursorComment->created_at)
                            ->where('id', '<', $cursorComment->id);
                    });
                });
            }
        }

        $comments = $query->limit($limit + 1)->get();

        // 次のページがあるかどうかを判定
        $hasMore = $comments->count() > $limit;
        if ($hasMore) {
            $comments = $comments->slice(0, $limit);
        }

        return response()->json([
            'comments' => $comments->values(),
            'hasMore' => $hasMore,
            'nextCursor' => $comments->last()?->id,
        ]);
    }
}

コメントを一度にすべて取得しようとすると数がとても多い時には、時間がかかってしまいそうです。

そこで、リクエストから一度に取得する上限を送られるようにしてちょっとずつ取得するようにします。

コメントだけなく、誰が投稿したのかも表示したいので、userwith()メソッドでEager Loadします。

バックエンドが久々すぎて覚えていないっていう方もいると思います。
あせらず、じっくり復習しておきましょう。

そして、今どこまで取得しているかを示すものを一般的にカーソルと呼びます。

基本的には、created_atで識別しますが、万が一それが同じ場合はidでソートして区別するようにしています。

最後のreturn文は今までと違っていますね。

これまでのコントローラーでは、以下のようにInertiaを通じて、ページコンポーネントに変数をpropsとして渡していたり、redirect()でリダイレクトをしたりしていました。

\project-root\src\app\Http\Controllers\CommentController.php
// create()メソッド
return Inertia::render('Comment/Create', [
            'lab' => $lab,
        ]);

// store()メソッド
return redirect()->route('labs.show', ['lab' => $lab])->with('success', 'コメントが保存されました。');    

今回のindex()メソッドでは、JSONを返却しています。

JSONは、データの形式のことで、Inertiaが有名になる前の従来のバックエンドとフロントエンドを完全に分けて開発する手法では、両者のデータの受け渡しに最適な形式で、軽量で扱いやすいため今もよく用いられています。

詳しくは以下をどうぞ。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/JSON

つまり、index()メソッドでは、何かのビューを表示するのではなく、単にPHPの変数をフロントエンド側で受け取れる状態にしているだけということになります。

Inertiaに慣れすぎていると逆に新鮮に感じられるかもしれませんが、とてもよく用いられる手法なので覚えておきましょう!

2.2 ルーティング追加

しかし、JSONデータを用意するだけではフロントエンドからそれを取得できないので、ルーティングを追加しましょう。

routes/web.php

クリックでコードを見る
\project-root\src\routes\web.php
Route::get('/labs/{lab}/comments', [CommentController::class, 'index'])->name('comment.index');
コメント一覧は、ログインしていなくても見られるようにしたいので`auth`ミドルウェアの外の好きなところに書いてください。

2.3 コメント一覧表示モーダル作成

では、実際に表示するコメント一覧表示モーダルを作成します。
Components/配下にComment/というフォルダを作って、その下に新しいファイルを作成してください。

resources/js/Components/Comment/CommentListModal.jsx

クリックでコードを見る
\project-root\src\resources\js\Components\Comment\CommentListModal.jsx
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import Modal from '@/Components/Common/Modal';

/**
 * コメント一覧モーダルコンポーネント
 * カーソルベースのページネーションでコメントを取得・表示する
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる関数
 * @param {number} props.labId - 研究室ID
 * @param {number} props.totalCount - コメント総数
 */
const CommentListModal = ({ isOpen, onClose, labId, totalCount = 0 }) => {
  const [comments, setComments] = useState([]);
  const [hasMore, setHasMore] = useState(false);
  const [nextCursor, setNextCursor] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [isInitialLoad, setIsInitialLoad] = useState(true);

  /**
   * コメントをAPIから取得する
   * @param {number|null} cursor - カーソル(次のページの開始位置)
   */
  const fetchComments = useCallback(async (cursor = null) => {
    setIsLoading(true);
    try {
      const params = new URLSearchParams({ limit: '20' });
      if (cursor) params.append('cursor', cursor);

      const response = await axios.get(
        route('comment.index', labId),
        { params: { limit: 20, ...(cursor ? { cursor } : {}) } }
      );
      const data = response.data;

      setComments(prev => cursor ? [...prev, ...(data.comments || [])] : (data.comments || []));
      setHasMore(data.hasMore ?? false);
      setNextCursor(data.nextCursor ?? null);
    } catch (error) {
      console.error('コメントの取得に失敗しました', error);
    } finally {
      setIsLoading(false);
      setIsInitialLoad(false);
    }
  }, [labId]);

  // モーダルが開かれたときにコメントを初期取得する
  useEffect(() => {
    if (isOpen) {
      setComments([]);
      setNextCursor(null);
      setHasMore(false);
      setIsInitialLoad(true);
      fetchComments();
    }
  }, [isOpen, fetchComments]);

  /**
   * 「もっと読み込む」ボタン押下時の処理
   */
  const handleLoadMore = () => {
    if (nextCursor && !isLoading) {
      fetchComments(nextCursor);
    }
  };

  return (
    <Modal isOpen={isOpen} onClose={onClose} title={`コメント一覧(${totalCount}件)`} size="md">
      <div className="max-h-[400px] overflow-y-auto">
        {isInitialLoad && isLoading ? (
          <p className="text-sm text-[#747D8C] text-center py-4">読み込み中...</p>
        ) : comments.length > 0 ? (
          <div className="space-y-3">
            {comments.map(comment => (
              <div key={comment.id} className="border-b border-gray-200 pb-3">
                <h3 className="text-sm font-medium text-black">
                  {comment.user?.name || '匿名'}
                </h3>
                <p className="text-sm text-[#747D8C] mt-1 whitespace-pre-wrap">
                  {comment.content}
                </p>
              </div>
            ))}
            {hasMore && (
              <button
                onClick={handleLoadMore}
                disabled={isLoading}
                className="w-full text-sm text-[#747D8C] hover:text-black hover:underline py-2"
              >
                {isLoading ? '読み込み中...' : 'もっと読み込む'}
              </button>
            )}
          </div>
        ) : (
          <p className="text-sm text-[#747D8C] text-center py-4">コメントがありません</p>
        )}
      </div>
    </Modal>
  );
};

export default CommentListModal;

例によって、Modalコンポーネントをインポートして再利用しています。

一番重要なのは、fetchComments()関数内で使用されているaxios.getという部分です。

axiosとは

axiosとは、ブラウザからHTTPSリクエストを送信することができる、JavaScriptのモジュールです。

同じプロジェクト内のJavaScriptファイル同士のやり取りではなく、あくまで外部のリソースに対してアクセスするものです。

同じような役割を果たすものにfetchがあり、これら二つがかなりメジャーに使われている印象です。

それぞれの違いについては、以下の記事を一読すると良いかもです。
https://zenn.dev/kenjh/articles/85b6ebf712b6eb

実は僕も大きな違いは感じていないのですが、すでにプロジェクトにaxiosがインストール済みなので、こっちを使ってみたいと思いました。

\project-root\src\package.json
    "devDependencies": {
        "axios": "^1.6.4",
    }

以下のように書くことで、簡単にバックエンドのデータを取得・変数への格納ができてしまいます!

      // .get()でルーティングをたたきデータを取得。パラメータも送信できる
      const response = await axios.get(
        route('comment.index', labId),
        { params: { limit: 20, ...(cursor ? { cursor } : {}) } }
      );

      // .dataで中身を展開。変数に代入もできる
      const data = response.data;

非同期処理とは

少し気になるものがあるとすれば、axiosの前に書いてあるawaitや関数の最初についているasyncだと思います。

これらはこのシリーズではおそらく初めて登場するので無理もありません。

これは、いわゆる非同期処理というやつです!

非同期処理については、一応JavaScriptの基本文法なので細かい解説は控えますが、ここでのざっくりとした役割となぜ使うのかについて解説はしますね。

もし、非同期処理自体が怪しい方は分かりやすそうな記事を一つ読んでみることをお勧めします。

これとかはかなりしっかり書かれていると思うのでよければ!
https://qiita.com/zuisho-1848/items/a33813b78a008083387b

JavaScriptは同時に一つの処理しか実行できない仕組み(シングルスレッド)なので、同期処理(非同期処理じゃない普通の書き方)をするとバックエンドなどの外部のリソースにデータを取得をしに行って帰ってくるまでの間画面が固まってしまいます。

そこで、リクエストを送信してデータを取得し戻ってくるまでの間に他の処理を順番を待たずにできるようにする(非同期処理にする)ことで、画面が固まるのを防ぐことができます。

Googleマップなどが分かりやすいと思いますが、地図を広げて読み込もうとしてもし時間がかかってしまった場合に例えば「位置情報を取得して読み込んで変数に代入する」みたいな処理があると思います。

位置情報の取得に多くの時間がかかってしまったとします。

その時、同期処理だと、変数に代入しないと次の処理に行けないためブラウザ画面が固まって、スクロールや取り消しなどができなくなってしまい、やや使いにくそうです。

一方で、非同期処理だと取得できていない地図の部分を例えば「読み込み中」みたいに表示しておけば、他の取得済みの部分を見たり、読み込みを中止したりなどの操作もできます。

このように外部リソースに対してリクエストを送信するには、JavaScirptの非同期処理がほぼ必須の状態で、先ほど紹介したfetchaxiosはこの非同期処理を前提として作られています。

asyncとawaitとは

しかし、処理の順番がばらばらになりすぎてしまっては、エラーになることもあり得ます。

例えば、先ほどのコードではバックエンドからのレスポンをresponseという変数に代入していますが、これが完了しないうちはその下の行でその結果を取得して変数に代入することはできません。

      const response = await axios.get(
        route('comment.index', labId),
        { params: { limit: 20, ...(cursor ? { cursor } : {}) } }
      );
      const data = response.data;

定義されていない変数を使おうとしてエラーが発生してしまいます。

これを防ぐのが一行目についているawaitというやつです。

これはいわば、「非同期処理なんだけど、この下の行はこの行が完了するまでまだ実行しないで待っておいて」と注意書きしている感じです。

これにより、responseが定義されて初めて、その下の行が実行されるので順番違いによるエラーを防ぐことができます。

一方で、asyncというのは、「この関数内ではawaitを使うぜ」という宣言みたいなものだと思ってもらえればよいです!

本当はもっと奥が深いのですが、今回は初めてだったということもあり、これくらいの解説にとどめておきます。

要するに、バックエンドなどの外部のAPI通信を行うときは非同期処理を活用しましょうということです!

2.4 研究室詳細画面修正

このコンポーネントを呼び出せるように研究室詳細画面に追加しましょう。
resources/js/Pages/Lab/Show.jsx

クリックでコードを見る
\project-root\src\resources\js\Pages\Lab\Show.jsx
import CommentListModal from '@/Components/Comment/CommentListModal'; // 追加

  const [isCommentListModalOpen, setIsCommentListModalOpen] = useState(false); // 追加

return (
                    {/* 2件以上の場合、もっと見るボタンを表示 */}
                    {comments.length > 1 && (
                      <button
                        onClick={() => setIsCommentListModalOpen(true)}
                        className="text-sm text-[#747D8C] hover:text-black hover:underline"
                      >
                        もっと見る...
                      </button>
                    )}

      {/* コメント一覧表示モーダル */}
      <CommentListModal
        isOpen={isCommentListModalOpen}
        onClose={() => setIsCommentListModalOpen(false)}
        labId={lab.id}
        totalCount={comments?.length || 0}
      />
)

2.5 シーダー修正

今は20件ずつコメントを取得して、「もっと読み込む」クリックでさらに追加で20件取得できるようにしています。

しかし、現状のコメントのシーダーだと件数が少なくて動作を確認しにくいです。

そこで、コメントの件数を増やします。

database/seeders/CommentSeeder.php

クリックでコードを見る
\project-root\src\database\seeders\CommentSeeder.php
        // 各研究室に対して1〜50件のコメントを生成
        foreach ($labs as $lab) {
            $commentCount = rand(1, 50);

修正出来たら、以下のコマンドを実行してデータを入れ替えます。

実行コマンド

/var/www
$ php artisan migrate:fresh --seed

2.6 動作確認

コメント数が2件以上のコメントを見つけて「もっと見る...」をクリックするとコメント一覧を表示するモーダルが開かれることを確認してください。
image.png

「もっと読み込む」をクリックすると追加でコメントを取得できることも併せて確認してください!
image.png

問題なければ、コミットしちゃいましょう!

3. コメント投稿エリア作成

3.1 ブランチ運用

先ほど作ったfeature/frontend/comment-list-modalにチェックアウトしたまま作業を続けましょう。

3.2 コントローラー修正

コメントコントローラーのstore()メソッドをJSONを返すように修正します。

app/Http/Controllers/CommentController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\CommentController.php
    // create()メソッドは削除
    
    public function store(Request $request, Lab $lab): JsonResponse
    {
        // ポリシーで認可をチェック
        $this->authorize('create', Comment::class);

        // バリデーション
        $validated = $request->validate([
            'content' => 'required|string|max:1000',
        ]);

        // バリデーション済みのデータを保存
        $comment = new Comment();
        $comment->user_id = FacadesAuth::id();
        $comment->lab_id = $lab->id;
        $comment->content = $validated['content'];
        $comment->save();

        return response()->json($comment->load('user'), 201); // 修正
    }
コメント作成ページは必要なくなったので、ついでに`create()`メソッドも削除しておきました。

201とは

HTTPステータスコードっていうやつです!

詳しい説明はまた今度にします!(笑)

良ければこちらをどうぞ。
https://qiita.com/takuo_maeda/items/9cff0b03e74f8f600eee

3.3 コメント投稿エリア追加

resources/js/Components/Comment/CommentListModal.jsx

クリックでコードを見る
\project-root\src\resources\js\Components\Comment\CommentListModal.jsx
import { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import { usePage } from '@inertiajs/react';
import Modal from '@/Components/Common/Modal';

/**
 * コメント一覧モーダルコンポーネント
 * カーソルベースのページネーションでコメントを取得・表示する
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる関数
 * @param {number} props.labId - 研究室ID
 * @param {number} props.totalCount - コメント総数
 * @param {Function} props.onCommentPosted - コメント投稿後のコールバック
 */
const CommentListModal = ({ isOpen, onClose, labId, totalCount = 0, onCommentPosted }) => {
  const { auth } = usePage().props;
  const [comments, setComments] = useState([]);
  const [hasMore, setHasMore] = useState(false);
  const [nextCursor, setNextCursor] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [isInitialLoad, setIsInitialLoad] = useState(true);
  const [newComment, setNewComment] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [validationError, setValidationError] = useState('');
  const [isFocused, setIsFocused] = useState(false);
  const textareaRef = useRef(null);

  /**
   * テキストエリアの高さを内容に応じて自動調整する
   */
  const adjustTextareaHeight = () => {
    const textarea = textareaRef.current;
    if (textarea) {
      textarea.style.height = 'auto';
      textarea.style.height = `${textarea.scrollHeight}px`;
    }
  };

  /**
   * コメントをAPIから取得する
   * @param {number|null} cursor - カーソル(次のページの開始位置)
   */
  const fetchComments = useCallback(async (cursor = null) => {
    setIsLoading(true);
    try {
      const params = new URLSearchParams({ limit: '20' });
      if (cursor) params.append('cursor', cursor);

      const response = await axios.get(
        route('comment.index', labId),
        { params: { limit: 20, ...(cursor ? { cursor } : {}) } }
      );
      const data = response.data;

      setComments(prev => cursor ? [...prev, ...(data.comments || [])] : (data.comments || []));
      setHasMore(data.hasMore ?? false);
      setNextCursor(data.nextCursor ?? null);
    } catch (error) {
      console.error('コメントの取得に失敗しました', error);
    } finally {
      setIsLoading(false);
      setIsInitialLoad(false);
    }
  }, [labId]);

  // モーダルが開かれたときにコメントを初期取得する
  useEffect(() => {
    if (isOpen) {
      setComments([]);
      setNextCursor(null);
      setHasMore(false);
      setIsInitialLoad(true);
      setIsFocused(false);
      setNewComment('');
      setValidationError('');
      fetchComments();
    }
  }, [isOpen, fetchComments]);

  /**
   * 「もっと読み込む」ボタン押下時の処理
   */
  const handleLoadMore = () => {
    if (nextCursor && !isLoading) {
      fetchComments(nextCursor);
    }
  };

  /**
   * コメント投稿処理
   */
  const handleSubmit = async () => {
    if (!newComment.trim()) return;

    setIsSubmitting(true);
    setValidationError('');
    try {
      await axios.post(route('comment.store', labId), {
        content: newComment,
      });
      setNewComment('');
      setIsFocused(false);
      // コメント一覧を再取得
      setComments([]);
      setNextCursor(null);
      setHasMore(false);
      fetchComments();
      onCommentPosted?.();
    } catch (error) {
      if (error.response?.status === 422) {
        const errors = error.response.data.errors;
        setValidationError(errors?.content?.[0] || 'バリデーションエラーが発生しました。');
      } else {
        console.error('コメントの投稿に失敗しました', error);
        setValidationError('コメントの投稿に失敗しました。');
      }
    } finally {
      setIsSubmitting(false);
    }
  };

  /**
   * キャンセルボタン押下時の処理
   */
  const handleCancel = () => {
    setNewComment('');
    setValidationError('');
    setIsFocused(false);
  };

  return (
    <Modal isOpen={isOpen} onClose={onClose} title={`コメント一覧(${totalCount}件)`} size="md">
      <div className="min-h-[60vh] max-h-[60vh] overflow-y-auto">
        {/* コメント投稿フォーム */}
        {auth?.user && (
          <div className="mb-4 border-b border-gray-200 pb-4">
            <textarea
              ref={textareaRef}
              value={newComment}
              onChange={(e) => {
                setNewComment(e.target.value);
                setValidationError('');
                adjustTextareaHeight();
              }}
              onFocus={() => setIsFocused(true)}
              placeholder="コメントを入力..."
              rows={1}
              maxLength={1000}
              disabled={isSubmitting}
              className="w-full border-0 border-b border-gray-200 bg-[#EEF7FB] px-3 py-2 text-sm resize-none overflow-hidden focus:border-blue-500 focus:outline-none focus:ring-0 disabled:bg-gray-100"
            />
            <ErrorSlot message={validationError} />
            {isFocused && (
              <div className="mt-2 flex justify-end">
                <div className="flex gap-2">
                  <button
                    onClick={handleCancel}
                    disabled={isSubmitting}
                    className="rounded-md bg-transparent px-3 py-1.5 text-sm text-[#747D8C] disabled:opacity-50"
                  >
                    キャンセル
                  </button>
                  <button
                    onClick={handleSubmit}
                    disabled={isSubmitting || !newComment.trim()}
                    className="rounded-md bg-[#33E1ED] px-3 py-1.5 text-sm text-white disabled:opacity-50"
                  >
                    {isSubmitting ? '投稿中...' : 'コメントする'}
                  </button>
                </div>
              </div>
            )}
          </div>
        )}

        {isInitialLoad && isLoading ? (
          <p className="text-sm text-[#747D8C] text-center py-4">読み込み中...</p>
        ) : comments.length > 0 ? (
          <div className="space-y-3">
            {comments.map(comment => (
              <div key={comment.id} className="border-b border-gray-200 pb-3">
                <h3 className="text-sm font-medium text-black">
                  {comment.user?.name || '匿名'}
                </h3>
                <p className="text-sm text-[#747D8C] mt-1 whitespace-pre-wrap">
                  {comment.content}
                </p>
              </div>
            ))}
            {hasMore && (
              <button
                onClick={handleLoadMore}
                disabled={isLoading}
                className="w-full text-sm text-[#747D8C] hover:text-black hover:underline py-2"
              >
                {isLoading ? '読み込み中...' : 'もっと読み込む'}
              </button>
            )}
          </div>
        ) : (
          <p className="text-sm text-[#747D8C] text-center py-4">コメントがありません</p>
        )}
      </div>
    </Modal>
  );
};

const ErrorSlot = ({ message }) => (
  <p className="h-5 text-sm leading-5 text-red-500 overflow-hidden">
    {message ?? '\u00A0'}
  </p>
);

export default CommentListModal;

3.4 コメント作成ページコンポーネント削除

必要なくなったファイルを削除します。
resources/js/Pages/Comment/Create.jsx

3.5 動作確認

コメントの入力・投稿ができることを確認してみてください!
image.png
image.png

できたら、コミットしておきましょう!

4. コメント編集・削除ボタン作成

最後は、投稿済みのコメントを編集・削除できるようにしましょう!

4.1 コントローラー修正

update()メソッドとdestroy()メソッドを修正して、JSONを返すようにします。

ついでに、使われなくなったInertiaのuse宣言は削除しておきましょう。

また、編集用ページは使わなくなったのでedit()メソッドは削除しておきましょう。

use Inertia\Inertia; // 削除

app/Http/Controllers/CommentController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\CommentController.php
    public function update(Request $request, Comment $comment): JsonResponse
    {
        // ポリシーで認可をチェック
        $this->authorize('update', $comment);

        // バリデーション
        $validated = $request->validate([
            'content' => 'required|string|max:1000',
        ]);

        // バリデーション済みのデータを更新
        $comment->content = $validated['content'];
        $comment->save();

        return response()->json($comment->load('user'));
    }

    public function destroy(Comment $comment): JsonResponse
    {
        // ポリシーで認可をチェック
        $this->authorize('delete', $comment);

        // コメントを削除
        $comment->delete();

        return response()->json(['message' => 'コメントが削除されました。']);
    }

4.2 コメント一覧表示モーダル修正

resources/js/Components/Comment/CommentListModal.jsx

クリックでコードを見る
\project-root\src\resources\js\Components\Comment\CommentListModal.jsx
import { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import { usePage } from '@inertiajs/react';
import Modal from '@/Components/Common/Modal';
import EditIcon from '@/Assets/icons/edit.svg';
import TrashIcon from '@/Assets/icons/trash.svg';

/**
 * コメント一覧モーダルコンポーネント
 * カーソルベースのページネーションでコメントを取得・表示する
 * @param {Object} props
 * @param {boolean} props.isOpen - モーダルの開閉状態
 * @param {Function} props.onClose - モーダルを閉じる関数
 * @param {number} props.labId - 研究室ID
 * @param {number} props.totalCount - コメント総数
 * @param {Function} props.onCommentPosted - コメント投稿後のコールバック
 * @param {Function} props.onDelete - 削除アイコン押下時のコールバック(コメントオブジェクトを引数に受け取る)
 */
const CommentListModal = ({ isOpen, onClose, labId, totalCount = 0, onCommentPosted, onDelete }) => {
  const { auth } = usePage().props;
  const [comments, setComments] = useState([]);
  const [hasMore, setHasMore] = useState(false);
  const [nextCursor, setNextCursor] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [isInitialLoad, setIsInitialLoad] = useState(true);
  const [newComment, setNewComment] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [createValidationError, setCreateValidationError] = useState('');
  const [isFocused, setIsFocused] = useState(false);
  const [editingCommentId, setEditingCommentId] = useState(null);
  const [editContent, setEditContent] = useState('');
  const [isEditSubmitting, setIsEditSubmitting] = useState(false);
  const [editValidationError, setEditValidationError] = useState('');
  const textareaRef = useRef(null);
  const editTextareaRef = useRef(null);

  /**
   * テキストエリアの高さを内容に応じて自動調整する
   */
  const adjustTextareaHeight = (ref = textareaRef) => {
    const textarea = ref.current;
    if (textarea) {
      textarea.style.height = 'auto';
      textarea.style.height = `${textarea.scrollHeight}px`;
    }
  };

  /**
   * コメントをAPIから取得する
   * @param {number|null} cursor - カーソル(次のページの開始位置)
   */
  const fetchComments = useCallback(async (cursor = null) => {
    setIsLoading(true);
    try {
      const params = new URLSearchParams({ limit: '20' });
      if (cursor) params.append('cursor', cursor);

      const response = await axios.get(
        route('comment.index', labId),
        { params: { limit: 20, ...(cursor ? { cursor } : {}) } }
      );
      const data = response.data;

      setComments(prev => cursor ? [...prev, ...(data.comments || [])] : (data.comments || []));
      setHasMore(data.hasMore ?? false);
      setNextCursor(data.nextCursor ?? null);
    } catch (error) {
      console.error('コメントの取得に失敗しました', error);
    } finally {
      setIsLoading(false);
      setIsInitialLoad(false);
    }
  }, [labId]);

  // 編集テキストエリアが表示されたとき高さを自動調整する
  useEffect(() => {
    if (editingCommentId !== null && editTextareaRef.current) {
      adjustTextareaHeight(editTextareaRef);
      editTextareaRef.current.focus();
    }
  }, [editingCommentId]);

  // モーダルが開かれたときにコメントを初期取得する
  useEffect(() => {
    if (isOpen) {
      setComments([]);
      setNextCursor(null);
      setHasMore(false);
      setIsInitialLoad(true);
      setIsFocused(false);
      setNewComment('');
      setCreateValidationError('');
      setEditingCommentId(null);
      setEditContent('');
      setEditValidationError('');
      fetchComments();
    }
  }, [isOpen, fetchComments]);

  /**
   * 「もっと読み込む」ボタン押下時の処理
   */
  const handleLoadMore = () => {
    if (nextCursor && !isLoading) {
      fetchComments(nextCursor);
    }
  };

  /**
   * コメント投稿処理
   */
  const handleCreateSubmit = async () => {
    if (!newComment.trim()) return;

    setIsSubmitting(true);
    setCreateValidationError('');
    try {
      await axios.post(route('comment.store', labId), {
        content: newComment,
      });
      setNewComment('');
      setIsFocused(false);
      // コメント一覧を再取得
      setComments([]);
      setNextCursor(null);
      setHasMore(false);
      fetchComments();
      onCommentPosted?.();
    } catch (error) {
      if (error.response?.status === 422) {
        const errors = error.response.data.errors;
        setCreateValidationError(errors?.content?.[0] || 'バリデーションエラーが発生しました。');
      } else {
        console.error('コメントの投稿に失敗しました', error);
        setCreateValidationError('コメントの投稿に失敗しました。');
      }
    } finally {
      setIsSubmitting(false);
    }
  };

  /**
   * キャンセルボタン押下時の処理
   */
  const handleCancel = () => {
    setNewComment('');
    setCreateValidationError('');
    setIsFocused(false);
  };

  /**
   * 編集モードを開始する
   * @param {Object} comment - 編集対象のコメント
   */
  const handleEditStart = (comment) => {
    setEditingCommentId(comment.id);
    setEditContent(comment.content);
    setEditValidationError('');
  };

  /**
   * 編集キャンセル処理
   */
  const handleEditCancel = () => {
    setEditingCommentId(null);
    setEditContent('');
    setEditValidationError('');
  };

  /**
   * 編集送信処理
   */
  const handleEditSubmit = async () => {
    if (!editContent.trim()) return;

    setIsEditSubmitting(true);
    setEditValidationError('');
    try {
      await axios.put(route('comment.update', editingCommentId), {
        content: editContent,
      });
      setEditingCommentId(null);
      setEditContent('');
      // コメント一覧を再取得
      setComments([]);
      setNextCursor(null);
      setHasMore(false);
      fetchComments();
    } catch (error) {
      if (error.response?.status === 422) {
        const errors = error.response.data.errors;
        setEditValidationError(errors?.content?.[0] || 'バリデーションエラーが発生しました。');
      } else {
        console.error('コメントの編集に失敗しました', error);
        setEditValidationError('コメントの編集に失敗しました。');
      }
    } finally {
      setIsEditSubmitting(false);
    }
  };

  return (
    <Modal isOpen={isOpen} onClose={onClose} title={`コメント一覧(${totalCount}件)`} size="md">
      <div className="min-h-[60vh] max-h-[60vh] overflow-y-auto">
        {/* コメント投稿フォーム */}
        {auth?.user && (
          <div className="mb-4 border-b border-gray-200 pb-4">
            <textarea
              ref={textareaRef}
              value={newComment}
              onChange={(e) => {
                setNewComment(e.target.value);
                setCreateValidationError('');
                adjustTextareaHeight();
              }}
              onFocus={() => setIsFocused(true)}
              placeholder="コメントを入力..."
              rows={1}
              maxLength={1000}
              disabled={isSubmitting}
              className="w-full border-0 border-b border-gray-200 bg-[#EEF7FB] px-3 py-2 text-sm resize-none overflow-hidden focus:border-blue-500 focus:outline-none focus:ring-0 disabled:bg-gray-100"
            />
            <ErrorSlot message={createValidationError} />
            {isFocused && (
              <div className="mt-2 flex justify-end">
                <div className="flex gap-2">
                  <button
                    onClick={handleCancel}
                    disabled={isSubmitting}
                    className="rounded-md bg-transparent px-3 py-1.5 text-sm text-[#747D8C] disabled:opacity-50"
                  >
                    キャンセル
                  </button>
                  <button
                    onClick={handleCreateSubmit}
                    disabled={isSubmitting || !newComment.trim()}
                    className="rounded-md bg-[#33E1ED] px-3 py-1.5 text-sm text-white disabled:opacity-50"
                  >
                    {isSubmitting ? '投稿中...' : 'コメントする'}
                  </button>
                </div>
              </div>
            )}
          </div>
        )}

        {isInitialLoad && isLoading ? (
          <p className="text-sm text-[#747D8C] text-center py-4">読み込み中...</p>
        ) : comments.length > 0 ? (
          <div className="space-y-3">
            {comments.map(comment => (
              <div key={comment.id} className="border-b border-gray-200 pb-3">
                {editingCommentId === comment.id ? (
                  /* 編集モード */
                  <div>
                    <h3 className="text-sm font-medium text-black mb-1">
                      {comment.user?.name || '匿名'}
                    </h3>
                    <textarea
                      ref={editTextareaRef}
                      value={editContent}
                      onChange={(e) => {
                        setEditContent(e.target.value);
                        setEditValidationError('');
                        adjustTextareaHeight(editTextareaRef);
                      }}
                      rows={1}
                      maxLength={1000}
                      disabled={isEditSubmitting}
                      className="w-full border-0 border-b border-gray-200 bg-[#EEF7FB] px-3 py-2 text-sm resize-none overflow-hidden focus:border-blue-500 focus:outline-none focus:ring-0 disabled:bg-gray-100"
                    />
                    <ErrorSlot message={editValidationError} />
                    <div className="mt-2 flex justify-end">
                      <div className="flex gap-2">
                        <button
                          onClick={handleEditCancel}
                          disabled={isEditSubmitting}
                          className="rounded-md bg-transparent px-3 py-1.5 text-sm text-[#747D8C] disabled:opacity-50"
                        >
                          キャンセル
                        </button>
                        <button
                          onClick={handleEditSubmit}
                          disabled={isEditSubmitting || !editContent.trim()}
                          className="rounded-md bg-[#33E1ED] px-3 py-1.5 text-sm text-white disabled:opacity-50"
                        >
                          {isEditSubmitting ? '編集中...' : '編集する'}
                        </button>
                      </div>
                    </div>
                  </div>
                ) : (
                  /* 通常表示モード */
                  <>
                    <div className="flex items-center justify-between">
                      <h3 className="text-sm font-medium text-black">
                        {comment.user?.name || '匿名'}
                      </h3>
                      {auth?.user?.id === comment.user_id && (
                        <div className="flex items-center gap-2">
                          <button type="button" className="p-1 hover:opacity-70" onClick={() => handleEditStart(comment)}>
                            <img src={EditIcon} alt="編集" className="w-4 h-4" />
                          </button>
                          <button type="button" className="p-1 hover:opacity-70" onClick={() => onDelete?.(comment)}>
                            <img src={TrashIcon} alt="削除" className="w-4 h-4" />
                          </button>
                        </div>
                      )}
                    </div>
                    <p className="text-sm text-[#747D8C] mt-1 whitespace-pre-wrap">
                      {comment.content}
                    </p>
                  </>
                )}
              </div>
            ))}
            {hasMore && (
              <button
                onClick={handleLoadMore}
                disabled={isLoading}
                className="w-full text-sm text-[#747D8C] hover:text-black hover:underline py-2"
              >
                {isLoading ? '読み込み中...' : 'もっと読み込む'}
              </button>
            )}
          </div>
        ) : (
          <p className="text-sm text-[#747D8C] text-center py-4">コメントがありません</p>
        )}
      </div>
    </Modal>
  );
};

const ErrorSlot = ({ message }) => (
  <p className="h-5 text-sm leading-5 text-red-500 overflow-hidden">
    {message ?? '\u00A0'}
  </p>
);

export default CommentListModal;

4.3 研究室詳細画面修正

resources/js/Pages/Lab/Show.jsx

クリックでコードを見る
\project-root\src\resources\js\Pages\Lab\Show.jsx
import { useState, useEffect, useRef } from 'react';
import { Head, router } from '@inertiajs/react';
import axios from 'axios';
import AppLayout from '@/Layouts/AppLayout';
import StarRating from '@/Components/Lab/Star/StarRating';
import Breadcrumb from '@/Components/Common/Breadcrumb';
import CreateReviewModal from '@/Components/Review/CreateReviewModal';
import EditReviewModal from '@/Components/Review/EditReviewModal';
import MenuPopover from '@/Components/Common/MenuPopover';
import KebabIcon from '@/Components/Common/KebabIcon';
import EditLabModal from '@/Components/Lab/EditLabModal';
import AlertModal from '@/Components/Common/AlertModal';
import CommentListModal from '@/Components/Comment/CommentListModal';
import { formatRating } from '@/utils/formatRating';
import {
  Chart as ChartJS,
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend,
} from 'chart.js';
import { Radar } from 'react-chartjs-2';

// Chart.jsのコンポーネントを登録
ChartJS.register(RadialLinearScale, PointElement, LineElement, Filler, Tooltip, Legend);

/**
 * 研究室詳細ページコンポーネント
 * @param {Object} props - コンポーネントのprops
 * @param {Object} props.lab - 研究室オブジェクト
 * @param {Object} props.averagePerItem - 各評価項目の平均値
 * @param {number} props.overallAverage - 総合評価の平均値
 * @param {Array} props.comments - コメント一覧
 * @param {Object} props.auth - 認証情報
 * @param {Object|null} props.userReview - ログインユーザーのレビュー
 * @param {Object|null} props.userBookmark - ログインユーザーのブックマーク
 * @param {number} props.bookmarkCount - ブックマーク数
 * @param {string} props.query - 検索クエリ文字列
 * @returns {JSX.Element} コンポーネントのJSX
 */

const Show = ({
  lab,
  averagePerItem,
  overallAverage,
  comments,
  auth,
  userReview,
  userBookmark,
  bookmarkCount,
  query,
}) => {
  const [isCommentListModalOpen, setIsCommentListModalOpen] = useState(false);
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
  const [isLabEditModalOpen, setIsLabEditModalOpen] = useState(false);
  const [isReviewEditModalOpen, setIsReviewEditModalOpen] = useState(false);
  const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
  const [isDeleting, setIsDeleting] = useState(false);
  const [isCommentDeleteAlertOpen, setIsCommentDeleteAlertOpen] = useState(false);
  const [isDeletingComment, setIsDeletingComment] = useState(false);
  const [deletingComment, setDeletingComment] = useState(null);
  const menuRef = useRef(null);

  // 外側クリックでメニューポップオーバーを閉じる
  useEffect(() => {
    const handleClickOutside = event => {
      if (menuRef.current && !menuRef.current.contains(event.target)) {
        setIsMenuOpen(false);
      }
    };

    if (isMenuOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    }

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isMenuOpen]);

  /**
   * メニューポップオーバーの「レビューを投稿する」クリック時の処理
   * @returns {void}
   */
  const handleAddReviewClick = () => {
    setIsMenuOpen(false);
    setIsCreateModalOpen(true);
  };

  /**
   * メニューポップオーバーの「編集する」クリック時の処理
   * @returns {void}
   */
  const handleLabEditClick = () => {
    setIsMenuOpen(false);
    setIsLabEditModalOpen(true);
  };

  // ブックマーク状態とカウントをローカルstateで管理
  const [isBookmarked, setIsBookmarked] = useState(auth?.user && userBookmark);
  const [currentBookmarkCount, setCurrentBookmarkCount] = useState(bookmarkCount || 0);
  const [bookmarkId, setBookmarkId] = useState(userBookmark?.id || null);

  /**
   * ブックマークのトグル処理
   * ログインしていない場合は何もしない。
   * 既にブックマーク済みなら解除、未ブックマークなら追加する。
   * @returns {void}
   */
  const handleBookmarkClick = () => {
    // ログインしていない場合は何もしない
    if (!auth?.user) {
      return;
    }

    if (isBookmarked) {
      // ブックマーク解除
      router.delete(route('bookmark.destroy', bookmarkId), {
        preserveScroll: true,
        onSuccess: () => {
          setIsBookmarked(false);
          setCurrentBookmarkCount(prev => Math.max(0, prev - 1));
          setBookmarkId(null);
        },
      });
    } else {
      // ブックマーク追加
      router.post(
        route('bookmark.store'),
        {
          lab_id: lab.id,
        },
        {
          preserveScroll: true,
          onSuccess: page => {
            setIsBookmarked(true);
            setCurrentBookmarkCount(prev => prev + 1);
            // 新しいブックマークIDを取得(ページデータから)
            if (page.props.userBookmark) {
              setBookmarkId(page.props.userBookmark.id);
            }
          },
        }
      );
    }
  };

  // 7つの評価指標のラベル
  const labels = [
    '指導スタイル',
    '雰囲気・文化',
    '成果・活動',
    '拘束度',
    '設備',
    '働き方',
    '人数バランス',
  ];

  // LabControllerから渡された平均評価データを配列に変換
  const averageRatings = averagePerItem
    ? [
        averagePerItem.mentorship_style || 0,
        averagePerItem.lab_atmosphere || 0,
        averagePerItem.achievement_activity || 0,
        averagePerItem.constraint_level || 0,
        averagePerItem.facility_quality || 0,
        averagePerItem.work_style || 0,
        averagePerItem.student_balance || 0,
      ]
    : [0, 0, 0, 0, 0, 0, 0];

  // ログインユーザーのレビューデータを配列に変換
  const userRatings = userReview
    ? [
        userReview.mentorship_style || 0,
        userReview.lab_atmosphere || 0,
        userReview.achievement_activity || 0,
        userReview.constraint_level || 0,
        userReview.facility_quality || 0,
        userReview.work_style || 0,
        userReview.student_balance || 0,
      ]
    : null;

  // レーダーチャートのデータセットを構築
  const datasets = [
    {
      label: '全投稿者の平均評価',
      data: averageRatings,
      backgroundColor: 'rgba(51, 225, 237, 0.2)',
      borderColor: 'rgba(51, 225, 237, 1)',
      borderWidth: 2,
      pointBackgroundColor: 'rgba(51, 225, 237, 1)',
      pointBorderColor: '#fff',
      pointHoverBackgroundColor: '#fff',
      pointHoverBorderColor: 'rgba(51, 225, 237, 1)',
    },
  ];

  // ログインユーザーのレビューがある場合、データセットに追加
  if (userRatings) {
    datasets.push({
      label: 'あなたの投稿済み評価',
      data: userRatings,
      backgroundColor: 'rgba(244, 187, 66, 0.2)',
      borderColor: 'rgba(244, 187, 66, 1)',
      borderWidth: 2,
      pointBackgroundColor: 'rgba(244, 187, 66, 1)',
      pointBorderColor: '#fff',
      pointHoverBackgroundColor: '#fff',
      pointHoverBorderColor: 'rgba(244, 187, 66, 1)',
    });
  }

  // レーダーチャートのデータ
  const chartData = {
    labels: labels,
    datasets: datasets,
  };

  // レーダーチャートのオプション
  const chartOptions = {
    responsive: true,
    maintainAspectRatio: true,
    scales: {
      r: {
        angleLines: {
          display: true,
        },
        suggestedMin: 0,
        suggestedMax: 5,
        ticks: {
          stepSize: 1,
          font: {
            size: 12,
          },
        },
        pointLabels: {
          font: {
            size: 14,
          },
        },
      },
    },
    plugins: {
      legend: {
        position: 'top',
      },
      tooltip: {
        callbacks: {
          label: context => {
            return `${context.dataset.label}: ${context.raw.toFixed(2)}`;
          },
        },
      },
    },
  };

  return (
    <AppLayout title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`}>
      <Head title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`} />
      {/* パンくずリスト+レビュー投稿状態+ケバブメニュー 横並び */}
      <div className="w-full flex flex-row items-center justify-between">
        {/* パンくずリスト 左寄せ */}
        <div>
          <Breadcrumb
            university={lab.faculty.university}
            faculty={lab.faculty}
            lab={lab}
            query={query}
          />
        </div>
        {/* レビュー投稿状態 + ケバブメニュー 右寄せ */}
        <div className="flex items-center gap-2">
          {auth?.user && userReview ? (
            <button
              type="button"
              onClick={() => setIsReviewEditModalOpen(true)}
              className="text-[#747D8C] hover:underline cursor-pointer"
            >
              レビューを投稿済みです。
            </button>
          ) : (
            <p className="text-[#747D8C]">まだ、レビューを投稿していません。</p>
          )}
          {/* ケバブメニュー */}
          <div className="relative" ref={menuRef}>
            <button onClick={() => setIsMenuOpen(!isMenuOpen)} className="p-2 rounded-full">
              <KebabIcon />
            </button>
            {isMenuOpen && (
              <MenuPopover
                {...(!userReview ? { addLabel: 'レビューを投稿する', onAddClick: handleAddReviewClick } : {})}
                onEditClick={handleLabEditClick}
              />
            )}
          </div>
        </div>
      </div>

      <div className="flex flex-col min-h-full">
        <div className="max-w-7xl mx-auto w-full">
          <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
            {/* 左側: レーダーチャート */}
            <div className="flex justify-center items-start">
              <div className="w-full max-w-sm">
                <Radar data={chartData} options={chartOptions} />
              </div>
            </div>

            {/* 右側: 総合評価と研究室概要 */}
            <div>
              {/* 総合評価 */}
              <div className="mb-4">
                <h2 className="text-base font-semibold text-black mb-2">
                  総合評価({lab.reviews?.length || 0})
                </h2>
                {lab.reviews && lab.reviews.length > 0 ? (
                  <div className="flex items-center gap-2 ml-4">
                    <StarRating rating={overallAverage || 0} />
                    <span className="text-sm text-[#F4BB42]">
                      {formatRating(overallAverage, '0.00')}
                    </span>
                  </div>
                ) : (
                  <p className="text-sm text-[#747D8C] ml-4">まだ評価がありません</p>
                )}
              </div>

              {/* 研究室概要 */}
              <h2 className="text-base font-semibold text-black mb-2">研究室概要</h2>
              <p className="text-sm text-[#747D8C] whitespace-pre-wrap leading-tight ml-4">
                {lab.description || '概要はまだ登録されていません'}
              </p>

              {/* 研究室ページ */}
              <div className="mt-4">
                <h2 className="text-base font-semibold text-black mb-2">研究室ページ</h2>
                {lab.url ? (
                  <a
                    href={lab.url}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="text-sm text-[#747D8C] hover:text-black hover:underline break-all ml-4"
                  >
                    {lab.url}
                  </a>
                ) : (
                  <p className="text-sm text-[#747D8C] ml-4">URLはまだ登録されていません</p>
                )}
              </div>

              {/* 教授 */}
              <div className="mt-4">
                <h2 className="text-base font-semibold text-black mb-2">教授</h2>
                {lab.professor_name ? (
                  lab.professor_url ? (
                    <a
                      href={lab.professor_url}
                      target="_blank"
                      rel="noopener noreferrer"
                      className="text-sm text-[#747D8C] hover:text-black hover:underline break-all ml-4"
                    >
                      {`${lab.professor_name} 先生`}
                    </a>
                  ) : (
                    <p className="text-sm text-[#747D8C] ml-4">{`${lab.professor_name} 先生`}</p>
                  )
                ) : (
                  <p className="text-sm text-[#747D8C] ml-4">教授名はまだ登録されていません</p>
                )}
              </div>

              {/* 男女比 */}
              <div className="mt-4">
                <h2 className="text-base font-semibold text-black mb-2">
                  男女比
                  {lab.gender_ratio_male != null &&
                    lab.gender_ratio_female != null &&
                    `(${lab.gender_ratio_male}:${lab.gender_ratio_female})`}
                </h2>
                {lab.gender_ratio_male != null && lab.gender_ratio_female != null ? (
                  <div className="flex w-full h-6 rounded overflow-hidden text-sm text-white font-medium ml-4">
                    {lab.gender_ratio_male > 0 && (
                      <div
                        className="flex items-center justify-center"
                        style={{
                          backgroundColor: '#7BB3CE',
                          width: `${(lab.gender_ratio_male / (lab.gender_ratio_male + lab.gender_ratio_female)) * 100}%`,
                        }}
                      ></div>
                    )}
                    {lab.gender_ratio_female > 0 && (
                      <div
                        className="flex items-center justify-center"
                        style={{
                          backgroundColor: '#E89EB9',
                          width: `${(lab.gender_ratio_female / (lab.gender_ratio_male + lab.gender_ratio_female)) * 100}%`,
                        }}
                      ></div>
                    )}
                  </div>
                ) : (
                  <p className="text-sm text-[#747D8C] ml-4">男女比はまだ登録されていません</p>
                )}
              </div>

              {/* コメント一覧 */}
              <div className="mt-4">
                <h2 className="text-base font-semibold text-black mb-2">
                  {comments?.length || 0}件のコメント
                </h2>
                {comments && comments.length > 0 ? (
                  <div className="space-y-3 ml-4">
                    {/* 最初の1件は常に表示 */}
                    <div key={comments[0].id} className="border-b border-gray-200 pb-3">
                      <h3 className="text-sm font-medium text-black">
                        {comments[0].user?.name || '匿名'}
                      </h3>
                      <p className="text-sm text-[#747D8C] mt-1 whitespace-pre-wrap">
                        {comments[0].content}
                      </p>
                    </div>

                    {/* 2件以上の場合、もっと見るボタンを表示 */}
                    {comments.length > 1 && (
                      <button
                        onClick={() => setIsCommentListModalOpen(true)}
                        className="text-sm text-[#747D8C] hover:text-black hover:underline"
                      >
                        もっと見る...
                      </button>
                    )}
                  </div>
                ) : (
                  <p className="text-sm text-[#747D8C] ml-4">まだコメントがありません</p>
                )}
              </div>

              {/* ブックマークアイコン */}
              <div className="mt-4 flex items-center justify-end">
                <div className="flex items-center gap-1 min-w-[50px] justify-end">
                  <svg
                    width="20"
                    height="20"
                    viewBox="0 0 24 24"
                    xmlns="http://www.w3.org/2000/svg"
                    onClick={handleBookmarkClick}
                    className={`flex-shrink-0 ${auth?.user ? 'cursor-pointer hover:opacity-70' : 'cursor-default'}`}
                  >
                    <path
                      d="M5 2C4.44772 2 4 2.44772 4 3V21C4 21.3746 4.21048 21.7178 4.54555 21.8892C4.88062 22.0606 5.28335 22.0315 5.59026 21.8137L12 17.229L18.4097 21.8137C18.7166 22.0315 19.1194 22.0606 19.4545 21.8892C19.7895 21.7178 20 21.3746 20 21V3C20 2.44772 19.5523 2 19 2H5Z"
                      fill={isBookmarked ? '#747D8C' : 'transparent'}
                      stroke="#747D8C"
                      strokeWidth="2"
                    />
                  </svg>
                  <span className="text-sm text-gray-600 tabular-nums min-w-[1.5ch] text-left">
                    {currentBookmarkCount}
                  </span>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      {/* レビュー作成モーダル */}
      <CreateReviewModal
        isOpen={isCreateModalOpen}
        onClose={() => setIsCreateModalOpen(false)}
        lab={lab}
      />
      {/* レビュー編集モーダル */}
      <EditReviewModal
        isOpen={isReviewEditModalOpen}
        onClose={() => setIsReviewEditModalOpen(false)}
        review={userReview}
        onDelete={() => {
          setIsReviewEditModalOpen(false);
          setIsDeleteAlertOpen(true);
        }}
      />

      {/* レビュー削除確認モーダル */}
      <AlertModal
        isOpen={isDeleteAlertOpen}
        onClose={() => {
          setIsDeleteAlertOpen(false);
          setIsReviewEditModalOpen(true);
        }}
        title="レビューの削除"
        message="投稿済みのレビューを削除します。本当に削除しますか?"
        actionLabel="削除する"
        cancelLabel="キャンセル"
        isProcessing={isDeleting}
        onAction={() => {
          setIsDeleting(true);
          router.delete(route('review.destroy', userReview.id), {
            preserveScroll: true,
            onSuccess: () => {
              setIsDeleteAlertOpen(false);
              setIsDeleting(false);
            },
            onError: () => {
              setIsDeleting(false);
            },
          });
        }}
      />

      {/* 研究室編集モーダル */}
      <EditLabModal
        isOpen={isLabEditModalOpen}
        onClose={() => setIsLabEditModalOpen(false)}
        lab={lab}
      />

      {/* コメント一覧表示モーダル */}
      <CommentListModal
        isOpen={isCommentListModalOpen}
        onClose={() => setIsCommentListModalOpen(false)}
        labId={lab.id}
        totalCount={comments?.length || 0}
        onDelete={(comment) => {
          setDeletingComment(comment);
          setIsCommentListModalOpen(false);
          setIsCommentDeleteAlertOpen(true);
        }}
      />

      {/* コメント削除確認モーダル */}
      <AlertModal
        isOpen={isCommentDeleteAlertOpen}
        onClose={() => {
          setIsCommentDeleteAlertOpen(false);
          setIsCommentListModalOpen(true);
        }}
        title="コメントの削除"
        message="このコメントを削除します。本当に削除しますか?"
        actionLabel="削除する"
        cancelLabel="キャンセル"
        isProcessing={isDeletingComment}
        onAction={async () => {
          setIsDeletingComment(true);
          try {
            await axios.delete(route('comment.destroy', deletingComment.id));
            setIsCommentDeleteAlertOpen(false);
            setDeletingComment(null);
            setIsCommentListModalOpen(true);
          } catch (error) {
            console.error('コメントの削除に失敗しました', error);
          } finally {
            setIsDeletingComment(false);
          }
        }}
      />
    </AppLayout>
  );
};

export default Show;

4.4 編集用ページコンポーネント削除

コメントはすべてモーダルで完結してしまったので、edit.jsxひいては、pages/commentフォルダは必要なくなったので削除しておきましょう。

AIコーディングが最近流行っていますが、こういった不要になったファイルが残っているとAIが勘違いして参照・修正をしてしまうことがあるので気を付けたいところですね。

4.5 動作確認

ログインした状態で自分が投稿したコメントの右肩に表示される編集アイコンをクリックするとコメントを編集できるようになります。
image.png

適当に編集して「編集する」ボタンをクリック。
image.png

更新されていることを確認!
image.png

削除ボタンを押すと削除モーダルのアラートが出るので「削除」を選択。
image.png

消えていることを確認!
image.png

確認出来たら、コミットしておきましょう!

5. コメント数が1件以下の時の修正

コメント一覧表示モーダルは「もっと見る...」をクリックすると開く仕組みになっています。

現在、コメント数が2件以上でないと「もっと見る...」が表示されません。
これのせいで、コメントが0件の時・1件の時にコメントを投稿することができません

試しに新しく研究室を作成してみると、こんな感じです。
image.png

5.1 ブランチ運用

feature/frontend/comment-list-modalにチェックアウトしたまま作業を続けます。

5.2 コメント数0件時の対応

Lab/Show.jsxの以下の部分を修正して、「まだコメントがありません」をクリックするとモーダルが開かれるようにします。

resources/js/Pages/Lab/Show.jsx

\project-root\src\resources\js\Pages\Lab\Show.jsx
                  <button
                    onClick={() => setIsCommentListModalOpen(true)}
                    className="text-sm text-[#747D8C] hover:text-black hover:underline"
                  >
                    まだコメントがありません
                  </button>

5.3 コメント数1件時の対応

Lab/Show.jsxの以下の部分を修正して、コメント数が1件の時は「コメント一覧を開く」を表示するようにして、クリックするとモーダルが開かれるようにします。

resources/js/Pages/Lab/Show.jsx

\project-root\src\resources\js\Pages\Lab\Show.jsx
                    {/* コメント一覧モーダルを開くボタン */}
                    <button
                      onClick={() => setIsCommentListModalOpen(true)}
                      className="text-sm text-[#747D8C] hover:text-black hover:underline"
                    >
                      {comments.length > 1 ? 'もっと見る...' : 'コメント一覧を開く'}
                    </button>

完了したら、コミット・プッシュ、PR作成・マージをお忘れなく頼むぜ!

6. まとめ・次回予告

コメントリストモーダルを作りました!
非同期処理で、ススクロールされるたびにデータを取得することにも挑戦しました。
JSONについても勉強しましたね。

編集履歴ページ・削除依頼ページ・通知ドロップダウンを作ります!

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

参考

軽く宣伝

YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。

現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"

3
3
1

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?