토이 프로젝트 - Vue, Firebase로 서버리스 PWA 개발

20. Firestore로 PWA myBlog 개발 - 댓글 쓰기

그랜파 개발자 2025. 2. 25. 04:42

블로그 댓글 쓰기

블로그의 댓글은 특정 블로그 게시물에 대해 방문자가 의견, 피드백, 질문 등을 작성할 수 있는 기능을 의미합니다. 댓글은 블로그의 중요한 소통 도구로, 글쓴이와 독자 간의 소통 창구 역할을 하며, 콘텐츠의 가치를 높이고 독자의 참여를 유도하는 데 도움을 줍니다.

 

 

 

댓글 기능을 구현합시다.

댓글 요구 사항

  1. 댓글은 자신의 글에 쓸수도 있고, 다른 회원의 글에도 쓸 수 있습니다.
  2. 댓글은 하나의 게시글에 대해 여러 사용자가 여러 개를 쓸 수 있습니다.
  3. 댓글은 게시글 아래 입력 필드와 저장 버튼이 있어 입력 필드에 내용을 작성하고,
  4. 저장 버튼을 누르면 저장합니다.
  5. 댓글을 저장할 때 글쓴이 정보와 작성일이 함께 저장됩니다.
  6. 댓글을 저장할 때 posts 컬렉션의 해당 문서의 댓글수 항목도 증가합니다.
  7. 댓글은 게시글에 종속되므로 글 상세보기에서 글을 로드할 때
  8. 글에 대한 댓글들을 함께 로드하여 게시글 아래 목록으로 나타냅니다.
  9. 댓글 작성자는 본인이 작성한 댓글을 삭제할 수 있습니다.
  10. 그러므로 댓글 옆에 댓글 소유자는 삭제 아이콘이 나타나고,
  11. 삭제 아이콘을 누르면 댓글을 삭제할 수 있습니다.

댓글 기능 분석

  1. 상세보기 페이지가 열리면 게시글과 함께 댓글의 목록도 가져와야 합니다.
  2. 상세보기 컴포넌트를 수정하여 하단에 댓글 입력창과 저장 버튼을 만들어야 합니다.
  3. 로그인을 하지 않으면 댓글 저장 버튼이 나타나지만 활성화되지 않습니다.
  4. 입력한 댓글들을 나타내기 위하여 댓글 목록도 댓글 입력창 아래에 만들어야 합니다.
  5. 지금 로그인한 사용자가 댓글을 쓴 저자이면 댓글 목록에서 해당 댓글은 삭제 버튼을 나타내어야 합니다.
  6. 댓글은 사용자가 내용을 입력하면 앱은 필요한 것들을 더 추가하여 저장합니다.

댓글 저장 컬렉션

댓글은 게시글에 종속됩니다.
그래서 posts 컬렉션의 서브 컬렉션 comments에 저장합니다.

댓글의 comments 컬렉션의 문서 항목은 다음과 같습니다.

  • content : 사용자가 입력한 댓글 내용
  • postId: 댓글을 쓰고 있는 글의 id
  • userId: 댓글을 쓰는 사용자 Id
  • userName : 댓글 쓰는 이의 이름
  • createdAt: 댓글 작성일

이들중 사용자가 입력하는 항목은 content 뿐 입니다.

myBlog 앱은 앱을 시작할 때 모든 글을 로드하여
store의 상태 변수 posts 배열에 저장합니다.

댓글 가져오기

  async fetchComments({ commit, dispatch }, postId) 
  {
    try 
    {
      const q = query(
        collection(db, "posts", postId, "comments"), 
        orderBy("createdAt", "asc")
      );

      const comments = [];
      const querySnapshot = await getDocs(q);      
      querySnapshot.forEach((doc) => {
        comments.push({ id: doc.id, ...doc.data(), replies: [] });
        // 댓글에 대한 답글 로드
        //dispatch('fetchReplies', { postId, commentId: doc.id });  
      });
      // 댓글을 상태에 저장
      commit('setComments', comments); 
    } 
    catch (error) 
    {
      alert("댓글 가져오기 실패 : " + error.message);
    }
  },

