Nuxtと Firebaseで読書メモを登録するサイトを作ってみた

勉強を兼ねてNuxtとFirebaseで読書メモを登録するサイトを作成したので、そのときに得た知見や思ったことを書いてみます。

# 作ったもの
読書メモを登録できるサイト
https://dokusho-memo.web.app/

# TL;DR
- Firebase Authenticationが素晴らしい！面倒な認証系を全部firebaseに任せられる。これだけでもfirebase使う価値があると思う。
- Nuxt使うことでRouter/Storeが手軽に使える
- Vuetify使うとマテリアルデザインの画面が簡単に作れる

# 環境
- Nuxt 2.13.3
- Typescript
- Vuetify
- Firebase 7.15.5
  - Authentication
  - Hosting
  - FireStore
  - firebaseui 4.5.2
- vue-class-component 7.2.3
- vue-property-decorator 9.0.0

# ディレクトリ構成
```
├── components
│   ├── Login.vue
│   └── TooltipIconButton.vue
├── firebase.json
├── nuxt.config.js
├── package-lock.json
├── package.json
├── pages
│   ├── about.vue
│   ├── create.vue
│   ├── detail
│   │   └── _id.vue
│   ├── index.vue
│   └── mylist.vue
├── plugins
│   └── firebase.js
├── static
│   ├── favicon.ico
│   └── logo.png
├── store
│   └── README.md
└── tsconfig.json
```

# Firebase Authenticationで認証
Firebase Authenticationで認証を利用するにあたって実施した作業は以下の通り。

- Firebase Authenticationのコンソールで対象の認証(メール、google、twitterなど)を有効にする
- firebaseuiをインストールする
- firebaseuiを使った認証画面コンポーネントを作成する(Login.vue)

認証の画面生成に必要なfirebaseuiをインストール

```shell:firebaseuiインストール
npm install firebase-ui
npm install firebase-ui-ja
```

認証画面コンポーネント。
これだけでログイン、アカウント作成、パスワード変更などの機能が利用できる！

```vue:/components/Login.vue
<template>
  <div>
    <div id="firebaseui-auth-container" />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import firebase from '~/plugins/firebase'

@Component
export default class Login extends Vue {
  mounted () {
    firebase.auth().onAuthStateChanged((user) => {
      if (user) {
        return
      }

      // firebaseui-jaは更新が止まっていて微妙だが、
      // 日本語化できるメリットが大きいのと、使ってみた感じ特に問題もなさそうだったので利用する。
      // ただしボタンの色がつかなかったので、cssのみ"ja"ではないものを利用する
      const firebaseui = require('firebaseui-ja')
      require('firebaseui-ja/dist/firebaseui.css')
      // const firebaseui = require('firebaseui')
      const uiConfig = {
        callbacks: {
          signInSuccessWithAuthResult: () => {
            return true
          }
        },
        signInFlow: 'popup',
        signInSuccessUrl: '/',
        signInOptions: [
          firebase.auth.GoogleAuthProvider.PROVIDER_ID,
          // firebase.auth.FacebookAuthProvider.PROVIDER_ID,
          firebase.auth.TwitterAuthProvider.PROVIDER_ID,
          firebase.auth.EmailAuthProvider.PROVIDER_ID
        ]
      }
      const ui = firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(firebase.auth())
      ui.start('#firebaseui-auth-container', uiConfig)
    })
  }
}
</script>
```

ログアウト

```vue:/layout/header.vue
<template>
...
<v-btn @click="signout">ログアウト</v-btn>
...
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import firebase from '~/plugins/firebase'

@Component
export default class Header extends Vue {
  ...
  public signout () {
    firebase.auth().signOut().then(() => {
      // ログアウト成功時の処理
      this.$router.push('/')
    })
  }
}
</script>
```

Firebase初期化プラグイン。

```javascript:/plugins/firebase.js
import firebase from 'firebase/app'
import 'firebase/auth'
import 'firebase/firestore'

if (!firebase.apps.length) {
  firebase.initializeApp(
    {
      // FireStore設定
      apiKey: 'xxx',
      authDomain: 'xxx',
      databaseURL: 'xxx',
      projectId: 'xxx',
      storageBucket: 'xxx',
      messagingSenderId: 'xxx',
      appId: 'xxx',
      measurementId: 'xxx'
    }
  )
}
export default firebase
```

