블로그 댓글 쓰기
블로그의 댓글은 특정 블로그 게시물에 대해 방문자가 의견, 피드백, 질문 등을 작성할 수 있는 기능을 의미합니다. 댓글은 블로그의 중요한 소통 도구로, 글쓴이와 독자 간의 소통 창구 역할을 하며, 콘텐츠의 가치를 높이고 독자의 참여를 유도하는 데 도움을 줍니다.
댓글 기능을 구현합시다.
댓글 요구 사항
- 댓글은 자신의 글에 쓸수도 있고, 다른 회원의 글에도 쓸 수 있습니다.
- 댓글은 하나의 게시글에 대해 여러 사용자가 여러 개를 쓸 수 있습니다.
- 댓글은 게시글 아래 입력 필드와 저장 버튼이 있어 입력 필드에 내용을 작성하고,
- 저장 버튼을 누르면 저장합니다.
- 댓글을 저장할 때 글쓴이 정보와 작성일이 함께 저장됩니다.
- 댓글을 저장할 때 posts 컬렉션의 해당 문서의 댓글수 항목도 증가합니다.
- 댓글은 게시글에 종속되므로 글 상세보기에서 글을 로드할 때
- 글에 대한 댓글들을 함께 로드하여 게시글 아래 목록으로 나타냅니다.
- 댓글 작성자는 본인이 작성한 댓글을 삭제할 수 있습니다.
- 그러므로 댓글 옆에 댓글 소유자는 삭제 아이콘이 나타나고,
- 삭제 아이콘을 누르면 댓글을 삭제할 수 있습니다.
댓글 기능 분석
- 상세보기 페이지가 열리면 게시글과 함께 댓글의 목록도 가져와야 합니다.
- 상세보기 컴포넌트를 수정하여 하단에 댓글 입력창과 저장 버튼을 만들어야 합니다.
- 로그인을 하지 않으면 댓글 저장 버튼이 나타나지만 활성화되지 않습니다.
- 입력한 댓글들을 나타내기 위하여 댓글 목록도 댓글 입력창 아래에 만들어야 합니다.
- 지금 로그인한 사용자가 댓글을 쓴 저자이면 댓글 목록에서 해당 댓글은 삭제 버튼을 나타내어야 합니다.
- 댓글은 사용자가 내용을 입력하면 앱은 필요한 것들을 더 추가하여 저장합니다.
댓글 저장 컬렉션
댓글은 게시글에 종속됩니다.
그래서 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()">
수정 <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>
'토이 프로젝트 - Vue, Firebase로 서버리스 PWA 개발' 카테고리의 다른 글
22. Vue 프로젝트 PWA myBlog 개발 - 블로그 구독 (1) | 2025.02.27 |
---|---|
21. 서버리스 PWA myBlog 개발 - 게시글 댓글에 대한 답글 (0) | 2025.02.26 |
19. Firestore로 PWA myBlog 개발 - 글수정 (0) | 2025.02.24 |
18. Vue(with Vuetify)와 Firebase로 서버리스 PWA myBlog 개발 - 블로그 글 상세보기 (1) | 2025.02.22 |
17. Firestore로 PWA myBlog 개발 - 블로그 글 상세보기 기능들 (0) | 2025.02.21 |