댓글 추가 하기

  async addComment({ dispatch }, { postId, content, userName, userId, createdAt }) 
  {
    try 
    {
      // db에 댓글 저장
      const commentRef = await addDoc(
        collection(db, "posts", postId, "comments"), 
        {content, userName, userId, createdAt}
      );
      // 댓글 저장 후 다시 로드
      dispatch('fetchComments', postId);
      // post의 댓글수 증가
      const postRef = doc(db, "posts", postId);     
      try 
      {
        // 댓글수 1 증가 또는 필드가 없을 때 1로 설정
        await updateDoc(postRef, {
          commentCount: increment(1)
        });
      } 
      catch (error) 
      {
        alert('addComment : ' + error.message);
      }
    } 
    catch (error) 
    {      
      alert("댓글 추가 실패:" + error.message);
    }
  },

댓글 삭제 하기

  async deleteComment({ commit, dispatch }, { postId, commentId }) 
  {
    commit('setLoading', true);

    const commentRef = doc(db, "posts", postId, "comments", commentId);
    try 
    {      
      await deleteDoc(commentRef);
      // 댓글 다시 로그
      dispatch('fetchComments', postId); 
      // post의 댓글수 감소
      const postRef = doc(db, "posts", postId);     
      try 
      {
        // 댓글수 1 감소
        await updateDoc(postRef, {
          commentCount: increment(-1)
        });
      } 
      catch (error) 
      {
        alert('댓글 삭제, 댓글수 감소 : ' + error.message);
      }

      commit('setLoading', false);
      alert("댓글을 삭제하였습니다.");
    } 
    catch (error) 
    {
      alert("댓글 삭제 실패 : " + error.message);
    }
  },
 

Firestore에서 컬렉션 전체를 로드하면 서브 컬렉션은 자동으로 로드되지 않습니다.
Firestore는 설계상 메인 컬렉션과 서브 컬렉션을 독립적으로 다룹니다.

댓글 로드 - PostView 컴포넌트의 script

상세보기 페이지를 열 때 해당 글의 Id로 댓글을 로드합니다.

댓글 목록 - PostView 컴포넌트의 template

PostView.vue

<!-- src/views/PostView.vue -->
<template>
  <v-container>
    <v-card v-if="post">

      <!--글쓴이의 블로그 이름-->
      <v-card-title v-if=author style="cursor: pointer; font-size:1em" @click="goToBlogs(author.userId)">
        {{ getBlogName }}<v-icon>mdi-chevron-right</v-icon>
      </v-card-title>
      <!--카테고리-->
      <v-card-text class="mb-n5" style="font-size:1em">
        {{ post.category }}
      </v-card-text>
      <!-- 제목 -->
       <v-card-title style="cursor: pointer; font-size:1.1em">
        {{ post.title }}
       </v-card-title>
       <!-- 내용 -->
      <!-- eslint-disable -->
      <v-card-text style="font-size:1em" v-html="content"></v-card-text>
      <!-- eslint-enable -->
      <!-- 글쓴이 이름, 글쓴 날, 조회수 -->
      <v-card-subtitle style="text-align:right">
        {{ post.userName }} . {{ post.createdAt.toDate().toISOString().split('T')[0] }}
        <span v-if="post.views > 0" > . ({{ post.views }})</span>
      </v-card-subtitle>      

      <v-card-actions >
        <v-spacer></v-spacer>
        <v-btn small text v-if="isAuthor" @click="goToEditPost()">
          수정 &nbsp; <v-icon>mdi-pencil</v-icon>
        </v-btn>
      </v-card-actions>

      <!-- 댓글 작성 폼 -->
      <v-form @submit.prevent="doSubmitComment" class="ml-2 mr-2">
        <v-textarea outlined style="border-color: #fffeee;" v-model="newComment" label="댓글 쓰기 ..." rows="3" required></v-textarea> 
        <v-card-actions >
          <v-spacer></v-spacer>
          <v-btn :disabled="isDisabled" class="mt-n6" type="submit" color="primary">댓글</v-btn>  
        </v-card-actions >
      </v-form>

      <v-list>
        <!-- 댓글 리스트 -->
        <v-list-item v-for="comment in comments" :key="comment.id"> 
          <v-list-item-content>
            <!-- eslint-disable -->
            <v-list-item-subtitle style="white-space: normal;" v-html="sanitizeContent(comment.content)"></v-list-item-subtitle>
            <!-- eslint-enable -->

            <v-list-item-subtitle class="mt-1"> 
              {{ comment.userName }} . {{ formatDate(comment.createdAt) }}
              <!--댓글 삭제 버튼 -->             
              <v-btn v-if="isOwner(comment.userId)" icon @click="doDeleteComment(comment.id)">
                <v-icon>mdi-delete</v-icon>
              </v-btn>
              <!-- reply 버튼 -->  
              <v-btn v-if="user" small text @click="showReplyForm(comment.id)">답글</v-btn>             
            </v-list-item-subtitle>

          </v-list-item-content>
        </v-list-item>
      </v-list> 

    </v-card>

    <div class="text-center">
      <v-progress-circular v-if="loading" indeterminate></v-progress-circular>
    </div>
  </v-container>
