✒️

簡易ブログ作ってみた!パート1(Nuxt.js & Firebase & Tailwind CSS)

2022/01/05に公開

はじめに

Nuxt.js のアウトプットとしてFirebaseと連携し, 簡単なブログを作成する手順をまとめました。
この記事ではfirestoreにデータ保存, firestoreからのデータ取得この2点の達成を目指します!

今回の実例はgithubにて管理しております。是非御覧になってください🙌
https://github.com/tuboihirokidesu/nuxt-blog


Firebaseとの連携

firebaseとの連携を図るために, 必要なモジュールを導入します。

ターミナル
yarn add firebase @nuxtjs/firebase

以下の画像に従って, FirebaseConfigをコピーします。

コピーした後, エディタに .env ファイルを作成し貼り付けます。

API_KEY='~~~',
AUTH_DOMAIN='~~~',
PROJECT_ID='~~~'
STORAGE_BUCKET='~~~',
MESSAGING_SENDER_ID='~~~',
APP_ID='~~~',
MEASUREMENT_ID='~~~',

コピペした値をnuxt.config.tsmodulesに以下のように記述します。

nuxt.config.ts
import { NuxtConfig } from '@nuxt/types'
const config: NuxtConfig = {

  ...

  modules: [
    [
      '@nuxtjs/firebase',
      {
        config: {
          apiKey: process.env.API_KEY,
          authDomain: process.env.AUTH_DOMAIN,
          projectId: process.env.PROJECT_ID,
          storageBucket: process.env.STORAGE_BUCKET,
          messagingSenderId: process.env.MESSAGING_SENDER_ID,
          appId: process.env.APP_ID,
          measurementId: process.env.MEASUREMENT_ID,
        },
        services: {
          auth: true,
          firestore: true,
        },
      },
    ],
  ],
  
  ...
  
}

export default config

以上で, Firebaseと連携が出来ました!


ベースとなるコード

Headerは常に最上部に置いておきたいので, layouts/default.vueを作成します。

layouts/defaut.vue
<template lang="">
  <div>
    <TheHeader @sidenavToggle="displaySidenav = !displaySidenav" />
    <nuxt />    <!-- ここに各vueファイルが入ります -->
  </div>
</template>

<script lang="ts">
import TheHeader from '@/components/Navigation/TheHeader.vue'
import Vue from 'vue'

export default Vue.extend({
  components: {
    TheHeader,
  },
  data() {
    return {
      displaySidenav: false,
    }
  },
})
</script>

TheHeaderCustomButtonのコードはトグルボタンに置いておきます。

TheHeader.vue
<template lang="">
  <div class="h-16">
    <header class="the-header">
      <TheSideNavToggle @toggle="$emit('sidenavToggle')" />
      <div class="ml-10 font-mono text-2xl font-bold text-white">
        <nuxt-link to="/">Nuxt BLOG</nuxt-link>
      </div>
      <div class="spacer"></div>
      <CustomButton
        @click="$router.push('/admin/new-post')"
        class="mr-10 font-mono font-bold text-white"
        >New Blog</CustomButton
      >
    </header>
  </div>
</template>

<script lang="ts">
import TheSideNavToggle from '@/components/Navigation/TheSideNavToggle.vue'
import CustomButton from '@/components/UI/CustomButton.vue'
import Vue from 'vue'

export default Vue.extend({
  name: 'TheHeader',
  components: {
    TheSideNavToggle,
    CustomButton,
  },
})
</script>

<style scoped>
.the-header {
  width: 100%;
  position: fixed;
  height: 60px;
  display: flex;
  justify-content: space-around;
  align-items: center;
  background-color: black;
  z-index: 100;
  box-sizing: border-box;
  padding: 0 20px;
}
.spacer {
  flex: 1;
}
</style>

