토이 프로젝트 - Vue, Firebase로 서버리스 PWA 개발

16. Vue(with Vuetify)와 Firebase로 서버리스 PWA myBlog 개발 - 홈페이지

그랜파 개발자 2025. 2. 20. 04:23

홈페이지 - 글 전체 보기

새글을 쓰고 저장을 하였습니다.
글을 쓰는 것은 보기 위해서이니 볼 수 있는 방법을 만들어야 합니다.

앱이 열릴 때 로그한 모든 글을 목록으로 화면에 나타내어야 하고,
글의 정렬 방법의 선택에 따라 정렬하여 목록을 나타냅니다.
목록의 각 글에는 글의 카테고리, 제목, 내용, 글쓴이, 글쓴 날짜와 시간 등이 나타납니다.

myBlog에 접속을 하면 처음으로 나타나는 페이지를 홈페이지라고 합시다.

 

1. 홈페이지 요구 사항

보통은 글의 제목과 글 내용 일부를 목록으로 보여주고,
목록에서 글을 선택하면 글의 모든 내용을 보여주는 시퀀스를 가집니다.

그러나 myBlog는 글의 목록에 글의 제목과 모든 내용을 볼 수 있습니다.
즉 웹툰처럼 화면을 위로 스크롤시켜 페이지를 바꾸지 않고 연속적으로 글을 볼 수 있도록 합니다.
그럼 댓글과 답글은 어떻게 하느냐구요? 그건 홈페이지를 만들고 난 후에 생각하지요

2. 홈페이지 기능 정의

등록된 모든 글의 목록을 보여주기 위해 더 필요한 기능들이 무엇인지 생각하여 봅시다.

홈페이지는 로그인과 관계없이 누구나 접속이 가능합니다.
앱이 열리면 등록된 글을 모드 상태 변수 posts에 로드합니다.
홈페이지가 열리면 상태 변수 posts에 로드한 글의 전체 목록을 보여줍니다.

화면의 글들은 v-card로 제목과 내용을 목록으로 보여줍니다.
글을 최신순, 오래된 순으로 정렬하여 볼 수 있습니다.
특정 회원의 글을 선택하여 볼 수 있습니다. 블로그 기능입니다.

3. 홈페이지 기능 분석

1. 등록된 모든 글 로드하기

등록된 모든 글의 로드는 앱에 접속할 때 모든 글을 로드하여 state의 posts 변수에 저장합니다.

 
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);
  },

2. 로드한 글 목록으로 화면에 보이기

제목, 내용, 사용자이름, 글 등록한 날짜와 시간, 조회수

3. 글 목록을 최신순, 오래된순으로 정렬하여 보기

글 목록의 정렬은 firebase의 정렬 함수 orderBy를 사용하여 createdAt 항목을 desc 또는 asc로 정렬하여 로드하면 됩니다.

  • 최신순
  // 전체 글 가져오기 - 최신순
  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);
  },

4. 글 내용을 화면에 출력할 때 안전한 HTML로 변경하여 출력하기

sanitize-html 라이브러리의 sanitizeHtml 함수를 사용합니다.

 
sanitizeContent(content) {
    return sanitizeHtml(content.replace(/\n/g, '<br>'), {
        allowedTags: ['b', 'i', 'em', 'strong', 'p', 'br'],
        allowedAttributes: {}
    });
},

5. 글 목록에서 글쓴이 클릭하면 글쓴이 블로그로 이동하기

이것은 사용자별 블로그 기능을 구현할 때 같이 구현합니다.

6. 글을 선택하면 글 상세 보기로 이동하기

이것은 글 상세보기 기능 구현에서 구현합니다.

store의 post 모듈

// 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: [],        // 륵목록
};

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);
  },
};

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);
  },
};

const getters = { 

};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
};

HomeView.vue

<!-- src/views/HomeView.vue -->
<template>
  <v-container>

    <div class="mt-4 text-center">
      <v-progress-circular v-if="loading" indeterminate></v-progress-circular>
    </div>  

    <v-row class="mt-n5">
      <v-spacer></v-spacer>
      <v-col >
        <v-btn text @click="viewByDateDesc">최신순</v-btn> 
        <v-btn text @click="viewByDateAsc">오래된순</v-btn>
      </v-col>
    </v-row>

    <v-row>
      <v-col v-for="post in posts" :key="post.id" @click="viewPost(post.id)" cols="12">
        <v-card>
          <!--카테고리-->
          <v-card-text class="mb-n5" v-if="post.category" style="font-size:1em">
            {{ post.category }}
          </v-card-text>
          <v-card-text  class="mb-n5" v-else style="font-size:1em">
            카테고리 없음
          </v-card-text>

          <v-card-title style="font-size:1em">{{ post.title }}</v-card-title>
          <!-- eslint-disable -->
          <v-card-text style="font-size:1em" class="mt-n2 mb-n6" v-html="sanitizeContent(post.content)"></v-card-text>
          <!-- eslint-enable -->
          <v-card-subtitle>
            <b>댓글</b> <span v-if="post.commentCount > 0"> {{ post.commentCount }} </span> &nbsp;        
            {{ post.userName }} . {{ formatDate(post.createdAt) }}
          </v-card-subtitle>
        </v-card>
      </v-col>
    </v-row>      
  </v-container>
</template>

<script>
import { mapActions, mapState } from "vuex";
import sanitizeHtml from 'sanitize-html';

export default {
  name:'Home',
  data() {
    return {
    };
  },
  computed: {
    ...mapState('post',['posts', 'loading'])
  },
  async created() {

  },
  methods: {
    ...mapActions('post', ['fetchPosts', 'fetchPostsOrderByAsc']),
    viewPost(postId) {
      this.$router.push({ name: "Post", params: { id: postId } });
    },
    formatDate(date) {
      if (date && date.toDate) {
        return date.toDate().toISOString().replace('T', ' ').substring(0, 16);
      }
      return '';
    },
    // content를 안전한 html로 바꿔준다.
    sanitizeContent(content) {
      return sanitizeHtml(content.replace(/\n/g, '<br>'), {
        allowedTags: ['b', 'i', 'em', 'strong', 'p', 'br'],
        allowedAttributes: {}
      });
    },

    // 글 가져오기 - 최신순
    viewByDateDesc() { 
      this.fetchPosts();
    },
    // 글 가져오기 - 오래된 순
    viewByDateAsc() { 
      this.fetchPostsOrderByAsc();
    },
  },
};
</script>