</template>

<script>
import { mapActions, mapState } from "vuex";
import sanitizeHtml from 'sanitize-html';

export default {
  data() {
    return {
      content: '',
      newComment: '',  // 댓글
      replyingTo: null,
      newReply: '',
      author: null, // 저자의 이름을 가져오기 위해
      isDisabled: false, // 버튼 비활성화 여부를 결정하는 상태
    };
  },

  computed: {
    ...mapState('auth',['user', 'profile', 'profiles']),
    ...mapState('post', ['loading', 'post', 'comments']),

    getBlogName() {
      return this.author.blogName;
    },
    // 저자인가? - 작성자와 로그인한 사용자 비교
    isAuthor() {
      return this.user && this.user.uid === this.post.userId;
    },
  },
  methods: {  
    ...mapActions('post', ['fetchPost','fetchComments',
      'addComment', 'deleteComment']),

    // content를 안전한 html로 바꿔준다.
    sanitizeContent(content) {
      return sanitizeHtml(content.replace(/\n/g, '<br>'), {
        allowedTags: ['b', 'i', 'em', 'strong', 'p', 'br'],
        allowedAttributes: {}
      });
    },

    formatDate(date) {
      if (date && date.toDate) {
        return date.toDate().toISOString().replace('T', ' ').substring(0, 16); 
      }
      return '';
    },

    // 로그인한 회원이 글쓴이 인가?
    isOwner(userId) {
      if(!this.user)
        return false;
      return userId === this.user.uid;
    },

    // 글 수정하러 가기
    goToEditPost() {
      this.post.updatedAt = new Date(); // 수정한 날짜를 넣자.
      this.$router.push({ name: 'Edit', params: { postId: this.$route.params.id, post: this.post } });
    },

    // 댓글 쓰기
    async doSubmitComment() {
      //postId, content, autherName, author, createdAt
      if (this.newComment.trim()) {
        await this.addComment({
          postId: this.$route.params.id,
          content: this.newComment,
          userName: this.author.name,
          userId: this.user.uid,
          createdAt: new Date()
        });
        this.newComment = ''; // 댓글 입력 필드 초기화
      }
    },

    // 댓글 삭제
    async doDeleteComment(commentId) {
      await this.deleteComment({
        postId: this.$route.params.id, 
        commentId: commentId
      });      
    },
  },

  async created() {
    // 글 가져오기
    const postId = this.$route.params.id; // Get the article ID from the route parameters
    await this.fetchPost(postId);       // 글 id로 글 가져오기
    this.content = this.sanitizeContent(this.post.content);  // 내용에 포한된 html을 안전한 html로 변경한다.

    // 글 저자 설정정보 가져오기 - 상태에 로드되어 있는 전체 계정 설정 정보를 이용한다.
    this.author = this.profiles.find(profile => profile.uids.includes(this.post.userId));

    // postId에 대한 comments를 로드한다.
    await this.fetchComments(postId);
  },

};
</script>