ReactとExpressを使ってコメント欄のアプリを作る

プログラミング

Reactで作ったアプリケーションの中にコメント欄を設置したいという人がもしかしたらいるかも知れません。

そこでNode.jsのフレームワークであるExpressを使ったコメント欄の雛形を作る方法と、その手順のメモを残しておきます。

コメント欄の完成系

コメント欄は次のように動作します。ログイン機能がついていなくて、誰でもコメントが追加、変更、削除できる仕様になっています。

コメント欄の完成形

環境と使用するもの

主に次のようなものを使ってコメント欄を作っていきます。

フロントエンド → React
バックエンド  → Express
データベース  → XAMPPに含まれているMySQL
エディタ    → Visual Studio Code
OS → Windows 11 Home

簡単に作るために、見た目の部分は Create React App と呼ばれる数ステップでReactアプリケーションを表示できるものを使います。

エディタは Visual Studio Code 以外のものを使っても大丈夫です。

他の項目についても聞いたことがないものがあるかもしれませんが、同じように作っていただければたぶん大丈夫です。

コメント欄の見た目を作る

お好きな場所に「MyApps」という名前のフォルダを作成します。

ターミナルを開いて作成したMyAppsフォルダ(ディレクトリ)に移動します。

cd MyApps

次のコマンドでcomment-sectionという名前のプロジェクトを作成します。作成されるまでに少し時間がかかります。

npx create-react-app comment-section

MyAppsディレクトリの中にアプリの雛形が入ったcomment-sectionというディレクトリが作成されます。

comment-sectionディレクトリに移動します。

cd comment-section

アプリを起動させます。

npm start

Webブラウザーが開いてアプリが表示されます。

srcディレクトリの中にCommentSection.jsを作成します。

commentSection.jsの中身は次のようにします。

import './CommentSection.css';
import { useEffect, useRef, useState } from 'react';