CustomButton.vue
<template lang="">
  <button
    class="px-4 py-2 font-semibold border rounded"
    v-bind="$attrs"
    v-on="$listeners"
  >
    <slot />
  </button>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'CustomButton',
  props: {
    btnStyle: {
      type: String,
      default: '',
    },
  },
})
</script>

これでHeaderが最上部に固定されます。


Firestoreにデータ保存

まずは状態管理のパッケージであるVuexを用いてBlogの情報(author,titile,thumbnailLink,content)を管理していきます。

Nuxt.jsではstoreディレクトリに, Vuexストアのファイルが格納します。
VuexストアはNuxtに最初から付属していますが, デフォルトでは無効になっています。このディレクトリにindex.tsファイルを作成すると, ストアが有効になります。

store/index.ts
import Vuex from 'vuex'
import { ActionContext } from 'vuex/types'

import { Blog, Id } from '../types/index'
import firebase from 'firebase/compat/app'

export const state = () => ({})
export type RootState = ReturnType<typeof state>

const createStore = () => {
  return new Vuex.Store({
    state: {
      loadedPosts: [] as Blog[],
    },
    mutations: {
      setPosts(state, posts) {
        state.loadedPosts = posts
      },
      addPost(state, post: Blog) {
        state.loadedPosts.push(post)
      },
    },
    actions: {
      async addPost(vuexContext, post: Blog) {
        const createdPost = {
          ...post,
          updatedDate: new Date(),
        }
        return await firebase
          .firestore()
          .collection('blog')
          .add({
            ...post,
            updatedDate: new Date(),
          })
          .then(
            (result) =>
              vuexContext.commit('addPost', { ...createdPost, id: result.id }) //result.idには一意なID
          )
          .catch((e) => console.log(e))
      },
    },
  })
}

export default createStore

ポイント

  • stateにloadedBlogというブログの情報(author,titile,thumbnailLink,content)を管理
  • mutationsはStateを更新するメソッド。非同期処理でなければならない。
  • actionsはMutationsを介して, Stateを更新するメソッド。同期処理である必要がある。
  • collection('~').add({})によってドキュメントIDを自動的に生成(任意のドキュメントIDを付与する場合はcollection("~").doc("~").set({}))

その後,New Blog(CustomButton)を押すした後の遷移先(ブログ作成ページ)を作っていきます。

pages/admin/new-post/index.vue
<template lang="">
  <section class="w-2/5 mx-auto my-5">
    <AdminPostForm @submit="onSubmitted" />
  </section>
</template>

<script lang="ts">
import Vue from 'vue'
import AdminPostForm from '@/components/Admin/AdminPostForm.vue'
import { Blog } from '../../../types/index'

export default Vue.extend({
  components: {
    AdminPostForm,
  },
  methods: {
    onSubmitted(postData: Blog) {
      this.$store.dispatch('addPost', postData).then(() => { 
        this.$router.push('/')
      })
    },
  },
})
</script>

ポイント

onSubmittedについて

  • storeのactions(addPost)を呼び出す
  • addPostは非同期処理だったため, 呼び出したその後の処理(pages/index.vueに画面遷移)はthenで繋ぐ
自分用メモ
  • actionsを呼び出すときはstore.dispatch('')で呼び出す必要がある
  • mutationsを呼び出すときはstore.commit('')で呼び出す必要がある
  • この2点の違いは同期処理(mutaitions)か非同期処理(actions)かの違い

v-on(@)について

  • onSubmittedメソッドをsubmitイベントリスナをバインド(縛る)

AdminPostFormのコードは以下のトグルボタンに置いておきます。
ポイントとなる箇所についてはコメントアウトしています。

