🔥

Nuxt + Firebaseでシンプルなカラーコード管理アプリを作成

19 min read

はじめに

今回はFirebaseを使ってみるために、簡単なカラーコードピッカーを作成してみました。制作期間は一週間ちょっとくらいです。初めてfirebaseやVuexをしっかり使ったので雑なところもありますがよろしくお願いします。

github

機能

至ってシンプルです。

・Google認証
・パレット毎のカラーコード管理
・カラーピッカーでcodeを自動生成
・パレットに登録したカラーコードをコピー

*ダッシュボード画面
スクリーンショット 2021-03-03 1.10.15.png

*パレット編集画面
スクリーンショット 2021-03-03 1.10.32.png

作成手順

今回は以下の手順で作成しました。

1. Firebaseの導入(Firebase JavaScript SDK)
2. Vuexでfirestoreの操作をするaction, mutationを作成
3. ページ、コンポーネントにて取得したデータを描画

Firebaseの導入

全て説明すると長くなりますので、簡潔に述べます。
参考記事は最後に載せておきますので、詳しいやり方はそちらでチェックしてみてください。

Firebase consoleにてプロジェクトを作成、databaseの紐付けをしたら、プロジェクトにFirebase SDKを入れます。

$ yarn add firebase save

Firebase consoleからプロジェクトにアプリを追加します。webを選択してHostingを設定したら、いろいろ指示が出てきますので、その通りに導入していきます。

$ yarn add firebase-tools

あとは指示通りにデプロイするだけです。指示はかなり親切に書かれているので困ることはないかと思います。

$ firebase deploy

Nuxt側でのFirebase設定

pluginsディレクトリの配下にfirebase.jsを作成して、コンソールから取得できる値を貼り付けます。

plugins/firebase.js
import firebase from 'firebase'

if (!firebase.apps.length) {
  firebase.initializeApp(
    {
      apiKey: process.env.apiKey,
      authDomain: process.env.authDomain,
      databaseURL: process.env.databaseURL,
      projectId: process.env.projectId,
      storageBucket: process.env.storageBucket,
      messagingSenderId: process.env.messagingSenderId,
      appId: process.env.appId,
    }
  )
}

export default firebase

これだけで設定完了です。

アプリ機能実装

Google認証を使ったログイン機能、一般的なCRUDを実装していきます。

ログイン機能

認証関係のStoreはauth.jsとして切り離しています。

store/modules/auth.js
import firebase from '~/plugins/firebase'
import Vuex from 'vuex'

const namespaced = true // モジュールを切り離して呼び出す時に必要

const state = () => ({
  userUid: '',
  userName: '',
  userImage: '',
  userEmail: '',
  loggedIn: false,
})

const mutations = {
  // ログイン状態の切替
  loginStatusChange(state, status) {
    state.loggedIn = status
  },
  // ここからユーザー情報をそれぞれ取得
  setUserUid(state, userUid) {
    state.userUid = userUid
  },
  setUserName(state, userName) {
    state.userName = userName
  },
  setUserImage(state, userImage) {
    state.userImage = userImage
  },
  setUserEmail(state, userEmail) {
    state.userEmail = userEmail
  }
}

const actions = {
  login({ commit }) {
    const provider = new firebase.auth.GoogleAuthProvider()
    firebase.auth().signInWithPopup(provider).then((result) => {
      const user = result.user
      commit('loginStatusChange', true)
      console.log('Login was successful')
      commit('setUserUid', user.uid)
      commit('setUserName', user.displayName)
      this.$router.push('/dashboard')
    }).catch((error) => {
      const errorCode = error.code
      console.log('error : ' + errorCode)
    });
  },
  // ログイン中のユーザー取得
  onAuth({ commit }) {
    firebase.auth().onAuthStateChanged(user => {
      user = user ? user : {}
      commit('setUserUid', user.uid)
      commit('setUserName', user.displayName)
      commit('setUserImage', user.photoURL)
      commit('setUserEmail', user.email)
      commit('loginStatusChange', user.uid ? true : false)
    })
  },
  logout({ commit }) {
    firebase.auth().signOut().then(() => {
      commit('loginStatusChange', false)
      console.log('Logout was successful')
      this.$router.push('/')
    }).catch((error) => {
      const errorCode = error.code
      console.log('error :' + errorCode)
    })
  },
}

const getters = {
  getUserUid(state) {
    return state.userUid
  },
  getUserName(state) {
    return state.userName
  },
  getUserImage(state) {
    return state.userImage
  },
  getUserEmail(state) {
    return state.userEmail
  },
}

export default {
  namespaced,
  state,
  mutations,
  actions,
  getters,
}