function CommentSection() {
  const el = useRef(null);
  const [comment, setComment] = useState('');
  const [comments, setComments] = useState([{ id: 1, comment: 'コメント1' }, { id: 2, comment: 'コメント2' }]);
  const [commentIsEmpty, setCommentIsEmpty] = useState(true);
  const [showEditMenu, setShowEditMenu] = useState(false);
  const [displayedCommentKey, setDisplayedCommentKey] = useState(1);

  //コメントが空かどうかの情報componentEmptyを更新
  useEffect(() => {
    //コメントが空、またはスペースや改行だけしかないときはコメントを投稿させない
    if (!comment || !comment.match(/\S/g)) {
      setCommentIsEmpty(true);
    } else {
      setCommentIsEmpty(false);
    }
  }, [comment]);

  // 文字を入力されるたびに入力中のコメントをcommentとして保存しておく
  const commentInput = () => {
    const newComent = el.current.innerText;
    setComment(newComent);
  }

  // コメントボタンが押された時に入力欄にあるコメントをcommentsに追加する
  const commentsInput = () => {
    //コメントが空ではなかったら実行
    if (!commentIsEmpty) {
      const newComments = [...comments, { id: comment.length + 1, comment: comment }];
      setComments(newComments);
      setComment('');
      el.current.innerText = '';
    }
  };

  // Shift+Enterで<br>が挿入されるのを防ぐ
  const preventBreack = (event) => {
    if (event.shiftKey && event.code === 'Enter') {
      event.preventDefault();
    }
  };

  // 三点リーダーを表示させる
  const handleClickDottedLine = (e, key) => {
    setShowEditMenu(!showEditMenu);
    setDisplayedCommentKey(key);
  }

  // メニューの外側をクリックしたときだけメニューを閉じる
  const closeWithClickOutSideMethod = (e, setter) => {
    if (e.target === e.currentTarget) {
      setter(false);
    } else {
    }
  };

  return (
    <div className="CommentSection">
      <div className="comment-input-field" contentEditable="true" placeholder="コメントを追加..." onInput={commentInput} onKeyDown={preventBreack} ref={el}></div>
      <button className={`comment-button ${commentIsEmpty ? "comment-empty" : ""}`} onClick={commentsInput}>コメント</button>
      <div className="comment-display-field">
        {comments.map(comment => {
          return (
            <div className='comment-row'>
              <div className='comment' key={comment.id}>{comment.comment}</div>
              <div className='edit'>
                <div className='dotted-line' onClick={(e) => { handleClickDottedLine(e, comment.id) }}>
                  <svg version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512" style={{ width: 100 + "%", height: 100 + "%", opacity: 0.5 }} xmlSpace="preserve">
                    <g>
                      <circle className="st0" cx="256" cy="55.091" r="55.091" style={{ fill: 'black' }}></circle>
                      <circle className="st0" cx="256" cy="256" r="55.091" style={{ fill: 'black' }}></circle>
                      <circle className="st0" cx="256" cy="456.909" r="55.091" style={{ fill: 'black' }}></circle>
                    </g>
                  </svg>
                </div>
                {/* クリックされたメニューの三点リーダーに対応する位置のコメントだけを表示させる */}
                {showEditMenu && comment.id === displayedCommentKey &&
                  <div className={`edit-menu-wrapper ${showEditMenu ? "edit-menu-wrapper__active" : ""}`} onClick={(e) => { closeWithClickOutSideMethod(e, setShowEditMenu) }}>
                    <div className='edit-menu'>
                      <div className='comment-edit-button'>
                        <svg version="1.1" id="_x32_" x="0px" y="0px" viewBox="0 0 512 512" style={{ width: 16, height: 16, opacity: 1 }}>
                          <style type="text/css" dangerouslySetInnerHTML={{ __html: "\n    .st0{fill:#4B4B4B;}\n  " }} />
                          <path className="st0" d="M392.052,0l-0.642,0.01c0,0,0,0,0.009,0c0,0,0-0.01,0.008,0L392.052,0z" style={{ fill: 'rgb(75, 75, 75)' }} />
                          <path className="st0" d="M487.66,61.494l-0.037-0.037c-0.056-0.056-0.102-0.111-0.156-0.166h-0.01
                            c-0.064-0.056-0.092-0.092-0.193-0.183l-36.51-36.511c-0.009-0.018-0.028-0.027-0.046-0.046
                            C434.386,8.23,412.811-0.027,391.419,0.01c-21.392-0.037-42.968,8.211-59.308,24.56L297.75,58.931l-13.043,13.033L0,356.681V512
                            h155.327l284.708-284.698l13.042-13.042l34.362-34.37c16.34-16.332,24.588-37.916,24.56-59.299
                            c0.027-21.291-8.156-42.766-24.313-59.06L487.66,61.494z M136.572,466.736h-91.3v-91.299l271.445-271.455l91.299,91.3
                            L136.572,466.736z M455.429,147.878l-21.327,21.318l-91.29-91.299L364.13,56.58c7.578-7.569,17.35-11.279,27.289-11.306
                            c9.938,0.027,19.702,3.738,27.288,11.306l36.722,36.712c7.569,7.587,11.279,17.36,11.298,27.298
                            C466.708,130.528,462.998,140.292,455.429,147.878z" style={{ fill: 'rgb(75, 75, 75)' }} />
                        </svg>
                        編集
                      </div>
                      <div className='comment-delete-button'>
                        <svg version="1.1" id="_x32_" x="0px" y="0px" viewBox="0 0 512 512" style={{ width: 16, height: 16, opacity: 1 }}>
                          <style type="text/css" dangerouslySetInnerHTML={{ __html: "\n    .st0{fill:#4B4B4B;}\n  " }} />
                          <path className="st0" d="M77.869,448.93c0,13.312,1.623,25.652,5.275,35.961c4.951,13.636,13.475,23.457,26.299,26.297
                            c2.598,0.488,5.277,0.812,8.117,0.812h277.364c0.73,0,1.381,0,1.947-0.082c26.463-1.703,37.258-29.219,37.258-62.988
                            l11.121-269.324H66.748L77.869,448.93z M331.529,239.672h52.68v212.262h-52.68V239.672z M229.658,239.672h52.682v212.262h-52.682
                            V239.672z M127.789,239.672h52.762v212.262h-52.762V239.672z" style={{ fill: 'rgb(75, 75, 75)' }} />
                          <path className="st0" d="M368.666,89.289c0.078-2.028,0.242-4.059,0.242-6.09v-5.598c0-42.777-34.822-77.602-77.6-77.602h-70.701
                            c-42.778,0-77.6,34.824-77.6,77.602v5.598c0,2.031,0.162,4.062,0.326,6.09H28.721v62.582h454.558V89.289H368.666z M320.205,83.199
                            c0,2.113-0.242,4.141-0.648,6.09H192.361c-0.406-1.949-0.65-3.977-0.65-6.09v-5.598c0-15.91,12.986-28.898,28.897-28.898h70.701
                            c15.99,0,28.896,12.988,28.896,28.898V83.199z" style={{ fill: 'rgb(75, 75, 75)' }} />
                        </svg>
                        削除</div>
                    </div>
                  </div>
                }
              </div>
            </div>
          );
        })}
      </div>
    </div>
  )
}

