PWA

ChatGPT와 함께 PWA Blog 개발 - my-blog Source

그랜파 개발자 2024. 8. 28. 15:03

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 }} &nbsp;&nbsp; {{ 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('&nbsp;', ' ');
    }
  }
};
</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 }} &nbsp;&nbsp; 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) }} &nbsp;&nbsp; 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>

11. src/components/SubscribeButton.vue

<!-- 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>