auth.jsで記述したactionをログイン・ログアウトボタンで呼び出せるようにします。
v-if="!$store.state.auth.loggedIn"でAuthストアを参照して、ログイン中ならログインボタンを表示しない・ログイン前ならログアウトボタンは表示されません。

components/beforeLogin/loginButton.vue
<template>
  <v-btn
    color="secondary"
    @click="login"
    v-if="!$store.state.auth.loggedIn"
    outlined
  >
    Login
  </v-btn>
</template>>

<script>
export default {
  methods: {
    login() {
      console.log('Login attempt')
      this.$store.dispatch('auth/login')
    },
  }
}
</script>
components/afterLogin/logoutButton.vue
<template>
  <div class='text-center'>
    <v-btn
      color="warning"
      @click="logout"
      v-if="$store.state.auth.loggedIn"
      block
      width="100px"
    >
      Logout
    </v-btn>
  </div>
</template>

<script>
export default {
  methods: {
    logout() {
      console.log('Logout attempt')
      const result = window.confirm('Do you want to logout?')
      if (result) {
        this.$store.dispatch('auth/logout')
      } else {
        console.log('Logout was canceled')
      }
    }
  }
}
</script>

これでログインボタンの実装が完了しました。認証時に取得できるuserからidやnameも抽出できるので、後に活用します。

パレットの作成・削除機能

auth.jsと同様にmoduleで切り離しています。actionやmutationの名前は機能そのままです。

store/modules/palette.js
import firebase from '~/plugins/firebase'
const db = firebase.firestore()
const palettesRef = db.collection('palettes')

const namespaced = true

const state = () => ({
  palettes: [], // ユーザーが所持しているパレット一覧
  paletteInfo: [], // パレットの詳細情報
})

const mutations = {
  getPalettes(state, palettes) {
    state.palettes = palettes
  },
  getPaletteInfo(state, paletteInfo) {
    state.paletteInfo = paletteInfo
  },
  addPalette(state, palette) {
    state.palettes.push(palette)
  },
}

const actions = {
  fetchPalettes({ dispatch, commit, rootState, rootGetters }) {
    const userUid = rootState.auth.userUid // rootから別モジュールのstateを参照
    // ログイン中のuserIDに結びついたパレットのみを取得
    palettesRef.where('user', '==', userUid).get().then((res) => {
      let palettesBox = []
      res.forEach((doc) => {
        const palette = doc.data()
        palettesBox.push(
          Object.assign({
           // firestoreで割り振られるdocumentIDも挿入しておく
            id: doc.id
          }, palette)
        )
      })
      commit('getPalettes', palettesBox)
    }).catch((error) => {
      console.log('error :' + error)
    })
  },
  fetchPaletteDetail({ commit }, id) {
    palettesRef.doc(id).get().then((doc) => {
      const paletteInfo = doc.data()
      commit('getPaletteInfo', paletteInfo)
    }).catch((error) => {
      console.log('error :' + error)
    })
  },
  addPalette({ commit }, palette) {
    palettesRef.add({
      name: palette.name,
      description: palette.description,
      user: palette.user
    }).then((decRef) => {
      commit('addPalette', palette)
    }).catch((error) => {
      console.log('Error adding palette :' + error)
    })
  },
  deletePalette({ dispatch, commit, rootState, rootGetters }, id) {
    palettesRef.doc(id).delete().then(() => {
    // 削除後に最新の状態を取得し直す
      dispatch('fetchPalettes')
      this.$router.push('/dashboard')
    }).catch((error) => {
      console.log('error :' + error)
    })
  },
}

const getters = {
  getPalettes(state) {
    return state.palettes
  },
  getPaletteInfo(state) {
    return state.paletteInfo
  }
}

export default {
  namespaced,
  state,
  mutations,
  actions,
  getters,
}

pages/newPalette.vue
<template>
  <v-app>
    <v-container>
      <palette-form-card v-slot:form-card-content>
        <v-form ref="form" v-model="isValid">
          <palette-name :name.sync="params.palette.name" />
          <palette-description :description.sync="params.palette.description" />
          <v-btn
            :disabled="!isValid || loading"
            :loading="loading"
            block
            color="primary"
            @click="addPalette"
          >
          Create palette
          </v-btn>
        </v-form>
      </palette-form-card>
    </v-container>
  </v-app>
</template>

<script>
import PaletteDescription from '~/components/paletteForm/paletteDescription.vue'
import PaletteFormCard from '~/components/paletteForm/paletteFormCard.vue'
import PaletteName from '~/components/paletteForm/paletteName.vue'

