67. my-blog Source Code
1. main.js
import Vue from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import store from './store'
import vuetify from './plugins/vuetify'
import { auth } from '@/firebase';
Vue.config.productionTip = false
new Vue({
router,
store,
vuetify,
render: h => h(App),
created() {
// Set up Firebase auth state change listener
const { dispatch } = this.$store;
dispatch('auth/fetchUsers');
auth.onAuthStateChanged(user => {
// 우리는 우리의 웹앱에 저장된 계정 정보를 사용한다.
// user.uid로 웹앱의 firestore DB에서 계정 정보를 가져온다.
if(user != null) {
dispatch('auth/fetchUserWithUid', {uid: user.uid});
}
});
}
}).$mount('#app')
2. firebase.js
import firebase from 'firebase/compat/app';
import 'firebase/compat/auth';
import 'firebase/compat/firestore';
const firebaseConfig = {
apiKey: 'YOUR_API_KEY',
authDomain: 'YOUR_AUTH_DOMAIN',
projectId: 'YOUR_PROJECT_ID',
storageBucket: 'YOUR_STORAGE_BUCKET',
messagingSenderId: 'YOUR_MESSAGING_SENDER_ID',
appId: 'YOUR_APP_ID'
};
firebase.initializeApp(firebaseConfig);
const db = firebase.firestore();
const auth = firebase.auth();
const googleProvider = new firebase.auth.GoogleAuthProvider();
export { db, firebase, auth, googleProvider };
3. src/router/index.js
// src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeView from '@/views/HomeView.vue';
import LoginView from '@/views/LoginView.vue';
import RegisterView from '@/views/RegisterView.vue';
import ProfileView from '@/views/ProfileView.vue';
import AboutView from '@/views/AboutView.vue';
import WritePost from '@/views/WritePost.vue';
import MyPosts from '@/views/MyPosts.vue';
import UserPosts from '@/views/UserPosts.vue';
import PostDetailView from '@/views/PostDetailView.vue';
import SearchPosts from '@/views/SearchPosts.vue';
import SubscribedPosts from '@/views/SubscribedPosts.vue';
import SubscribersList from '@/views/SubscribersList.vue';
import store from '../store';
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'HomeView',
component: HomeView
},
{
path: '/my-posts',
name: 'MyPosts',
component: MyPosts,
meta: { requiresAuth: true }
},
{
path: '/user-posts/:id',
name: 'UserPosts',
component: UserPosts
},
{
path: '/write-post',
name: 'WritePost',
component: WritePost,
meta: { requiresAuth: true }
},
{
path: '/post/:id',
name: 'PostDetailView',
component: PostDetailView
},
{
path: '/search-posts',
name: 'SearchPosts',
component: SearchPosts
},
{
path: '/subscribed-posts',
name: 'SubscribedPosts',
component: SubscribedPosts,
meta: { requiresAuth: true }
},
{
path: '/subscriber-list',
name: 'SubscribersList',
component: SubscribersList,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'LoginView',
component: LoginView
},
{
path: '/register',
name: 'RegisterView',
component: RegisterView
},
{
path: '/profile',
name: 'ProfileView',
component: ProfileView,
meta: { requiresAuth: true }
},
{
path: '/about',
name: 'AboutView',
component: AboutView
}
];
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
});
router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
const user = store.state.auth.user;
if (requiresAuth && !user) {
next('/login');
} else {
next();
}
});
export default router;
4. src/store/modules/auth.js
// src/store/modules/auth.js
import { db, firebase, auth, googleProvider } from '@/firebase';
import router from '@/router'; // Vue Router import
const state = {
user: null,
users: [],
isLoading: false,
error: null
};
const mutations = {
setUser(state, user) {
state.user = user;
},
setUsers(state, users) {
state.users = users;
state.isLoading = false;
},
setError(state, error) {
console.log(error);
state.error = error;
state.isLoading = false;
},
setLoading(state, isLoading) {
state.isLoading = isLoading;
}
}
const actions = {
async fetchUsers({ commit }) {
commit('setLoading', true);
try {
const usersSnapshot = await db.collection('users').get();
const users = usersSnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
//console.log(users);
commit('setUsers', users);
} catch (error) {
commit('setError', error.message);
}
},
async addUser({ dispatch, commit }, user) {
try {
await db.collection('users').add(user)
.then(newUser => {
// 로그인 설정
user.id = newUser.id;
console.log('addUser :', user);
commit('setUser', user);
});
dispatch('fetchUsers');
} catch (error) {
console.error('Error adding user:', error);
}
},
async deleteUser({ dispatch }, userId) {
try {
await db.collection('users').doc(userId).delete();
dispatch('fetchUsers');
} catch (error) {
console.error('Error deleting user:', error);
}
},
async updateUser({ dispatch }, { userId, user }) {
try {
await db.collection('users').doc(userId).update(user);
dispatch('fetchUsers');
} catch (error) {
console.error('Error updating user:', error);
}
},
async login({ commit, dispatch }, { email, password }) {
try {
const { user } = await auth.signInWithEmailAndPassword(email, password);;
// 웹앱의 계정 정보를 가져와 로그인 설정을 한다.
dispatch('fetchUserWithUid', {uid: user.uid});
router.push("/"); // home으로
} catch (error) {
commit('setError', error.message);
}
},
async fetchUserWithUid({ commit }, {uid}) {
//console.log(uid);
try {
const querySnapshot = await db.collection('users').where('uids', 'array-contains', uid).get();
//const user = querySnapshot.docs.map(doc => doc.data());
const user = querySnapshot.docs. map(doc => ({ id: doc.id, ...doc.data() }));
//console.log('Matching user:', user[0]);
// 로그인 설정을 한다.
commit('setUser', user[0]);
} catch (error) {
console.error('Error fetching user:', error);
}
},
async register({ commit, dispatch }, { email, password, name }) {
try {
const { user } = await auth.createUserWithEmailAndPassword(email, password, name);
// 웹앱에 계정을 만든다.
const newUser = {
email: email,
name: name,
uids: [user.uid]
};
// newUser 객체가 추가됨
// dispatch('addUser', { user: newUser });
// newUser 계정 정보가 추가됨
dispatch('addUser', newUser);
router.push("/"); // home으로
} catch (error) {
console.log(error);
commit('setError', error.message);
}
},
async addUidToUser({ state }, newUid) {
//console.log('user.id:', state.user.id);
if (state.user) {
try {
await db.collection('users').doc(state.user.id).update({
uids: firebase.firestore.FieldValue.arrayUnion(newUid)
});
// Update the local state if neededa
state.user.uids = [...(state.user.uids || []), newUid];
} catch (error) {
console.error('Error adding UID to user:', error);
}
}
},
async addGoogleUid({ commit, dispatch, getters }) {
try {
const { user } = await auth.signInWithPopup(googleProvider);
try {
// 이미 연동된 회원의 경우 알림 메시지 출력한다.
const myUser = getters.getUserByUid(user.uid);
if (myUser) {
console.log("sos:", myUser);
// 이미 연동되어 있다.
commit('setError','이미 연동되어 있습니다.');
} else {
// 구글 연동을 진행한다.
dispatch('addUidToUser', user.uid);
}
} catch (error) {
commit('setError','Error adding google uid');
}
} catch (error) {
commit('setError', error.message);
}
},
async googleLogin({ commit, getters }) {
try {
// 구글 계정에 로그인
const { user } = await auth.signInWithPopup(googleProvider);
try {
// 구글계정의 uid로 웹앱 계정의 정보를 가져옴.
// 웹앱 계정은 사이트에 접속할 때 전체 회원 정보를 로드하였으므로
// 이미 로드된 회원 리스트에서 구글 계정 uid를 가진 myUser를 가져온다.
const myUser = getters.getUserByUid(user.uid);
if (myUser) {
commit('setUser', myUser);
router.push("/"); // home으로
} else {
console.log('등록된 회원이 아닙니다.');
// 이경우 회원 가입 페이지로 이동 필요
}
} catch (error) {
console.error('Error adding user:', error);
}
} catch (error) {
commit('setError', error.message);
}
},
async logout({ commit }) {
await auth.signOut();
commit('setUser', null);
},
resetError({ commit }) {
commit('setError', null);
},
setUser({ commit }, user) {
commit('setUser', user);
}
}
const getters = {
user: state => state.user,
isAuthenticated: state => !!state.user,
users: state => state.users,
isLoading: state => state.isLoading,
error: state => state.error,
getUserByUid: (state) => (uid) => {
return state.users.find(user => user.uids && user.uids.includes(uid));
}
};
export default {
namespaced: true,
state,
mutations,
actions,
getters
};
5. src/store/modules/posts.js
// src/store/modules/posts.js
import { db } from '../../firebase';
const state = {
posts: [],
loading: false,
error: null
};
const mutations = {
setLoading(state, loading) {
state.loading = loading;
},
setPosts(state, posts) {
state.posts = posts;
},
setError(state, error) {
state.error = error;
}
};
const actions = {
async fetchPosts({ commit }) {
commit('setLoading', true);
try {
const postsSnapshot = await db.collection('posts').get();
const posts = [];
postsSnapshot.forEach(doc => {
posts.push({ id: doc.id, ...doc.data() });
});
commit('setPosts', posts);
} catch (error) {
commit('setError', error);
} finally {
commit('setLoading', false);
}
},
async addPost({ commit, dispatch }, post) {
commit('setLoading', true);
try {
await db.collection('posts').add(post);
dispatch('fetchPosts');
} catch (error) {
commit('setError', error);
} finally {
commit('setLoading', false);
}
}
};
export default {
namespaced: true,
state,
mutations,
actions
};
6. src/store/index.js
// src/store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import auth from './modules/auth';
import posts from './modules/posts';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
auth,
posts
}
});
===============================
src/views/AboutView.vur
<template>
<v-container>
<h1>About Page</h1>
<v-card>
<v-card-title>About My Blog</v-card-title>
<v-card-text>
My Blog는 ChatGPT와 함께 개발하는 PWA 웹앱입니다.
</v-card-text>
</v-card>
</v-container>
</template>
<script>
export default {
name: 'About'
};
</script>
7. rc/views/HomeView.vue
<!-- src/views/HomeView.vue -->
<template>
<v-container>
<v-alert v-if="error" type="error" dismissible>{{ error.message }}</v-alert>
<v-progress-circular v-if="loading" indeterminate></v-progress-circular>
<v-list v-if="!loading && posts.length">
<v-list-item v-for="post in posts" :key="post.id" @click="goToPostDetail(post.id)">
<v-list-item-content>
<v-list-item-title>{{ post.title }}</v-list-item-title>
<v-list-item-subtitle>
<div>{{ post.userName }} {{ formatDate(post.createdAt) }}</div>
</v-list-item-subtitle>
<v-list-item-subtitle>{{ viewContent(post.content) }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
<v-alert v-if="!loading && !posts.length" type="info">No posts available</v-alert>
</v-container>
</template>
<script>
export default {
name: 'Home',
computed: {
posts() {
return this.$store.state.posts.posts;
},
loading() {
return this.$store.state.posts.loading;
},
error() {
return this.$store.state.posts.error;
}
},
created() {
this.$store.dispatch('posts/fetchPosts');
},
methods: {
goToPostDetail(postId) {
this.$router.push({ name: 'PostDetailView', params: { id: postId } });
},
formatDate(date) {
if (date && date.toDate) {
return date.toDate().toLocaleString();
}
return '';
},
viewContent(content) {
//console.log('viewContent:', content);
return content.replaceAll(' ', ' ');
}
}
};
</script>
8. src/views/Login.vue
<!-- src/views/Login.vue -->
<template>
<v-container>
<v-row>
<v-col cols="12" class="text-center my-5">
<h3>로그인</h3>
</v-col>
</v-row>
<v-row>
<v-col class="text-center" cols="10" offset="1" sm="8" offset-sm="2">
<v-form @submit.prevent="loginUser">
<v-text-field v-model="email" label="이메일" type="email" required></v-text-field>
<v-text-field v-model="password" label="비밀번호" type="password" required></v-text-field>
<v-btn type="submit" color="primary">Login</v-btn>
<v-alert v-if="error" type="error" dismissible>{{ error }}</v-alert>
</v-form>
</v-col>
</v-row>
<v-row>
<v-col class="text-center" cols="10" offset="1" sm="8" offset-sm="2">
<v-btn color="red" @click="googleLogin" dark>
<v-icon left>mdi-google</v-icon>
Sign in with Google
</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'Login',
data() {
return {
email: '',
password: ''
};
},
computed: {
...mapGetters('auth',['error'])
},
methods: {
...mapActions('auth', ['login', 'googleLogin']),
async loginUser() {
await this.login({ email: this.email, password: this.password });
}
}
};
</script>
9. src/views/MyPosts.vue
<!-- src/views/MyPosts.vue -->
<template>
<v-container>
<v-alert v-if="error" type="error" dismissible>{{ error.message }}</v-alert>
<v-progress-circular v-if="loading" indeterminate></v-progress-circular>
<v-list v-if="!loading">
<v-list-item v-for="post in posts" :key="post.id" @click="goToPostDetail(post.id)">
<v-list-item-content>
<v-list-item-title>{{ post.title }}</v-list-item-title>
<v-list-item-subtitle class="text-right">
{{ post.userName }} Views: {{ post.views }}
</v-list-item-subtitle>
<v-list-item-subtitle>{{ post.content }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ formatDate(post.createdAt) }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-container>
</template>
<script>
import { db, auth } from '../firebase';
export default {
data() {
return {
posts: [],
loading: false,
error: null
};
},
async created() {
this.loading = true;
//const user = auth.currentUser;
const user = this.$store.state.auth.user;
if (user) {
try {
//const querySnapshot = await db.collection('posts').where('userId', '==', user.uid).get();
const querySnapshot = await db.collection('posts').where('userId', '==', user.id).get();
this.posts = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
} catch (error) {
this.error = error;
} finally {
this.loading = false;
}
} else {
this.error = { message: 'User not authenticated' };
this.loading = false;
}
},
methods: {
goToPostDetail(postId) {
this.$router.push({ name: 'PostDetailView', params: { id: postId } });
},
formatDate(date) {
if (date && date.toDate) {
return date.toDate().toLocaleString();
}
return '';
}
}
};
</script>
10. src/views/PostDetailView.vue
<!-- src/views/PostDetailView.vue -->
<template>
<v-container>
<v-progress-circular v-if="loading" indeterminate></v-progress-circular>
<v-card v-if="!loading && post">
<div class="ml-2 my-2" id="blog-link" @click="goToUserPosts(author.id)"><b>{{ author.name }}님 블로그</b></div>
<v-card-title>
{{ post.title }}
<v-spacer></v-spacer>
<SubscribeButton :authorId="post.userId" :currentUser="currentUser" v-if="currentUser && currentUser.id !== post.userId" />
</v-card-title>
<v-card-text>{{ post.userName }} - {{ formatDate(post.createdAt) }} Views: {{ post.views }}</v-card-text>
<!-- eslint-disable -->
<v-card-text v-html="postContent"></v-card-text>
<!-- eslint-enable -->
<!-- Comments Section -->
<v-divider class="my-2"></v-divider>
<h3>Comments</h3>
<v-list>
<v-list-item v-for="comment in comments" :key="comment.id">
<v-list-item-content>
<!-- comments -->
<v-list-item-title>{{ comment.content}}</v-list-item-title>
<v-list-item-subtitle>
{{ comment.userName }} : {{ formatDate(comment.createdAt) }}
<v-btn v-if="isCommentOwner(comment.userId)" icon @click="deleteComment(comment.id)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-list-item-subtitle>
<!-- reply -->
<v-list-item-action v-if="isAuthenticated">
<v-btn small outlined @click="showReplyForm(comment.id)"> 답글 쓰기 </v-btn>
</v-list-item-action>
<!-- reply list -->
<v-list class="ml-6" v-if="comment.replies && comment.replies.length">
<v-list-item v-for="reply in comment.replies" :key="reply.id">
<v-list-item-content>
<v-list-item-title>{{ reply.content }}</v-list-item-title>
<v-list-item-subtitle>
{{ reply.userName }} : {{ formatDate(reply.createdAt) }}
<v-btn v-if="isReplyOwner(reply.userId)" icon @click="deleteReply(comment.id, reply)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
<!-- 답글 쓰기-->
<v-form v-if="replyingTo === comment.id" @submit.prevent="addReply(comment.id)">
<v-text-field v-model="newReply" label="Add a reply" required></v-text-field>
<v-btn small text @click="showReplyForm(null)"> 취소 </v-btn>
<v-btn small text type="submit" color="primary" class="ml-2"> 답글 </v-btn>
</v-form>
<!-- reply end --->
</v-list-item-content>
</v-list-item>
</v-list>
<!-- Add Comment Form -->
<v-form v-if="isAuthenticated" @submit.prevent="addComment">
<v-textarea v-model="newComment" label="Add a comment" rows="3" required></v-textarea>
<!-- <v-text-field v-model="newComment" label="Add a comment" required></v-text-field> -->
<v-btn small outlined type="submit" color="primary"> 댓글 쓰기 </v-btn>
</v-form>
<v-alert v-else type="info" dismissible>Please log in to post a comment.</v-alert>
</v-card>
<v-alert v-model="alert" v-if="error" type="error" dismissible class="my-alert">{{ error.message }}</v-alert>
</v-container>
</template>
<script>
import { db } from '../firebase';
import firebase from 'firebase/compat/app';
import sanitizeHtml from 'sanitize-html';
import { v4 as uuidv4 } from 'uuid'; // Importing uuidv4
import SubscribeButton from '@/components/SubscribeButton.vue';
export default {
components: {
SubscribeButton
},
data() {
return {
currentUser: this.$store.state.auth.user,
post: null,
comments: [], // 댓글
newComment: '',
newReply: '',
replyingTo: null,
postContent: '',
loading: false,
error: null,
author: null, // 저자의 이름을 가져오기 위해
alert: true // 이것을 사용하지 않으면 alert가 dismissible를 누르면 다시 나타나지 않음
};
},
computed: {
isAuthenticated() {
//return !!auth.currentUser;
return this.$store.state.auth.user;
}
},
async created() {
this.loading = true;
const postId = this.$route.params.id;
try {
const postRef = db.collection('posts').doc(postId);
//const postDoc = await db.collection('posts').doc(postId).get();
const postDoc = await postRef.get();
if (postDoc.exists) {
this.post = postDoc.data();
this.postContent = this.sanitizeContent(this.post.content);
this.loadComments(postId);
// author 정보를 가져오자.
// 전체 회원 정보는 이미 읽어 두었다.
const users = this.$store.state.auth.users;
this.author = users.find(user => user.id === this.post.userId);
const today = new Date().toISOString().split('T')[0];
let userId;
const user = this.$store.state.auth.user;
if (user) {
//userId = auth.currentUser.uid;
// 계정 등록된 user
userId = user.id;
} else {
// 계정 등록이 되지 않은 user
userId = this.getOrCreateAnonymousUserId();
}
const alreadyViewed = this.post.viewedBy.some(view => view.userId === userId && view.date === today);
if (!alreadyViewed) {
await postRef.update({
views: firebase.firestore.FieldValue.increment(1),
viewedBy: firebase.firestore.FieldValue.arrayUnion({ userId, date: today })
});
this.post.views += 1; // Update the local view count
}
} else {
this.error = { message: 'Post not found' };
}
} catch (error) {
this.error = error;
} finally {
this.loading = false;
}
},
methods: {
sanitizeContent(content) {
return sanitizeHtml(content.replace(/\n/g, '<br>'), {
allowedTags: ['b', 'i', 'em', 'strong', 'p', 'br'],
allowedAttributes: {}
});
},
async loadComments(postId) {
try {
const commentsSnapshot = await db.collection('posts').doc(postId).collection('comments').get();
this.comments = commentsSnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
} catch (error) {
this.error = error;
}
},
async addComment() {
// 댓글이 비었으면 저장하지 않습니다.
if(this.newComment === '') {
this.error = { message: '댓글 내용이 없습니다.' };
this.alert = true;
return;
}
//const postId = this.post.id;
const postId = this.$route.params.id;
//const userName = auth.currentUser.displayName;
const user = this.$store.state.auth.user;
const userName = user.name;
const comment = {
content: this.newComment,
userId: user.id,
userName,
createdAt: new Date(),
replies: []
};
try {
await db.collection('posts').doc(postId).collection('comments').add(comment)
.then(addedComment => {
// 추가된 댓글의 ID를 받아 넣는다.
comment.id = addedComment.id;
this.comments.push(comment);
});
this.newComment = '';
} catch (error) {
this.error = error;
}
},
async deleteComment(commentId) {
const postId = this.$route.params.id;
const commentRef = await db.collection('posts').doc(postId).collection('comments').doc(commentId);
//const commentRef = db.collection('comments').doc(commentId);
const commentDoc = await commentRef.get();
if (commentDoc.exists) {
const commentData = commentDoc.data();
if (commentData.replies.length === 0) {
await commentRef.delete();
this.comments = this.comments.filter(c => c.id !== commentId);
} else {
alert('Cannot delete a comment with replies.');
}
}
},
isCommentOwner(userId) {
return userId === this.$store.state.auth.user.id;
},
async addReply(commentId) {
if (!this.isAuthenticated) {
this.error = { message: 'Please log in to reply.' };
return;
}
if(this.newReply === '') {
this.error = { message: '답글 내용이 없습니다.' };
this.alert = true;
return;
}
//const postId = this.post.id;
//const userName = auth.currentUser.displayName;
const postId = this.$route.params.id;
const userId = this.$store.state.auth.user.id;
const userName = this.$store.state.auth.user.name;
const reply = {
id: uuidv4(), // 답글의 id 생성
userId,
content: this.newReply,
userName,
createdAt: new Date()
};
try {
const commentRef = db.collection('posts').doc(postId).collection('comments').doc(commentId);
await commentRef.update({
replies: firebase.firestore.FieldValue.arrayUnion(reply)
});
const comment = this.comments.find(comment => comment.id === commentId);
comment.replies.push(reply);
this.newReply = '';
this.replyingTo = null;
} catch (error) {
this.error = error;
}
},
isReplyOwner(userId) {
return userId === this.$store.state.auth.user.id;
},
async deleteReply(commentId, reply) {
const postId = this.$route.params.id;
//const commentRef = db.collection('comments').doc(commentId);
const commentRef = db.collection('posts').doc(postId).collection('comments').doc(commentId);
const commentDoc = await commentRef.get();
if (commentDoc.exists) {
const commentData = commentDoc.data();
const userId = this.$store.state.auth.user.id;
if (reply.userId === userId) {
const updatedReplies = commentData.replies.filter(r => r.id !== reply.id);
await commentRef.update({
replies: updatedReplies
});
const comment = this.comments.find(c => c.id === commentId);
if (comment) {
comment.replies = updatedReplies;
}
}
}
},
showReplyForm(commentId) {
this.replyingTo = commentId;
},
formatDate(date) {
if (date && date.toDate) {
return date.toDate().toLocaleString();
}
return '';
},
getOrCreateAnonymousUserId() {
let userId = localStorage.getItem('anonymousUserId');
if (!userId) {
userId = uuidv4();
localStorage.setItem('anonymousUserId', userId);
}
return userId;
},
goToUserPosts(userId) {
this.$router.push({ name: 'UserPosts', params: { id: userId } });
},
}
};
</script>
<style scoped>
.my-alert {
position: fixed;
left: 10%;
bottom: 30px;
margin: 20px 0;
}
#blog-link:hover {
color: blue;
cursor: pointer;
}
</style>
<!-- src/components/SubscribeButton.vue -->
<template>
<div>
<v-btn @click="toggleSubscription">
{{ isSubscribed ? 'Unsubscribe' : 'Subscribe' }}
</v-btn>
</div>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
import firebase from 'firebase/compat/app';
export default {
props: ['authorId', 'currentUser'],
data() {
return {
isSubscribed: false
};
},
async mounted() {
this.checkSubscription();
},
methods: {
async checkSubscription() {
//const userDoc = await db.collection('users').doc(this.currentUser.uid).get();
const userDoc = await db.collection('users').doc(this.currentUser.id).get();
if (userDoc.exists) {
const userData = userDoc.data();
if (userData.subscriptions && userData.subscriptions.includes(this.authorId)) {
this.isSubscribed = true;
} else if (!userData.subscriptions) {
await userDoc.ref.update({ subscriptions: [] });
}
}
},
async toggleSubscription() {
//const userRef = db.collection('users').doc(this.currentUser.uid);
const userRef = db.collection('users').doc(this.currentUser.id);
if (this.isSubscribed) {
await userRef.update({
subscriptions: firebase.firestore.FieldValue.arrayRemove(this.authorId)
});
} else {
await userRef.update({
subscriptions: firebase.firestore.FieldValue.arrayUnion(this.authorId)
});
}
this.isSubscribed = !this.isSubscribed;
}
}
};
</script>
12. src/views/ProfileView.vue
<!-- src/views/ProfileView.vue -->
<template>
<v-container>
<v-card v-if="user">
<v-card-title>User Profile</v-card-title>
<v-card-text>
<p><strong>이메일:</strong> {{ user.email }}</p>
<p><strong>이름:</strong> {{ user.name }}</p>
<p><strong>id:</strong> {{ user.id }}</p>
</v-card-text>
<v-card-text>
<v-btn color="red" @click="addGoogleAccount" dark>
<v-icon left>mdi-google</v-icon>
Google 계정 연동
</v-btn>
<v-alert v-if="error" type="error" dismissible @input="resetErrorMsg" class="my-alert">{{ error }}</v-alert>
</v-card-text>
</v-card>
<v-alert v-else type="info">No user is logged in.</v-alert>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
computed: {
...mapGetters('auth',['user', 'error'])
},
methods: {
...mapActions('auth',['addGoogleUid', 'resetError']),
async addGoogleAccount() {
await this.addGoogleUid();
},
resetErrorMsg() {
this.resetError();
}
}
};
</script>
<style scoped>
.my-alert {
margin: 20px 0;
}
</style>
13. src/views/RegisterView.vue
<!-- src/views/RegisterView.vue -->
<template>
<v-container>
<v-row>
<v-col cols="12" class="text-center my-5">
<h3>계정 만들기</h3>
</v-col>
</v-row>
<v-row>
<v-col class="text-center" cols="10" offset="1" sm="8" offset-sm="2">
<v-form @submit.prevent="userRegister">
<v-text-field v-model="email" label="이메일" type="email" required></v-text-field>
<v-text-field v-model="name" label="이름" type="text" required></v-text-field>
<v-text-field v-model="password" label="비밀번호" type="password" required></v-text-field>
<v-text-field v-model="confirmPassword" name="confirmPassword" label="비밀번호 확인" type="password" required :rules="[comparePassword]"></v-text-field>
<v-progress-circular v-if="isLoading" indeterminate :width="7" :size="70" color="grey lighten-1"></v-progress-circular>
<v-btn type="submit" color="primary">Register</v-btn>
<v-alert v-if="error" type="error" dismissible>{{ error }}</v-alert>
</v-form>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
data() {
return {
email: '',
name: '',
password: '',
confirmPassword: ''
};
},
computed: {
...mapGetters('auth', ['error', 'isLoading']),
comparePassword() {
if(this.password == this.confirmPassword) return true;
else return "비밀번호가 일치하지 않습니다.";
},
},
methods: {
...mapActions('auth', ['register']),
async userRegister() {
await this.register({ email: this.email, password: this.password, name: this.name });
}
}
};
</script>
14. src/views/SearchPosts.vue
<!-- src/views/SearchPosts.vue -->
<template>
<div>
<v-text-field
v-model="searchQuery"
label="Search Posts"
@keyup.enter="searchPosts"
></v-text-field>
<v-btn @click="searchPosts">Search</v-btn>
<v-list v-if="searchResults.length">
<v-list-item v-for="post in searchResults" :key="post.id" @click="viewPost(post.id)">
<v-list-item-content>
<!-- eslint-disable -->
<v-list-item-title v-html="highlight(post.title)"></v-list-item-title>
<v-list-item-subtitle v-html="highlight(post.content)"></v-list-item-subtitle>
<!-- eslint-enable -->
</v-list-item-content>
</v-list-item>
</v-list>
<v-alert v-if="!searchResults.length && searchPerformed" type="info">
No posts found.
</v-alert>
</div>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
export default {
data() {
return {
searchQuery: '',
searchResults: [],
searchPerformed: false
};
},
methods: {
async searchPosts() {
if (!this.searchQuery.trim())
return;
// Fetch all posts and filter on client-side
const querySnapshot = await db.collection('posts').get();
this.searchResults = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
})).filter(post =>
post.title.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
post.content.toLowerCase().includes(this.searchQuery.toLowerCase())
);
this.searchPerformed = true;
},
viewPost(postId) {
this.$router.push({ name: 'PostDetailView', params: { id: postId } });
},
highlight(text) {
const regex = new RegExp(`(${this.searchQuery})`, 'gi');
return text.replace(regex, '<span class="highlight">$1</span>');
}
}
};
</script>
<style>
.highlight {
background-color: yellow;
}
</style>
15. src/views/SubscribedPosts.vue
<!-- src/views/SubscribedPosts.vue -->
<template>
<v-container>
<v-progress-circular v-if="loading" indeterminate></v-progress-circular>
<v-card v-if="!loading">
<v-card-title>구독</v-card-title>
<v-list>
<v-list-item v-for="post in subscribedPosts" :key="post.id" @click="viewPost(post.id)">
<v-list-item-content>
<v-list-item-title>{{ post.title }}</v-list-item-title>
<v-list-item-subtitle>{{ post.content }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card>
</v-container>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
export default {
data() {
return {
loading: false,
user: null,
subscribedPosts: []
};
},
async created() {
this.user = this.$store.state.auth.user;
this.fetchSubscribedPosts();
},
methods: {
async fetchSubscribedPosts() {
this.loading = true;
//const userDoc = await db.collection('users').doc(this.$store.state.user.uid).get();
const userDoc = await db.collection('users').doc(this.user.id).get();
if (userDoc.exists) {
const subscriptions = userDoc.data().subscriptions;
if (subscriptions.length) {
//const querySnapshot = await db.collection('posts').where('authorId', 'in', subscriptions).get();
const querySnapshot = await db.collection('posts').where('userId', 'in', subscriptions).get();
this.subscribedPosts = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
}
}
this.loading = false;
},
viewPost(postId) {
//this.$router.push({ name: 'PostDetail', params: { id: postId } });
this.$router.push({ name: 'PostDetailView', params: { id: postId } });
}
}
};
</script>
16. src/views/SubscribersList.vue
<!-- src/views/SubscribersList.vue -->
<template>
<v-card>
<v-card-title>Subscribers</v-card-title>
<v-list>
<v-list-item v-for="subscriber in subscribers" :key="subscriber.id" @click="goToUserPosts(subscriber.id)">
<v-list-item-content>
<v-list-item-title>{{ subscriber.name }}</v-list-item-title>
<v-list-item-subtitle>{{ subscriber.email }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
export default {
data() {
return {
subscribers: []
};
},
async created() {
this.fetchSubscribers();
},
methods: {
async fetchSubscribers() {
//const userUid = this.$store.state.user.uid;
const userId = this.$store.state.auth.user.id;
const querySnapshot = await db.collection('users').where('subscriptions', 'array-contains', userId).get();
this.subscribers = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
},
goToUserPosts(userId) {
this.$router.push({ name: 'UserPosts', params: { id: userId } });
},
}
};
</script>
17. src/App.vue
<!--src/App.vue -->
<template>
<v-app>
<v-navigation-drawer app>
<v-list>
<v-list-item link>
<router-link to="/">Home</router-link>
</v-list-item>
<v-list-item link v-if="user">
<router-link to="/write-post">글쓰기</router-link>
</v-list-item>
<v-list-item link v-if="user">
<router-link to="/my-posts">나의 글</router-link>
</v-list-item>
<v-list-item>
<router-link link to="/search-posts">글 찾기</router-link>
</v-list-item>
<v-list-item link v-if="user">
<router-link to="/subscribed-posts">구독</router-link>
</v-list-item>
<v-list-item link v-if="user">
<router-link to="/subscriber-list">독자</router-link>
</v-list-item>
<v-list-item link v-if="!user">
<router-link to="/login" >로그인</router-link>
</v-list-item>
<v-list-item link v-if="!user">
<router-link to="/register">계정 만들기</router-link>
</v-list-item>
<v-list-item link v-if="user">
<router-link to="/profile">계정 정보</router-link>
</v-list-item>
<v-list-item link>
<router-link to="/about">About</router-link>
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-app-bar app>
<v-toolbar-title>My Blog App</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="doLogout" v-if="user">
<v-icon>mdi-logout</v-icon>
</v-btn>
</v-app-bar>
<v-main>
<router-view></router-view>
</v-main>
</v-app>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'App',
computed: {
...mapGetters('auth', ['user'])
},
methods: {
...mapActions('auth', ['logout']),
doLogout() {
this.logout()
}
}
};
</script>