AdminPostForm.vue
<template lang="">
  <!-- submitイベントは<form>で発生する -->
  <!-- submitイベントが本来, action属性での画面遷移を想定しているので, 意図しない画面遷移を避けるためにpreventを使う -->
  <form @submit.prevent="onSave" class="text-white">
    <div class="flex justify-between mt-4 space-x-4">
      <AppControlInput class="w-1/2" v-model="editedPost.author"
        >Author Name</AppControlInput
      >
      <AppControlInput class="w-1/2" v-model="editedPost.title"
        >Title</AppControlInput
      >
    </div>
    <AppControlInput v-model="editedPost.thumbnail"
      >Thumbnail Link</AppControlInput
    >
    <AppControlInput control-type="textarea" v-model="editedPost.content"
      >Content</AppControlInput
    >
    <div class="flex justify-end">
      <CustomButton type="submit" class="mr-5 text-white bg-blue-700"
        >Save</CustomButton
      >
      <CustomButton
        type="button"
        btn-style="red"
        class="bg-red-700"
        @click="onCancel"
        >Cancel</CustomButton
      >
    </div>
  </form>
</template>

<script lang="ts">
import AppControlInput from '@/components/UI/AppControlInput.vue'
import CustomButton from '@/components/UI/CustomButton.vue'
import Vue from 'vue'
import useRoute from 'vue-router'

export default Vue.extend({
  components: {
    AppControlInput,
    CustomButton,
  },
  props: {
    post: {
      type: Object,
      required: false,
    },
  },
  data() {
    return {
      editedPost: this.post
        ? { ...this.post }
        : {
            author: '',
            title: '',
            thumbnail: '',
            content: '',
          },
    }
  },
  methods: {
    onSave() {
      this.$emit('submit', this.editedPost)
    },
    onCancel() {
      // Navigate back
      this.$router.push('/')
    },
  },
})
</script>

画面は以下のとおりです

ブログ情報を入力していきます。

入力後Saveを押すとfirestoreにデータが保存されました!


Firestoreからデータ取得

まず, storeを通してFirestoreからデータ取得するロジックを書いていきます。

store/index.ts
import Vuex from 'vuex'
import { ActionContext } from 'vuex/types'

import { Blog, Id } from '../types/index'
import firebase from 'firebase/compat/app'

export const state = () => ({})
export type RootState = ReturnType<typeof state>

const createStore = () => {
  return new Vuex.Store({
    state: {
      loadedPosts: [] as Blog[],
    },
    mutations: {
+     setPosts(state, posts) {
+       state.loadedPosts = posts
+     },
      
      ...
      
    },
    actions: {
+     async nuxtServerInit(
+       vuexContext: ActionContext<RootState, RootState>,
+       context
+     ) {
+       const data: Blog[] = []
+       return await firebase
+         .firestore()
+         .collection('blog')
+         .get()
+         .then((res) => {
+           res.forEach((doc) => {
+             data.push(doc.data() as Blog)
+           })
+           vuexContext.commit('setPosts', data)
+         })
+         .catch((e) => context.error(e))
+     },
      getters: {
        loadedPosts(state) {
          return state.loadedPosts
        },
      },
    },
  })
}

export default createStore

ポイント

  • nuxtServerInitはサーバーサイドで実行されるVuexアクション(クライアントサイドに直接渡したいデータがサーバ上にある場合に便利)
  • このnuxtServerInitでFirestoreのデータを取得し, このデータをmutationsをコミット(setPostsを通してloadedPostsに格納)
  • gettersを使うことでデータを加工して提供する

この記事ではpages/index.vueに各々のブログをグリッドカードで表示します。

pages/index.vue
<template lang="">
  <div>
    <section
      :style="{ backgroundImage: 'assets/images/main-page-background.jpg' }"
    >
    <PostList :posts="loadedPosts" />
    </section>
  </div>
</template>

<script lang="ts">
import PostList from '@/components/Posts/PostList.vue'
import Vue from 'vue'

export default Vue.extend({
  components: {
    PostList,
  },
  computed: {
    loadedPosts() {
    //vuexで状態管理している`loadedPosts`をview側で呼び出す
      return this.$store.getters.loadedPosts
    },
  },
})
</script>
PostList.vue
<template lang="">
  <section
    class="grid content-center grid-cols-1 gap-5 p-10 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
  >
    <PostPreview
      v-for="post in posts"
      :key="post.id"
      :id="post.id"
      :is-admin="isAdmin"
      :thumbnail="post.thumbnail"
      :title="post.title"
      :content="post.content"
    />
  </section>
