게시글 댓글에 대한 답글
블로그 댓글과 답글 기능은 단순한 소통 수단을 넘어, 블로그의 활성화와 신뢰도를 높이는 중요한 요소입니다.
댓글을 통해 독자들은 자신의 의견을 공유하고, 블로거와 직접 소통할 수 있습니다.
답글 기능이 있으면 토론이 자연스럽게 이어지고, 블로거와 독자 간의 관계가 강화됩니다.
답글 쓰기
블로그의 게시글에 대해 댓글을 쓸 수 있습니다.
댓글은 게시글의 서브컬렉션 comments 컬렉션에 저장 됩니다.
댓글에 대해 답글을 쓸 수 있습니다.
댓글의 답글은 댓글의 서브컬렉션 replies 컬렉션에 저장이 됩니다.
글 상세보기 페이지가 열릴 때 글을 로드하면서
글의 댓글도 state의 comments 배열에 저장하였습니다.
각 댓글에 대한 답글은 comments 배열의 각 요소에 replies 배열에 로드합니다.
이렇게 해서 댓글의 목록에 답글의 목록도 함께 나타낼 수 있게 됩니다.
댓글의 목록에서 지금 로그인 한 회원이 댓글의 글쓴이라면 삭제 아이콘 버튼이 나타납니다.
답글의 목록에서 지금 로그인 한 회원이 답글의 글쓴이라면 삭제 아이콘 버튼이 나타납니다.
답글 쓰기 폼은 숨겨져 있습니다.
각 댓글의 ‘답글’ 버튼을 누르면 답글 쓰기 폼이 나타납니다.
취소를 누르면 답글 쓰기 폼을 닫습니다.
답글을 누르면 답글을 저장합니다.
답글은 사용자가 내용을 입력하면 앱은 필요한 것들을 더 추가하여 저장합니다.

답글의 문서 항목
- postId: 댓글을 쓰고 있는 글의 id
- commentId: 답글을 쓰고 있는 댓글의 Id
- content: 사용자가 입력한 댓글의 내용
- userName: 답글 쓰는 사용자의 이름,
- userId: 답글을 쓰는 사용자의 userId
- createdAt: 답글 작성일
댓글의 답글은 댓글의 서브컬렉션 replies 컬렉션에 저장이 됩니다.
PostView 컴포넌트의 template - 답글 리스트

PostView 컴포넌트의 script - 답글 로드
상세 보기 페이지가 열리면 게시글을 가져오면서 게시글의 댓글도 함께 로드합니다.

댓글을 로드할 때 댓글도 함께 로드합니다.

