PWA

ChatGPT와 함께 PWA Blog 개발 - 프로젝트

그랜파 개발자 2024. 8. 12. 19:32

51. My Blog 프로젝트 만들기

새로운 프로젝트를 만들어 계정만들기, 로그인 개발에서 개발한 각종 코드를 가져왔습니다. 그리고 블로그 게시물 관련 기능이 많이 추가될 것이므로 store 모듈을 분리하였습니다.

ChatGPT에게 물었습니다.

What if the store is divided into separate modules?

store를 별도 모듈로 나눈다면?

When dividing the Vuex store into separate modules, you organize your state management into more manageable pieces, each with its own state, mutations, actions, and getters. This modular approach helps in maintaining and scaling the application.
Vuex 스토어를 별도의 모듈로 나누면 상태 관리를 자체 상태, 변형, 작업 및 getter가 포함된 보다 관리하기 쉬운 부분으로 구성할 수 있습니다. 이 모듈식 접근 방식은 애플리케이션을 유지 관리하고 확장하는 데 도움이 됩니다.

Example of Vuex with Modules

Here’s how to set up and use Vuex with separate modules, and how to dispatch actions from those modules in the created() lifecycle hook within main.js.
별도의 모듈로 Vuex를 설정하고 사용하는 방법과 main.js 내의 created() 수명 주기 후크에 있는 해당 모듈에서 작업을 전달하는 방법은 다음과 같습니다.

Step-by-Step Example

  1. Setup Vuex Modules
  2. Dispatch Actions in main.js

1. Setup Vuex Modules

Create separate files for each module.
각 모듈에 대해 별도의 파일을 만듭니다.

 

store/modules/user.js

const state = {
  user: null
};

const mutations = {
  setUser(state, user) {
    state.user = user;
  }
};

const actions = {
  async fetchUser({ commit }) {
    // Simulate an API call to fetch user data
    const user = { id: 1, name: 'John Doe' };
    commit('setUser', user);
  }
};

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

 

store/modules/posts.js

const state = {
  posts: []
};

const mutations = {
  setPosts(state, posts) {
    state.posts = posts;
  }
};

const actions = {
  async fetchPosts({ commit }) {
    // Simulate an API call to fetch posts
    const posts = [
      { id: 1, title: 'First Post', content: 'This is the first post.' },
      { id: 2, title: 'Second Post', content: 'This is the second post.' }
    ];
    commit('setPosts', posts);
  }
};

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

 

store/index.js

import Vue from 'vue';
import Vuex from 'vuex';
import user from './modules/user';
import posts from './modules/posts';

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    user,
    posts
  }
});

2. Dispatch Actions in main.js

Dispatch the actions in the created() lifecycle hook.
created() 수명 주기 후크에서 작업을 전달합니다.

 

main.js

import Vue from 'vue';
import App from './App.vue';
import store from './store';
import vuetify from './plugins/vuetify'; // assuming you have Vuetify setup
import router from './router'; // assuming you have Vue Router setup

Vue.config.productionTip = false;

new Vue({
  store,
  vuetify,
  router,
  created() {
    // Dispatch actions from modules
    this.$store.dispatch('user/fetchUser');
    this.$store.dispatch('posts/fetchPosts');
  },
  render: h => h(App)
}).$mount('#app');

Explanation 설명

1. store/modules/user.js:

  • Created a user module with state, mutations, and actions.
    state, mutations 및 actions가 포함된 사용자 모듈을 만들었습니다.
  • Added namespaced: true to ensure the module is namespaced.
    모듈이 네임스페이스인지 확인하기 위해 'namespaced: true'를 추가했습니다.

2. store/modules/posts.js:

  • Created a posts module with state, mutations, and actions.
    state, mutations 및 actions가 포함된 게시물 모듈을 만들었습니다.
  • Added namespaced: true to ensure the module is namespaced.
    모듈이 네임스페이스인지 확인하기 위해 'namespaced: true'를 추가했습니다.

