블로그의 조회수
글의 목록에서 글을 선택하면
상세보기 페이지로 이동해서 글의 전체 내용을 보여줍니다.
이렇게 상세보기 페이지가 열릴 때 조회수를 증가시켜 글의 조회수를 집계합니다.
우리의 작은 블로그는 웹툰을 볼 때 화면을 스크롤하면서 보듯
우리의 작은 블로그도 마찬가지로 스크롤하면서 글을 볼 수 있도록 하여
페이지의 이동 없이도 목록의 글들을 연속해서 볼 수 있습니다.
그래서 상세보기가 필요할까 생각할 수도 있지만,
상세보기에는 댓글과 답글이 있습니다.
또한 구독 요청, 구독 취소를 할 수 있고,
글쓴이라면 자신의 글을 수정할 수도 있습니다.
이처럼 상세보기에 기능이 많습니다.
사실 블로그에 글을 쓰면 조회수가 궁금하기도 합니다.
우리의 작은 블로그는 목록에서 이미 글의 내용을 모두 볼 수 있기 때문에
상세 보기로 들어오는 경우가 줄어들 수 있습니다.
즉 조회수가 글의 내용을 본 숫자 보다는 작을 것입니다.
조회수
글의 조회수를 구현하여 봅시다.
사용자가 상세보기에 접속을 할 때 조회수를 증가시킵니다.
한 사용자는 하루에 글을 몇 번을 읽어도 조회수는 한 번이고
비회원의 경우도 조회수를 증가시킵니다.
비회원의 경우 하루에 글을 몇번을 읽을 때 같은 사용자임을 어떻게 알수 있을까요?
비회원일 경우 로컬 스토리지 또는 쿠키를 사용하여 고유 ID 생성함으로써 같은 사용자임을 알 수 있습니다.
비회원의 고유 ID를 생성하기 위해서 UUID4를 사용합니다.
UUID4란?
UUID4는 Universally Unique Identifier (UUID) 의 한 유형으로,
무작위(randomness) 를 기반으로 고유한 식별자를 생성합니다.
UUID는 데이터베이스 키, 파일 이름, 트랜잭션 ID 등에서 고유성을 보장하기 위해 사용됩니다.
UUID를 사용하기 위해 uuid 라이브러리를 사용합니다.
npm install uuid4
조회수 구현
- 독자가 로그인 상태인지 확인하고 로그인이 아니면 비회원 고유 ID를 얻는다.
- 로그인 사용자이면 userId가 독자 ID가 되고,
- 로그인 상태가 아니라면 비회원 고유 ID가 독자 ID가 된다.
- 독자 ID로 postViews 컬렉션에서 조회 내역이 있는지 확인한다.
- 조회 내역이 없다면 조회수 1로 새로운 조회 문서를 저장한다.
- 조회 내역이 있다면 오늘 조회 내역이 있는지 확인한다.
- 오늘 조회 내역이 없으면 조회수를 증가시킨다.
postViews 컬렉션은 users 서브컬렉션을 가지고,
users 서브컬렉션의 각 문서는 사용자가 방문한 날짜 항목이 있습니다.
이것은 블로그 글의 날짜별 조회수를 집계하기 위한 것입니다.
postViews 컬렉션 뿐 아니라
posts에도 views 항목을 두어서 조회수를 증가시킵니다.
이것은 글을 화면에 나타낼 때 조회수를 출력을 쉽게 하려는 의도입니다.
조회수 증가
store의 post 모듈
// -- 조회수 증가 ----
// 1. 비회원도 조회수 증가, 비회원 고유 ID 얻기
// 2. 같은 사용자일 경우 하루 1회 조회 증가
// 오늘 조회 내역이 있는지 확인 필요
// 3. 사용자의 조회 정보는 views 컬렉션에 저장
async updateViewCount({ }, postId, userId = null) {
let viewId;
let viewedToday;
// 비회원일 경우 로컬 스토리지 또는 쿠키를 사용하여 고유 ID 생성
if (!userId) {
viewId = localStorage.getItem('anonymousUserId');
if (!viewId) {
viewId = uuidv4();
localStorage.setItem('anonymousUserId', viewId);
}
} else {
viewId = userId; // 회원일 경우 사용자 ID
}
try {
const today = new Date().toISOString().split('T')[0];
const viewDocRef = doc(db, 'postViews', postId, 'users', viewId);
// Firestore에서 해당 사용자의 조회 기록을 가져옵니다.
const viewDoc = await getDoc(viewDocRef);
if (viewDoc.exists()) {
// 오늘 조회 기록이 있는지 확인
const lastViewed = viewDoc.data().lastViewed;
viewedToday = lastViewed.some(view => view === today);
}
// 조회한 내역이 없으면 조회수 추가
if (!viewedToday) {
// Firestore의 lastViewed 배열에 조회 시간을 추가
await setDoc(viewDocRef, {
lastViewed: arrayUnion(today) // 배열에 서버 시간을 추가
}, { merge: true });
// post 조회수 증가
const postRef = doc(db, "posts", postId);
try {
// 조회수 1 증가 또는 필드가 없을 때 1로 설정
await updateDoc(postRef, {
views: increment(1)
});
} catch (error) {
alert(error.message);
}
}
} catch (error) {
alert(error.message);
}
},
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-btn small text v-if="!isAuthor && user" @click="toggleSubscription">
{{ isSubscribed ? "구독 취소" : "구독 요청" }} <v-icon>mdi-check-circle</v-icon>
</v-btn>
<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',
'isSubscribed','subscriptionId']),
getBlogName() {
return this.author.blogName;
},
// 저자인가? - 작성자와 로그인한 사용자 비교
isAuthor() {
return this.user && this.user.uid === this.post.userId;
},
},
methods: {
...mapActions('post',
['fetchPost','fetchComments',
'addComment', 'deleteComment', 'addReply', 'deleteReply',
'checkSubscription', 'subscribeToUser', 'unsubscribeFromUser',
'updateViewCount']),
// 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
});
},
// -- 구독 -------------------------------
// Toggle subscription: subscribe if not subscribed,
// unsubscribe if already subscribed
async toggleSubscription() {
const userId = this.user.uid;
const authorId = this.post.userId;
const subscriptionId = this.subscriptionId;
if (this.isSubscribed) {
// Unsubscribe the user
await this.unsubscribeFromUser({userId, authorId});
} else {
// Subscribe the user
const createdAt = new Date();
await this.subscribeToUser({userId, authorId, createdAt});
}
// 변경 내용 갱신을 위하여
// Check if the user is subscribed when the component mounts
this.checkSubscription({userId, authorId});
},
// -- 구독 끝 ----------------------------
},
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));
const authorId = this.post.userId;
const userId = this.user.uid;
if(userId) {
// Check if the user is subscribed when the component mounts
this.checkSubscription({userId, authorId});
}
// 댓글 버튼 활성화
if(this.user)
this.isDisabled = false;
else
this.isDisabled = true;
// postId에 대한 comments를 로드한다.
await this.fetchComments(postId);
// 조회수를 증가한다.
// 같은 회원은 몇번을 방문해도 하루 1회, 비회원도 조회수 증가시킴
await this.updateViewCount(postId, userId);
},
};
</script>
'토이 프로젝트 - Vue, Firebase로 서버리스 PWA 개발' 카테고리의 다른 글
22. Vue 프로젝트 PWA myBlog 개발 - 블로그 구독 (1) | 2025.02.27 |
---|---|
21. 서버리스 PWA myBlog 개발 - 게시글 댓글에 대한 답글 (0) | 2025.02.26 |
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 |