store의 post 모듈
댓글 가져오기
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);
}
},
src/store/modules/post.js
// src/store/modules/post.js
import { v4 as uuidv4 } from 'uuid';
import { db, collection, getDocs, getDoc, setDoc, doc,
addDoc, updateDoc, deleteDoc, query, where, orderBy,
increment, arrayUnion } from "@/firebase";
const state = {
loading: false,
categories: [], // 카테고리
post: null, // 글
posts: [], // 륵목록
comments: [], // 댓글 목록 저장
};
const mutations = {
setLoading(state, loading) {
state.loading = loading;
},
// 카테고리 관리
setCatogories(state, categories) { // 카테고리
state.categories = categories;
},
addCatogory(state, category) { // 카테고리 추가
state.categories.push(category);
},
removeCatogory(state, id) { // 카테고리 제거
let categories = [];
categories = state.categories.filter((category) => category.id !== id);
state.categories = categories;
},
// -- 글관리
setPost(state, post) {
state.post = post;
},
setPosts(state, posts) {
state.posts = posts;
},
addPost(state, post) {
state.posts.push(post);
},
setComments(state, comments) { // 현재 댓글
state.comments = comments;
},
addReply(state, { commentId, reply }) { // 댓글에 대한 답글 로드
const comment = state.comments.find(c => c.id === commentId);
if (comment) {
if (!comment.replies) {
comment.replies = [];
}
comment.replies.push(reply);
}
},
};
const actions = {
// -- 카테고리 관리 ----
async fetchCategories({ commit }, { userId }) {
if (!userId) return;
try {
// 사용자 ID로 필터링된 카테고리 가져오기
const q = query(
collection(db, "categories"),
where("userId", "==", userId)
);
const querySnapshot = await getDocs(q);
let categories = [];
categories = querySnapshot.docs.map((doc) => ({
id: doc.id, ...doc.data(),
}));
// 로드한 카테고리를 상태에 저장한다.
commit("setCatogories", categories);
} catch (error) {
alert("Error fetching categories : " + error.message);
}
},
async addCategory({ commit }, { newCategory, userId } ) {
if (!newCategory || !userId) return;
try {
commit('setLoading', true);
const docRef = await addDoc(collection(db, "categories"), {
name: newCategory, userId: userId,
});
commit("addCatogory", { id: docRef.id, name: newCategory });
commit('setLoading', false);
} catch (error) {
alert("Error adding category : " + error.message);
}
},
async removeCategory({ commit }, id) {
try {
commit('setLoading', true);
await deleteDoc(doc(db, "categories", id)); // db에 저장된 카테고리 에서 제거
commit("removeCatogory", id); // state에 있는 카테고리에서 제거
commit('setLoading', false);
} catch (error) {
console.error("Error deleting category:", error);
}
},
// -- 글쓰기 -------
async addPost({ dispatch }, post) {
const docRef = await addDoc(collection(db, "posts"), post);
dispatch('fetchPosts');
},
async fetchPost({ commit }, postId) {
commit('setLoading', true);
try {
const docRef = doc(db, "posts", postId); // Reference to the specific article document
const docSnap = await getDoc(docRef); // Fetch the document snapshot
if (docSnap.exists()) {
const post = docSnap.data(); // Set article data
commit('setPost', post);
} else {
alert("post가 없습니다.");
}
} catch (error) {
alert('fetchPost: ' + error.message);
} finally {
commit('setLoading', false);
}
},
// 전체 글 가져오기 - 최신순
async fetchPosts({ commit }) {
const postsRef = query(collection(db, "posts"), orderBy("createdAt", "desc"));
const querySnapshot = await getDocs(postsRef);
const posts = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
commit("setPosts", posts);
},
// 전체 글 가져오기 - 오래된 순
async fetchPostsOrderByAsc({ commit }) {
const postsRef = query(collection(db, "posts"), orderBy('createdAt', 'asc'));
const querySnapshot = await getDocs(postsRef);
const posts = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
commit("setPosts", posts);
},
// 글수정
async updatePost({ commit }, { id, post }) {
const docRef = doc(db, "posts", id);
await updateDoc(docRef, post);
},
// -- 댓글 --
// 댓글 가져오기
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);
}
},
// -- 댓글 끝 ---
// -- 답글 -----
// 특정 댓글의 답글 불러오기
async fetchReplies({ commit }, { postId, commentId }) {
try {
const q = query(
collection(db, "posts", postId, "comments", commentId, "replies"),
orderBy("createdAt", "asc"));
const querySnapshot = await getDocs(q);
const replies = [];
querySnapshot.forEach((doc) => {
replies.push({ id: doc.id, ...doc.data() });
});
replies.forEach(reply => {
commit('addReply', { commentId, reply });
});
} catch (error) {
alert("답글 불러오기 실패 : " + error.message);
}
},
// 댓글에 답글 추가
async addReply({ dispatch }, { postId, commentId, content, userName, userId, createdAt }) {
try {
// db에 답글 저장
const replyRef = await addDoc(
collection(db, "posts", postId, "comments", commentId, "replies"), {
content, userName, userId, createdAt
});
dispatch('fetchReplies', { postId, commentId }); // 답글 로드
alert("답글을 저장하였습니다.");
} catch (error) {
alert("답글 추가 실패 : " + error.message);
}
},
// 답글 삭제
async deleteReply({ commit, dispatch }, { postId, commentId, replyId }) {
const replyRef = doc(db, "posts", postId, "comments", commentId, "replies", replyId);
try {
await deleteDoc(replyRef);
dispatch('fetchComments', postId); // 댓글 다시 로드
alert("답글이 삭제되었습니다.");
} catch (error) {
alert("답글 삭제 실패 : " + error.message);
}
},
};
const getters = {
};
export default {
namespaced: true,
state,
mutations,
actions,
getters
};
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 class="ml-5" v-if="comment.replies && comment.replies.length">
<!-- 답글 -->
<v-list-item v-for="reply in comment.replies" :key="reply.id">
<v-list-item-content>
<!-- eslint-disable -->
<v-list-item-subtitle style="white-space: normal;" v-html="sanitizeContent(reply.content)"></v-list-item-subtitle>
<!-- eslint-enable -->
<v-list-item-subtitle class="ml-2">
{{ reply.userName }} : {{ formatDate(reply.createdAt) }}
<v-btn v-if="isOwner(reply.userId)" icon @click="doDeleteReply(comment.id, reply.id)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
<!-- 답글 쓰기-->
<v-form class="ml-5 mt-1 mr-2" v-if="replyingTo === comment.id" @submit.prevent="doSubmitReply(comment.id)">
<v-textarea outlined style="border-color: #fffeee; width: 90%" v-model="newReply" label="답글 쓰기 ..." rows="3" required></v-textarea>
<v-btn class="ma-0 mt-n12" small text @click="showReplyForm(null)">취소</v-btn>
<v-btn class="ma-0 mt-n12" small text type="submit" color="primary">답글</v-btn>
</v-form>
<!-- reply end --->
</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', 'addReply', 'deleteReply']),
// 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
});
},
// 답글 작성 폼 보이기
showReplyForm(commentId) {
this.replyingTo = commentId;
},
// 답글 작성
async doSubmitReply(commentId) {
if (this.newReply && this.newReply.trim()) {
//console.log(this.author.name);
await this.addReply({
postId: this.$route.params.id,
commentId: commentId,
content: this.newReply,
userName: this.author.name,
userId: this.user.uid,
createdAt: new Date()
});
this.newReply = ''; // 입력 필드 초기화
this.replyingTo = null;
}
},
// 답글 삭제
async doDeleteReply(commentId, replyId) {
await this.deleteReply({
postId: this.$route.params.id,
commentId: commentId,
replyId: replyId
});
},
},
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 개발' 카테고리의 다른 글
23. 프론트엔드 서버리스 프로젝트 PWA myBlog 개발 - 블로그 조회수 (0) | 2025.02.28 |
---|---|
22. Vue 프로젝트 PWA myBlog 개발 - 블로그 구독 (1) | 2025.02.27 |
20. Firestore로 PWA myBlog 개발 - 댓글 쓰기 (0) | 2025.02.25 |
19. Firestore로 PWA myBlog 개발 - 글수정 (0) | 2025.02.24 |
18. Vue(with Vuetify)와 Firebase로 서버리스 PWA myBlog 개발 - 블로그 글 상세보기 (1) | 2025.02.22 |