# Nuxt Middlewareで認証ページ制御
ログイン済みでないと開けないページの制御をNuxtのMiddlewareで実現する。

認証処理ミドルウェア。

```javascript:/middleware/authenticated.ts
import { Middleware } from '@nuxt/types'
import firebase from '~/plugins/firebase'

const authenticated: Middleware = (context) => {
  if (!firebase.auth().currentUser) {
    // 未ログインの場合はトップ画面へリダイレクト
    context.redirect('/')
  }
  // コールバックでリダイレクトすると先に画面表示が実行されてしまうので不可
  // firebase.auth().onAuthStateChanged((user) => {
  //   if (!user) {
  //     // 未ログインの場合はトップ画面へリダイレクト
  //     context.redirect('/')
  //   }
  // })
}

export default authenticated

```

認証制御したいページに作成した上記ミドルウェアを設定する。
これだけで未ログインの場合はトップ画面へリダイレクトされる。

```vue:/pages/mylist.vue
<template>
  ...
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'

@Component({ middleware: 'authenticated' })
export default class Mylist extends Vue {
  ...
}
</script>
```

# Vuetify
コンポーネントを利用するだけでマテリアルデザインのそれっぽい画面ができる。
テンプレートやコンポーネント例も豊富にあり、すごく有用だった。
https://vuetifyjs.com/ja/getting-started/quick-start/

- 位置合わせは`<v-spacer />`を使うことが多い
- marginやpaddingなどのスペースは`mx-2`や`py-4`などclassで設定する
- リンクボタンにしたい場合は`<v-btn to="/" nuxt/>`
- ツールチップボタンは下記コンポーネントを作成した

```vue:/components/TooltipIconButton.vue
<template>
  <v-tooltip bottom>
    <template v-slot:activator="{ on, attrs }">
      <v-btn
        color="primary"
        :class="buttonClasses"
        fab
        small
        v-bind="attrs"
        v-on="on"
        @click="$emit('click')"
      >
        <v-icon v-text="icon" />
      </v-btn>
    </template>
    <span v-text="tooltip" />
  </v-tooltip>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'

@Component
export default class TooltipIconButton extends Vue {
  @Prop(String) readonly icon: string | undefined
  @Prop(String) readonly tooltip: string | undefined
  @Prop(String) readonly buttonClasses: string | undefined
}
</script>

```

# Nuxt
https://typescript.nuxtjs.org/ja/guide/

- pagesディレクトリのVueファイルからルーティングが自動生成されるのはDRYでよかったと感じた
  - パラメータを使った動的ルーティングも可能
- storeは使わなかったがルーティング同様にメリットあると思う
- middlewareやpluginsなどディレクトリ構造の制約や指針が示されているのは良いと感じた
  - Vueだと共通機能をどこに置くべきかプロジェクトによってバラバラだったりするので
- SPAでfirebaseのhostingで動的ルーティングをする場合、rewrites設定で全てのurlを/index.htmlに向ける必要がある
  - https://qiita.com/misaosyushi/items/9693a5338eecc2d78144
  - https://firebase.google.com/docs/hosting/url-redirects-rewrites?hl=ja#section-rewrites

```json:firebase.json
"hosting": {
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
```

# vue-property-decorator

Nuxt & Typescriptでのコンポーネントの作り方は公式サイトには3通りの方法が示されている。
https://typescript.nuxtjs.org/ja/cookbook/components/

- Options API
- Composition API
- Class API

調べていて2020/7時点で情報が多かったClass API（vue-property-decorator）を利用した。
VueはClass APIからComposion API推奨に変わってきているので、将来的にはNuxtもComposition APIを使うのがいいのかもしれない。

# Typescript
- コンパイルエラーや警告で問題に気付けることが多かったため、使って良かったと感じた
- 動かすのを優先したのでanyで逃げたところもいくつかあった

# ユーザ名表示の改善点
- シンプルに作るのを優先したためFirebase Authenticationのdisplaynameをユーザ名の表示として利用しているが、ユーザが自由に変えられるようfirestoreにユーザ情報を作ったほうがいいかもしれない。（下記は参考例。動作検証はしていないのであくまでも参考イメージとして）

```text:firebaseのデータ構成
- authentication
  - uid(string型)
- firestore
  - items
    - createuser(reference型、usersコレクションを参照)
  - users
    - uid(string型、authenticationのuid)
    - uname(string型、表示名)
```