</template>

<script lang="ts">
import PostPreview from '@/components/Posts/PostPreview.vue'
import Vue from 'vue'

export default Vue.extend({
  components: {
    PostPreview,
  },
  props: {
    isAdmin: {
      type: Boolean,
      default: false,
    },
    posts: {
      type: Array,
      required: true,
    },
  },
})
</script>

<style scoped>
.post-list {
  display: flex;
  padding: 20px;
  box-sizing: border-box;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
}
</style>
PostPreview.vue
<template lang="">
  <nuxt-link :to="postLink" class="overflow-hidden rounded shadow-lg w-80">
    <article>
      <div
        class="w-full bg-center bg-cover h-52"
        :style="{ backgroundImage: 'url(' + thumbnail + ')' }"
      ></div>
      <div class="px-6 py-4">
        <div class="mb-2 text-xl font-bold">{{ title }}</div>
        <p class="text-base text-gray-700 truncate">{{ content }}</p>
      </div>
    </article>
  </nuxt-link>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'PostPreview',
  props: {
    id: {
      type: String,
      required: true,
    },
    isAdmin: {
      type: Boolean,
      required: true,
    },
    title: {
      type: String,
      required: true,
    },
    content: {
      type: String,
      required: true,
    },
    thumbnail: {
      type: String,
      required: true,
    },
  },
  //NOTE: pages/admin/index.vueにて<PostList isAdmin />をつけている。
  computed: {
    postLink() {
      return this.isAdmin ? '/admin/' + this.id : '/posts/' + this.id
    },
  },
})
</script>

<style scoped>
.post-thumbnail {
  width: 100%;
  height: 200px;
  background-position: center;
  background-size: cover;
}
</style>

以上にて,ブログを作成するとカードとしてブログが作成されるフローが実装できました!

各々のブログにIDを付与

ブログのカードをタップすると詳細ページに遷移する処理をPostPreview.vuepostLinkに書いていますが,
現状, 各ブログにはidが付与されていないため, ブログの詳細ページに遷移した時のURLがhttp://localhost:3000/posts/undifinedとなっています。

ログにも警告を出してくれています。

ここではFirestoreのドキュメントIDを(FirestoreのフィールドにIDを追加せずに)各ブログ情報に添えていきます。
nuxtServerInitに追記していきます。

store/index.ts(nuxtServerInit)
const createStore = () => {

    ...
    
    actions: {
      async nuxtServerInit(
        vuexContext: ActionContext<RootState, RootState>,
        context
      ) {
+       const data: (Blog & Id)[] = []
        return await firebase
          .firestore()
          .collection('blog')
          .get()
          .then((res) => {
            res.forEach((doc) => {
+             const obj = {
+               ...(doc.data() as Blog),
+               id: doc.id,
+             }
+             data.push(obj)
            })
            vuexContext.commit('setPosts', data)
          })
          .catch((e) => context.error(e))
      },
      
      ...
      
}

export default createStore

以上の対応で, ブログの詳細ページに遷移した際のURLにFirestoreのドキュメントIDが付与されました。
http://localhost:3000/posts/5OecS93IpTDSyjuEj5aX


以上になります。

パート2では,ブログの編集 & 削除機能
パート3では,firebase authを用いた認証機能

↑こちらを実装しようと検討しています!


最後に, 現在働かせて頂いております会社のご紹介です🙌

https://driglo.net/

株式会社ドリグロでは新しい仲間となるエンジニアを募集しております。

こちらから以下の内容とともにお気軽にご応募ください。

①どの勤務形態(正社員・アルバイト・業務委託など)に興味があるか

②対応言語と各経験年数

③生年月日・最終学歴

Discussion