export default {
  middleware: 'authenticated',
  data() {
    return {
      isValid: false,
      loading: false,
      params: { palette: { name: '', description: '' } }
    }
  },
  methods: {
    addPalette() {
      this.loading = true
      const name = this.params.palette.name
      const description = this.params.palette.description
      const user = this.$store.state.auth.userUid
      setTimeout(() => {
        this.$store.dispatch('palette/addPalette', {name, description, user})
        this.$router.replace('/dashboard')
        this.loading = false
      }, 1500)
    }
  },
  created() {
    this.$store.dispatch('auth/onAuth')
  },
  components: {
    PaletteFormCard,
    PaletteName,
    PaletteDescription
  }
}
</script>

カラーの作成・削除

color.jsはパレットとほとんど構造は同じです。

store/modules/color.js
import firebase from '~/plugins/firebase'
const db = firebase.firestore();
const colorsRef = db.collection('colors')

const namespaced = true

const state = () => ({
  colors: [],
  paletteColors: [],
})

const mutations = {
  addColor(state, color) {
    state.colors.push(color)
  },
  getColors(state, colors) {
    state.colors = colors
  },
  getPaletteColors(state, paletteColors) {
    state.paletteColors = paletteColors
  }
}

const actions = {
  fetchColors({ dispatch, commit, rootState, rootGetters }) {
    const userUid = rootState.auth.userUid
    colorsRef.where('user', '==', userUid).get().then((res) => {
      let colorsBox = []
      res.forEach((doc) => {
        const color = doc.data()
        colorsBox.push(
          Object.assign({
            id: doc.id
          }, color)
        )
      })
      commit('getColors', colorsBox)
    }).catch((error) => {
      console.log('error : ' + error)
    })
  },
  fetchPaletteColors({ commit }, id) {
    const paletteId = id
  // パレットに紐づいているカラーコードを取得
    colorsRef.where('palette', '==', paletteId).get().then((res) => {
      let paletteColorsBox = []
      res.forEach((doc) => {
        const paletteColor = doc.data()
        paletteColorsBox.push(
          Object.assign({
            id: doc.id
          }, paletteColor)
        )
      })
      commit('getPaletteColors', paletteColorsBox)
    }).catch((error) => {
      console.log('error : ' + error)
    })
  },
  addColor({ commit }, color) {
    colorsRef.add({
      name: color.name,
      code: color.code,
      user: color.user,
      palette: color.palette,
    }).then((docRef) => {
      commit('addColor', color)
    }).catch((error) => {
      console.log("Error adding color: ", error)
    })
  },
  deletePaletteColor({ dispatch, commit, rootState, rootGetters }, id) {
    colorsRef.doc(id).delete().then(() => {
      dispatch('fetchColors')
      location.reload()
    }).catch((error) => {
      console.log('error :' + error)
    })
  }
}

const getters = {
  getColors(state) {
    return state.colors
  }
}

export default {
  namespaced,
  state,
  mutations,
  actions,
  getters,
}
components/color/colorInputForm.vue
<template>
  <v-app>
    <v-container>
      <color-form-card v-slot:form-card-content>
        <v-form ref="form" v-model="isValid">
          <color-name :name.sync="params.color.name" />
          <color-code :code.sync="params.color.code" />
          <color-picker :code.sync="params.color.code" />
          <v-btn
            :disabled="!isValid || loading"
            :loading="loading"
            block
            color="primary"
            @click="addColor"
          >
          Create color
          </v-btn>
        </v-form>
      </color-form-card>
    </v-container>
  </v-app>
</template>

<script>
import ColorFormCard from '~/components/colorForm/colorFormCard.vue'
import ColorName from '~/components/colorForm/colorName.vue'
import ColorCode from '~/components/colorForm/colorCode.vue'
import ColorPicker from '~/components/colorForm/colorPicker.vue'

export default {
  data() {
    return {
      isValid: false,
      loading: false,
      params: { color: { name: '', code: '' } }
    }
  },
  methods: {
    addColor() {
      this.loading = true
      const name = this.params.color.name
      const code = this.params.color.code
      const user = this.$store.state.auth.userUid
      const palette = this.$route.params.id
      this.$store.dispatch('color/addColor', {name, code, user, palette})
      setTimeout(() => {
        location.reload();
        this.loading = false
      }, 1500)
    }
  },
  created() {
    this.$store.dispatch('auth/onAuth')
  },
  components: {
    ColorFormCard,
    ColorName,
    ColorCode,
    ColorPicker,
  },
}
</script>

パレットとカラーは基本的にログイン中のuserID + 各々のID と照らし合わせながら取得・作成・削除される仕組みです。

store/index.js
import Vuex from 'vuex'
import auth from './modules/auth'
import color from './modules/color'
import palette from './modules/palette'