export default CommentSection;

見た目を整えるためにCommentSection.cssというCSSファイルも作ってください。

.CommentSection {
  width: 80%;  /* コメント欄全体の幅はこちら変更してください */
  height: auto;
  margin-top: 70px;
  margin-right: auto;
  margin-left: auto;
}
body::-webkit-scrollbar {
  display:none;
 }

.comment-input-field[contenteditable=true]:empty:before{
  content: attr(placeholder);
  pointer-events: none;
  display: block;
  color: #aaa;
}

.comment-input-field[contenteditable=true]:after {
  content: '';
  position: absolute;
  bottom: -1px;
  left: 0;
  background-color: black;
  width: 0;
  height: 1px;
  transition: width .2s ease-in;
}
.comment-input-field[contenteditable=true]:focus:after {
  position: absolute;
  width: 100%;
}

.comment-input-field[contenteditable=true] {
  position: relative;
  border-bottom: 1px dashed #aaa;
  width: 100%;
  line-height: 20px;
  padding: 8px 0 6px;
  font-size: 14px;
  outline: none;
}

.comment-button {
  display: block;
  margin-top: 10px;
  margin-left: auto;
  margin-bottom: 14px;
  padding: 8px 10px;
  border: 0;
  color: #fff;
  background: #585858;
  font-size: 14px;
}

.comment-button:hover {
  cursor: pointer;
  transition: all .5s ease;
}

.comment-button.comment-empty {
  opacity: 0.3;
  cursor: default;
}

.comment-row {
  display: flex;
}

.comment {
  width: 96%;
  margin-bottom: 14px;
  font-size: 14px;
  word-break: break-all;
}

.edit {
  position: relative;
  width: 4%;
}

.dotted-line {
  position: absolute;
  width: 14px;
  opacity: 0;
  cursor: pointer;
  z-index: 10;  
}

.comment-row:hover .dotted-line {
  opacity: 1;
}

.edit-menu-wrapper {
  background-color: rgba(95, 80, 80, 0.0);
  height: 200vh;
  width: 200vw;
  position: absolute;
  top: -100vh;
  left: -100vw;
}

.edit-menu-wrapper__active {
  background-color: rgba(95, 80, 80, 0.1);
}

.edit-menu {
  position: absolute;
  padding-top: 8px;
  padding-bottom: 8px;
  top: calc(100vh + 35px);
  left: calc(100vw + -100px);
  background-color: #fff;
  border-radius: 2px;
  z-index: 11;
}

.comment-edit-button, 
.comment-delete-button {
  display: flex;
  align-items: center;
  padding-left: 34px;
  padding-right: 42px;
  padding-top: 3px;
  padding-bottom: 3px;
  font-size: 14px;
}