3. store/index.js:

  • Imported the user and posts modules and registered them in the Vuex store.
    사용자 및 게시물 모듈을 가져와서 Vuex 스토어에 등록했습니다.

4. main.js:

  • Imported necessary modules including Vue, the main App component, Vuex store, Vuetify, and Vue Router.
    Vue, 주요 앱 구성 요소, Vuex 스토어, Vuetify 및 Vue Router를 포함한 필수 모듈을 가져왔습니다.
  • Created a new Vue instance.
    새로운 Vue 인스턴스를 생성했습니다.
  • Dispatched fetchUser action from the user module and fetchPosts action from the posts module using the module namespaces in the created() lifecycle hook.
    created() 수명 주기 후크의 모듈 네임스페이스를 사용하여 user 모듈에서 fetchUser 작업을 전달하고 posts 모듈에서 fetchPosts 작업을 전달했습니다.

Additional Considerations

  • Error Handling: You might want to add error handling in the actions to manage any potential issues during data fetching.
    오류 처리: 데이터를 가져오는 동안 발생할 수 있는 문제를 관리하기 위해 작업에 오류 처리를 추가할 수 있습니다.
  • Loading State: Implement loading states in your Vuex modules to indicate when data is being fetched, and show a loading indicator in your UI.
    로드 상태: Vuex 모듈에 로드 상태를 구현하여 데이터를 가져오는 시기를 나타내고 UI에 로드 표시기를 표시합니다.

By organizing your Vuex store into modules and dispatching actions from these modules, you maintain a clean and scalable state management system, which initializes the state properly before the application is fully rendered.

Vuex 저장소를 모듈로 구성하고 이러한 모듈에서 작업을 전달함으로써 애플리케이션이 완전히 렌더링되기 전에 상태를 적절하게 초기화하는 깔끔하고 확장 가능한 상태 관리 시스템을 유지 관리할 수 있습니다.

My Blog 개발

1. 프로젝트 설정

vue create my-blog

? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, PWA, Router, Vuex
? Choose a version of Vue.js that you want to start the project with 2.x
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? (y/N) n

2. vuetify 설치

cd my-blog

vue add vuetify

? Choose a preset: (Use arrow keys)
Vuetify 2 - Configure Vue CLI (advanced)
> Vuetify 2 - Vue CLI (recommended)
Vuetify 2 - Prototype (rapid development)
Vuetify 3 - Vite (preview)
Vuetify 3 - Vue CLI (preview)

3. Firebase 설정

npm install firebase

 

store 모듈을 복사해서 수정하고 각종 View 컴포넌트들을 복사해 왔습니다.

4. Firebase 초기화:

5. Vuex store module

store/modules/auth.js 

 

store/index.js 

6. 웹앱 실행

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. 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 auth from '../store/modules/auth';

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'HomeView',
    component: HomeView
  },
  {
    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 isAuthenticated = auth.getters.isAuthenticated;
  if (requiresAuth && !isAuthenticated) {
    next('/login');
  } else {
    next();
  }
});

export default router;

3. 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
};

4. src/store/index.js

// src/store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import auth from './modules/auth';

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    auth
  }
});

5. 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="/login" >로그인</router-link>
        </v-list-item>
        <v-list-item link>
          <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>

6. src/views

HomeView.vue

<template>
  <v-container>
    <h1>Home Page</h1>
    <v-btn color="primary">Primary Button</v-btn>
  </v-container>
</template>

<script>
export default {
  name: 'Home'
};
</script>

LoginView.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']),
    ...mapActions('auth', ['googleLogin']),
    async loginUser() {
      await this.login({ email: this.email, password: this.password });
    }
  }
};
</script>

RegisterView.vue

<!-- src/views/Register.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>

ProfileView.vue

<!-- src/views/UserInfo.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>

AboutView.vue

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