const createStore = () => {
  return new Vuex.Store({
    modules: {
      auth,
      color,
      palette,
    },
  })
}

export default createStore

リダイレクト

middlewareで簡単に、「ログイン状態でなければトップページにリダイレクト」とだけ記述しておきました。

middleware/authenticated.js
export default function ({ store, redirect }) {
  if (!store.state.auth.loggedIn) {
    return redirect('/')
  }
}

パレットの詳細画面作成

パレットの詳細画面(画像2枚目)の作成では、右にカラーピッカー、左にパレットが持つカラーを配置しています。

pages/palettes/_id.vue
<template>
  <v-app>
    <v-container>
      <v-alert
        color="blue"
        type="success"
        v-if="show === true"
      >
        Copied!
      </v-alert>
      <v-row cols="12">
        <v-col>
          <draggable tag="div" v-if="$store.state.color.paletteColors">
            <div
              v-for="paletteColor in $store.state.color.paletteColors"
              :key="paletteColor.paletteColor"
              :style="{backgroundColor: paletteColor.code, height: '50px', color: conversionToDecimal(paletteColor.code)? '#111' : '#fff'}"
              class="wrapper"
            >
              <div class="inner-name">
              {{ paletteColor.name }}
              </div>
              <div class="inner-code">
              {{ paletteColor.code }}
              </div>
              <button
                class="copy-button"
                @click="doCopy(paletteColor.code)"
              >
                <v-icon :style="{color: conversionToDecimal(paletteColor.code)? '#111' : '#fff'}">
                  mdi-content-copy
                </v-icon>
              </button>
              <button
                class="delete-button"
                @click="deletePaletteColor(paletteColor.id)"
              >
                <v-icon :style="{color: conversionToDecimal(paletteColor.code)? '#111' : '#fff'}">
                  mdi-delete
                </v-icon>
              </button>
            </div>
          </draggable>
        </v-col>
        <v-col>
          <color-input-form />
        </v-col>
      </v-row>
    </v-container>
  </v-app>
</template>

<script>
import colorInputForm from '~/components/colorForm/colorInputForm.vue'
import draggable from 'vuedraggable' // パレットのカラーをドラッグで並び替える

export default {
  middleware: 'authenticated',
  components: {
    colorInputForm,
    draggable,
  },
  data() {
    return {
      show: false
    }
  },
  created() {
    const paletteId = this.$route.params.id
    this.$store.dispatch('palette/fetchPaletteDetail', paletteId)
    this.$store.dispatch('color/fetchPaletteColors', paletteId)
  },
  methods: {
    // カラーコードを十進数に直す
    // 色が明るい場合(true)では黒文字でカラーコードを表示
    // 色が暗い場合(false)では白文字でカラーコードを表示
    conversionToDecimal(colorCode) {
      let red = parseInt(colorCode.substring(1,3), 16)
      let green = parseInt(colorCode.substring(3,5), 16)
      let blue = parseInt(colorCode.substring(5,7), 16)
      if (red + green + blue > 255) {
        return true
      }
    },
    // クリップボードにカラーコードをコピーする
    doCopy(colorCode) {
      this.$copyText(colorCode)
      this.conversionToDecimal(colorCode)
      this.show = true
      setTimeout(() => {
        this.show = false}
        ,3000
      )
    },
    deletePaletteColor(id) {
      const result = window.confirm('Do you want to delete this palette color?')
      if (result) {
        this.$store.dispatch('color/deletePaletteColor', id).then(() => {
        console.log('deleted')
        }).catch((error) => {
          console.log(error)
        })
      } else {
        console.log('Delete was canceled')
      }
    }
  }
}
</script>

ログイン状態の永続化

vuex-persistedstateをインストールして認証状態を保つようにしました。

$ yarn add vuex-persistedstate
plugins/localStorage.js
import createPersistedState from 'vuex-persistedstate'

export default ({ store }) => {
  createPersistedState({
    paths: ['auth'],
  })(store);
};

課題点

・リロードをした時に表示に不具合が生じる時があります。まだまだライフサイクルの理解が甘いと思いますので、より改良を続けていきたいと思います。

・Jestを書いていきたいと思います。しかしfirebase SDKを使用したtestの書き方を探っていく必要がありそうです。

参考にさせていただいた記事

Nuxt.js + Firebase Authentication + FireStoreでwebアプリケーションハンズオン
Cloud Firestore でデータを取得する
複数の自動生成されたIDでFirestoreのドキュメントをクエリする
Nuxt.jsでURLからパラメーターを取得する
Nuxt.js: storeモジュールから別のstoreモジュールのstateを参照する方法
Nuxt.js 親子コンポーネント間の双方向データバインディングを実装する

Discussion

ログインするとコメントできます