.comment-edit-button:hover, 
.comment-delete-button:hover {
  background-color: #aaa;
  cursor: pointer;
}

.comment-edit-button svg, 
.comment-delete-button svg {
  padding-right: 10px;
}

@media screen and (min-width:480px) { 
  /* 画面サイズが480pxからはここを読み込む */
  .edit-menu {
    top: calc(100vh + 24px);
    left: calc(100vw + -56px);
  }
}

App.jsの中身を次のように書き換えます。

import CommentSection from './CommentSection'

function App() {
  return (
    <div className="App">
      <CommentSection />
    </div>
  );
}

export default App;

ここまででコメント欄の見た目がある程度完成します。

MySQLでデータベースとテーブル作成する

XAMPPを使います。XAMPPのインストール方法はこちらの記事に大変わかりやすくまとめられているため、参考にさせていただきましょう。

XAMPP のダウンロード・インストール方法と使い方
Web アプリケーションの開発環境(Apache + MySQL + PHP)をローカルに簡単に構築できる XAMPP(Windows版)の Windows10 でのダウンロード、インストール方法と基本的な使い方(コントロールパネルの設定等)についての解説

XAMPP Control Pane を開きます。ApacheとMySQLの行にある<Start>と書かれたボタンを2つともクリックします。

次に、MySQLの行にある<Admin>と書かれたボタンをクリックします。

次のようなタブが自動で開きます。

①<新規作成>という項目を選択し、②同じ感じで入力したあと、③作成ボタンをクリックします。(作成するデータベースの設定)

これで、データベースが作成されました。

お次は①テーブル名と列数の入力をしてから、②作成ボタンをクリックします。(テーブル名と列数の設定)

同じようにして次の画面でも入力を行います。(列ごとの設定)

インデックスまたは、A_Iの項目を設定すると、次のような画面が出てきますが、何も変更せずに実行ボタンを押します。

実行ボタンをクリックしたあとに、「PRIMARY」という文字が青色の文字で表示されている事を確認。

右下にある<保存する>ボタンをクリックします。

これでcomment_field_appというデータベースにcommentsというテーブルが作成されました。

次のようにして、テスト用のコメントを2件追加しておきます。

コメントの追加、読み込み、更新、削除ができるようにする

現在、Create React App の方でポート3000を使ってサーバーを起動している状態になっていると思います。次にNode.jsのExpressを使ってもう一つのサーバーを起動させていきます。

VSCodeのターミナルの右上あたりにある「+」をクリックして、新しいターミナルを起動します。

新しいターミナルを使ってcomment-sectionディレクトリに移動します。

cd comment-section  

次の3つのnpmパッケージをインストールします。

  • express
  • nodemon
  • mysql

インストールのためのコマンドは次のとおりです。

npm install express nodemon mysql

comment-sectionディレクトリのsrcフォルダの中にserver.jsという名前のファイル作成します。

作成したserver.jsの中身を次のようにします。

const express = require('express');
const app = express();
const mysql = require('mysql');

const PORT = 3001;

// jsonの受け取り
app.use(express.json());

// cors対策
app.use((req, res, next) => {
  res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
  res.setHeader(
    "Access-Control-Allow-Methods",
    "GET, POST, PUT, PATCH, DELETE, OPTION"
  );
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  next();
});

//connection pool
const pool = mysql.createPool({
  connectionLimit: 10,
  host: 'localhost',
  user: 'root',
  password: '',   //ここにはご自身のmySQLにおけるパスワードを設定します。
  database: 'comment_field_app'
})

const con = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: '',   //ここにはご自身のmySQLにおけるパスワードを設定します。
  database: 'comment_field_app'
});

app.get('/', (request, response) => {
  const sql = "select * from comments";
  con.query(sql, function (err, result, fields) {
    if (err) throw err;
    response.json(result)
    console.log(result);
  });
});

app.get('/delete', (req, res) => {
  const sql = "DELETE FROM comments WHERE id = ?";
  con.query(sql, [req.query.id], function (err, result, fields) {
    if (err) throw err;
    res.redirect('/')
  });
});