```text:firestoreのルール
match /items/{itemId} {
  allow read: if true;
  allow create: if request.auth != null;
  // createuserと一致するユーザのみが更新・削除できる
  allow update, delete: if request.auth.uid == get(resource.data.createuser).data.id;
  match /users/{userId} {
    allow read: if true;
    allow write: if false;
  }
}
    
match /users/{userId} {
  allow read: if true;
  allow create: if request.auth != null;
  // uidと一致するユーザのみが更新・削除できる
  allow update, delete: if request.auth != null && request.auth.uid == userId;
}
```
# 一覧画面
ここまでで記載した機能を使って作った一覧画面のソースは以下。

```vue:/pages/index.vue
<template>
  <div>
    <section id="index">
      <div class="py-4" />
      <v-responsive class="mx-auto mb-8">
        <p>
          読書した内容や感想を忘れないよう読書メモ/読書ノートを作ってみませんか。
        </p>
        <p>
          残したいメモを
          <span class="font-weight-bold">３点</span>
          に要約して登録できます。
        </p>
      </v-responsive>

      <Login />

      <v-row v-if="info">
        <v-spacer />
        <v-alert type="success" dense text>
          {{ info }}
        </v-alert>
        <v-spacer />
      </v-row>
    </section>

    <section id="list">
      <v-row>
        <v-col v-for="(item, i) in items" :key="i" cols="12" md="3">
          <v-card class="py-3 px-2 card" color="grey lighten-5" flat>
            <v-list-item>
              <v-list-item-content>
                <div class="mb-1">
                  <nuxt-link
                    :to="{ name: 'detail-id', params: { id: item.doc.id } }"
                    v-text="item.title"
                  />
                </div>
              </v-list-item-content>

              <v-img
                v-if="item.image"
                :src="item.image"
                height="80"
                max-width="50"
                class="ml-1"
              />
            </v-list-item>

            <v-list-item two-line>
              <v-list-item-content class="text-left">
                <v-list-item-subtitle>
                  ・<span v-text="item.text1" />
                </v-list-item-subtitle>
                <v-list-item-subtitle>
                  ・<span v-text="item.text2" />
                </v-list-item-subtitle>
                <v-list-item-subtitle>
                  ・<span v-text="item.text3" />
                </v-list-item-subtitle>
              </v-list-item-content>
            </v-list-item>
          </v-card>
        </v-col>
      </v-row>

      <TooltipIconButton
        v-if="items.length > 0"
        icon="mdi-chevron-down"
        tooltip="さらに表示"
        @click="searchMore"
      />
    </section>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import firebase from '~/plugins/firebase'
import Login from '~/components/Login.vue'
import TooltipIconButton from '~/components/TooltipIconButton.vue'

@Component({ components: { Login, TooltipIconButton } })
export default class Index extends Vue {
  itemSize = 12
  items: any[] = []

  get info (): string {
    return this.$route.params.info
  }

  get lastDoc () {
    return this.items[this.items.length - 1].doc
  }

  async created () {
    const query = this.createQuery().limit(this.itemSize)
    const snapshot = await query.get()
    const items = snapshot.docs.map((d) => { return { ...d.data(), doc: d } })
    this.items = items
  }

  public createQuery () {
    const db = firebase.firestore()
    return db.collection('memo').orderBy('date', 'desc')
  }

  public async searchMore () {
    const query = this.createQuery().startAfter(this.lastDoc).limit(this.itemSize)
    const snapshot = await query.get()
    const items = snapshot.docs.map((d) => { return { ...d.data(), doc: d } })
    this.items.push(...items)
  }
}
</script>

<style scoped>
.card {
  height: 100%;
}

.card-text p {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
  margin-bottom: 5px;
}

p {
  margin-bottom: 2px;
}

.card-text {
  text-align: left;
}
</style>
```

# まとめ
- Firebase Authenticationが本当に素晴らしかった！簡単に認証処理が作れた。
- VueよりもNuxtを使ったほうがビジネスロジックに集中できたので、特に明確な理由がなければNuxtを選択するのがよいと思う。
- デザインに詳しくない場合にVuetifyを使うのはとても有効だった。コンポーネントやテンプレートを置くだけでマテリアルデザインの画面が作成できた。NuxtではVuetify以外にも選択できるので、いろいろ試してみたい。