app.post('/update', (req, res, next) => {
  pool.getConnection((err, connection) => {
    if (err) throw err;

    const id = req.body.id;
    const comment = req.body.comment;
    const sql = "UPDATE comments SET id=?, comment=? WHERE id = ?";

    connection.query(sql, [id, comment, id], function (error, results, fields) {
      if (error) throw error;
      console.log('アップデート成功');
      res.redirect('/')
    });
  });
});

app.post('/', (req, res) => {
  pool.getConnection((err, connection) => {
    if (err) throw err;

    connection.query(`INSERT INTO comments values ("", "${req.body.comment}")`,
      (err, rows) => {
        connection.release();
        if (!err) {
          res.redirect('/');
        } else {
          console.log(err);
        }
      }
    );
  });
  console.log(req.body);
});

app.listen(PORT, () => console.log('サーバー起動中🚀'));

新しい方のターミナルを使ってもう一つのサーバーを起動します。ターミナル上でcomment-sectionにいる事を確認した上で、次のコマンドを実行してください。

node src/server.js

URL欄に「localhost:3001」と入力しEnterを押します。

次のように、先程データベースに入れたデータが表示されればOKです。

最後にCommentSection.jsを書き換えます。

import './CommentSection.css';
import { useEffect, useRef, useCallback, createRef, useState } from 'react';

function CommentSection() {
  const [comment, setComment] = useState('');
  const [comments, setComments] = useState([]);
  const [commentIsEmpty, setCommentIsEmpty] = useState(true);
  const [showEditMenu, setShowEditMenu] = useState(false);
  const [showConfirmButton, setShowConfirmButton] = useState(false);
  const [showDottedLine, setShowDottedLine] = useState(true);
  const [displayedCommentKey, setDisplayedCommentKey] = useState(1);
  const [canEdit, setCanEdit] = useState(false);
  const [editInProgress, setEditInProgress] = useState(0);
  const [editContainerTop, setEditContainerTop] = useState(30);
  const [commentMarginBottom, setCommentMarginBottom] = useState(14)
  const [commentWidthFull, setCommentWidthFull] = useState(false);
  const [preEditComment, setPreEditComment] = useState('');
  const [commentForUpdate, setCommentForUpdate] = useState('');
  const [commentForUpdateIsEmpty, setCommentForUpdateIsEmpty] = useState(true);

  const commentInputField = useRef(null);
  var editing = useRef(comments.map(() => createRef()));

  // コメントが空かどうかの情報componentIsEmptyを更新
  useEffect(() => {
    // コメントが空のとき、またはスペースや改行だけしかないときはコメントを投稿させない
    if (!comment || !comment.match(/\S/g)) {
      setCommentIsEmpty(true);
    } else {
      setCommentIsEmpty(false);
    }
  }, [comment]);

  // 投稿済みのコメントが空かどうかの情報componentForUpdateIsEmptyを更新
  useEffect(() => {
    // コメントが空のとき、またはスペースや改行だけしかないときはコメントの編集を確定させない
    if (!commentForUpdate || !commentForUpdate.match(/\S/g)) {
      setCommentForUpdateIsEmpty(true);
    } else {
      setCommentForUpdateIsEmpty(false);
    }
  }, [commentForUpdate]);

  //データベースからデータを取ってくる
  useEffect(() => {
    const newComments = [];
    fetch('http://localhost:3001/', { method: 'GET' })
      .then(res => res.json())
      .then(data => {
        data.forEach(commentData => {
          newComments.push(commentData);
        });
        setComments(newComments);
      });
  }, []);

  // 新しくレンダリングされたコメントへの参照を追加する
  const setRef = useCallback((node, commentId) => {
    editing.current[commentId] = node;
  }, []);

  // 文字を入力されるたびに入力中のコメントを一次保存しておく
  const commentInput = (e, commentId = null) => {
    let newComment = '';
    // commentIdがnullではなかったら、.commentに文字が入力されている
    if (commentId === null) {
      newComment = commentInputField.current.innerText;
      setComment(newComment);
    } else {
      newComment = editing.current[commentId].innerText;
      setCommentForUpdate(newComment);
    }
  }


  // コメントデータの送信
  const commentsInput = async () => {
    //コメントが空でではなかったらAPIに送信コメントデータを送信
    if (!commentIsEmpty) {
      await fetch("http://localhost:3001/", {
        method: "POST",
        mode: "cors",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ comment: comment }),
      })
        .then((response) => response.json())
        .then((data) => console.log(data));

      const newComments = [...comments, { id: comments.length + 1, comment: comment }];
      // 入力欄にあるコメントをcommentsに追加する
      setComments(newComments);
      setComment('');
      commentInputField.current.innerText = '';
    }
  };

  const handleDeleteButton = async (e, id) => {
    await fetch("http://localhost:3001/delete?id=" + id, { method: "GET" })
      .then((response) => response.json())
      .then((data) => console.log(data));
    // 押された「削除」ボタンに対応したidを持つコメントを削除
    const newComments = comments.filter(commentData => commentData.id !== id);
    setComments(newComments);
  };

  const updateComment = async (e, id) => {
    if (commentForUpdateIsEmpty) return;
    await fetch("http://localhost:3001/update/", {
      method: "POST",
      mode: "cors",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ id: id, comment: commentForUpdate }),
    })
      .then((response) => response.json())
      .then((data) => console.log(data));

    // 「キャンセル」「確定」ボタンを非表示にする
    setShowConfirmButton(false);
    // コメントを編集できなくする
    setCanEdit(false);
    // コメントをマウスホバーすると三点リーダが表示されるようにする
    setShowDottedLine(true);
    // .editのwidthを元に戻す
    setCommentWidthFull(false);

    const commentsClone = JSON.parse(JSON.stringify(comments));
    commentsClone.forEach(commentData => {
      if (commentData.id === id) {
        commentData.comment = commentForUpdate;
      }
    })
    const newComments = commentsClone;
    setComments(newComments, true);
    setComment('');
    commentInputField.current.innerText = '';
  };

  const handleClickConfirm = () => {

  }

  // 「編集」ボタンを押したときの処理
  const handleClickEditButton = (e, commentId) => {
    // 編集ボタンに対応するコメントを編集可能にする
    setCanEdit(true);
    const forEditing = editing.current[commentId];  // 押した編集ボタンに対応しているコメントの取得
    setTimeout(() => {
      forEditing.focus();
    }, 100);
    // 編集ボタンに対応するコメントのwidthを100%にする
    setCommentWidthFull('true');
    // 編集前のコメントを一時保存しておく
    setPreEditComment(editing.current[commentId].innerText);
    // キャレットを文末まで移動する
    const selection = window.getSelection()
    const range = document.createRange()
    const offset = forEditing.innerText.length
    range.setStart(forEditing.firstChild, offset)
    range.setEnd(forEditing.firstChild, offset)
    selection.removeAllRanges()
    selection.addRange(range)
    // 編集メニューを非表示にする
    setShowEditMenu(false);
    // コメントをマウスホバーしても三点リーダが表示されないようにしする
    setShowDottedLine(false);
    // 「確定」「キャンセル」ボタンを表示する
    setShowConfirmButton(true);
  };

  // Shift+Enterで<br>が挿入されるのを防ぐ
  const preventBreack = (event) => {
    if (event.shiftKey && event.code === 'Enter') {
      event.preventDefault();
    }
  };

  // 三点リーダーを表示させる
  const handleClickDottedLine = (e, key) => {
    setShowEditMenu(!showEditMenu);
    setDisplayedCommentKey(key);
  }

  // メニューの外側をクリックしたときだけメニューを閉じる
  const closeWithClickOutSideMethod = (e, setter) => {
    if (e.target === e.currentTarget) {
      setter(false);
    } else {
    }
  };

  // 「キャンセル」ボタンを押したときの処理
  const handleClickCancelEdit = (e, commentId) => {
    // コメントを一次保存しておいた変更前の内容に戻す
    editing.current[commentId].innerText = preEditComment;
    // 「確定」「キャンセル」ボタンを非表示にする
    setShowConfirmButton(false);
    // コメントをマウスホバーしたら三点リーダが表示さされるようにする
    setShowDottedLine(true);
    // コメントを編集できなくする
    setCanEdit(false);
    // .editのwidthを元に戻す
    setCommentWidthFull(false);
  }

  const ajustEditConainerTop = (e) => {
    setEditContainerTop(e.target.getBoundingClientRect().height + 30);
    setCommentMarginBottom(e.target.getBoundingClientRect().height + 14)
  };

  return (
    <div className="CommentSection">
      <div className="comment-input-field" contentEditable="true" placeholder="コメントを追加..." onInput={commentInput} onKeyDown={preventBreack} ref={commentInputField}></div>
      <button className={`comment-button ${commentIsEmpty ? "comment-empty" : ""}`} onClick={commentsInput}>コメント</button>
      <div className="comment-display-field">
        {comments.map((comment, index) => {
          return (
            <div className='comment-row' key={comment.id}>
              {/* contentEditable={canEdit&&comment.id&&displayedCommentKey===comment.id} → 編集ボタンに対応しているコメントのみを編集可能にする */}
              <div className='comment-editing-upper'>
                <div className={`comment ${commentWidthFull ? "comment-width-full" : ""}`} key={comment.id} contentEditable={canEdit && comment.id && displayedCommentKey === comment.id} ref={(node) => { setRef(node, comment.id) }} onInput={(e) => { commentInput(e, comment.id); ajustEditConainerTop(e); }}>{comment.comment}</div>
                <div className={`edit ${commentWidthFull ? "edit-width-zero" : ""}`}>
                  {showDottedLine &&
                    <div className='dotted-line' onClick={(e) => { handleClickDottedLine(e, comment.id) }}>
                      <svg version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512" style={{ width: 100 + "%", height: 100 + "%", opacity: 0.5 }} xmlSpace="preserve">
                        <g>
                          <circle className="st0" cx="256" cy="55.091" r="55.091" style={{ fill: 'black' }}></circle>
                          <circle className="st0" cx="256" cy="256" r="55.091" style={{ fill: 'black' }}></circle>
                          <circle className="st0" cx="256" cy="456.909" r="55.091" style={{ fill: 'black' }}></circle>
                        </g>
                      </svg>
                    </div>
                  }
                  {/* クリックされたメニューの三点リーダーに対応する位置のコメントだけを表示させる */}
                  {showEditMenu && comment.id === displayedCommentKey &&
                    <div className={`edit-menu-wrapper ${showEditMenu ? "edit-menu-wrapper__active" : ""}`} onClick={(e) => { closeWithClickOutSideMethod(e, setShowEditMenu) }}>
                      <div className='edit-menu'>
                        <div className='comment-edit-button' onClick={(e) => { handleClickEditButton(e, comment.id) }}>
                          <svg version="1.1" id="_x32_" x="0px" y="0px" viewBox="0 0 512 512" style={{ width: 16, height: 16, opacity: 1 }}>
                            <style type="text/css" dangerouslySetInnerHTML={{ __html: "\n    .st0{fill:#4B4B4B;}\n  " }} />
                            <path className="st0" d="M392.052,0l-0.642,0.01c0,0,0,0,0.009,0c0,0,0-0.01,0.008,0L392.052,0z" style={{ fill: 'rgb(75, 75, 75)' }} />
                            <path className="st0" d="M487.66,61.494l-0.037-0.037c-0.056-0.056-0.102-0.111-0.156-0.166h-0.01
                            c-0.064-0.056-0.092-0.092-0.193-0.183l-36.51-36.511c-0.009-0.018-0.028-0.027-0.046-0.046
                            C434.386,8.23,412.811-0.027,391.419,0.01c-21.392-0.037-42.968,8.211-59.308,24.56L297.75,58.931l-13.043,13.033L0,356.681V512
                            h155.327l284.708-284.698l13.042-13.042l34.362-34.37c16.34-16.332,24.588-37.916,24.56-59.299
                            c0.027-21.291-8.156-42.766-24.313-59.06L487.66,61.494z M136.572,466.736h-91.3v-91.299l271.445-271.455l91.299,91.3
                            L136.572,466.736z M455.429,147.878l-21.327,21.318l-91.29-91.299L364.13,56.58c7.578-7.569,17.35-11.279,27.289-11.306
                            c9.938,0.027,19.702,3.738,27.288,11.306l36.722,36.712c7.569,7.587,11.279,17.36,11.298,27.298
                            C466.708,130.528,462.998,140.292,455.429,147.878z" style={{ fill: 'rgb(75, 75, 75)' }} />
                          </svg>
                          編集
                        </div>
                        <div className='comment-delete-button' onClick={(e) => handleDeleteButton(e, comment.id)}>
                          <svg version="1.1" id="_x32_" x="0px" y="0px" viewBox="0 0 512 512" style={{ width: 16, height: 16, opacity: 1 }}>
                            <style type="text/css" dangerouslySetInnerHTML={{ __html: "\n    .st0{fill:#4B4B4B;}\n  " }} />
                            <path className="st0" d="M77.869,448.93c0,13.312,1.623,25.652,5.275,35.961c4.951,13.636,13.475,23.457,26.299,26.297
                            c2.598,0.488,5.277,0.812,8.117,0.812h277.364c0.73,0,1.381,0,1.947-0.082c26.463-1.703,37.258-29.219,37.258-62.988
                            l11.121-269.324H66.748L77.869,448.93z M331.529,239.672h52.68v212.262h-52.68V239.672z M229.658,239.672h52.682v212.262h-52.682
                            V239.672z M127.789,239.672h52.762v212.262h-52.762V239.672z" style={{ fill: 'rgb(75, 75, 75)' }} />
                            <path className="st0" d="M368.666,89.289c0.078-2.028,0.242-4.059,0.242-6.09v-5.598c0-42.777-34.822-77.602-77.6-77.602h-70.701
                            c-42.778,0-77.6,34.824-77.6,77.602v5.598c0,2.031,0.162,4.062,0.326,6.09H28.721v62.582h454.558V89.289H368.666z M320.205,83.199
                            c0,2.113-0.242,4.141-0.648,6.09H192.361c-0.406-1.949-0.65-3.977-0.65-6.09v-5.598c0-15.91,12.986-28.898,28.897-28.898h70.701
                            c15.99,0,28.896,12.988,28.896,28.898V83.199z" style={{ fill: 'rgb(75, 75, 75)' }} />
                          </svg>
                          削除
                        </div>
                      </div>
                    </div>
                  }
                </div>
              </div>
              <div className='comment-editing-lower'>
                {showConfirmButton && displayedCommentKey === comment.id &&
                  <div className='confirm-button-container'>
                    <button className='cancel-edit-button' onClick={e => handleClickCancelEdit(e, comment.id)}>キャンセル</button>
                    <button className={`confirm-edit-button ${commentForUpdateIsEmpty ? "comment-empty" : ""}`} onClick={(e) => { updateComment(e, comment.id) }}>確定</button>
                  </div>
                }
              </div>
            </div>
          );
        })}
      </div>
    </div>
  )
}

export default CommentSection;

以上でコメント欄アプリの完成です。

コメント欄アプリを使ってみる

「npm start」というコマンドを使ってサーバーを起動した方(最初に使っていた方)のターミナルで Ctrl + C を入力してサーバーを停止させます。

その後、同じターミナルでサーバーを再起動させます。

npm start

自動的にブラウザの新しいタブが開き、コメント欄アプリが使えるようになります。

コメント

タイトルとURLをコピーしました