213
93

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

本当なら君たちは手を挙げなければならない

Last updated at Posted at 2020-01-30

とあるハンズオンにて

講師「わかりましたか?」

受講者「しーん」

数名はうなずいている

先に進んで良いのか悪いのか判りません。
これをサポートするショートケーキを作成した話です。

※ショートケーキについては、こちらをお読みください。
 https://jflute.hatenadiary.jp/entry/20180223/mastercurrent


この記事はリンク情報システムの勝手に始める「2020新春アドベントカレンダー Tech Connect」のリレー記事です。
engineer.hanzomon のグループメンバによってリレーされています。

本日は大伍がお届けします。


サービス概要

ハンズオンや勉強会などにおいて、
受講者が理解度をポチポチし、
講師が聞かなくても理解を確認できるサービスです。
(サービス公開はいずれ)

image.png

全体をざっくり図にすると以下の感じです。

image.png

技術要素

FirebaseとNuxtは、プロトを作成する際の近年ベストチョイスなのでは?と思っています。

Firebase

 HostingとFirestoreを利用。
 Spark(無料)プラン。

Nuxt

 最近Nuxtづいているので一択。

その他

 ・GoogleのOAuth
   受講者のポチポチをカウントするため、Googleアカウントでログインしてもらいます。
 ・Bootstrap
   すごくBootstrap感がでています。

画面とソース

ログイン画面

image.png

  • ボタンイベントにて firebase.auth().signInWithRedirect() をコールすると、Googleの認証画面が表示され、認証後にこちらに戻ってきます。
  • 認証の結果はonAuthStateChanged()で通知されます。認証成功の場合はアプリ側で次画面を表示させます。
ソース
<template>
  <b-container class="container">
    <b-row>
      <b-col class="center">
        <h1 class="title">KAIGI-Feedbacker</h1>
      </b-col>
    </b-row>
    <br />

    <b-row>
      <b-col md="4" />
      <b-col md="4" class="center">
        主催者と参加者の橋渡しをするサービスです
        <br />
        <br />
        <b-button v-show="nowProc === false" @click="loginGoogle" block variant="outline-primary" size="lg">
          Googleでログイン
        </b-button>
      </b-col>
      <b-col md="4" />
    </b-row>
  </b-container>
</template>

<script>
import firebase from '@/plugins/firebase'

export default {
  components: {},
  middleware: [],

  data() {
    return {
      nowProc: true
    }
  },

  created() {},

  mounted() {
    // 認証状態が変化した場合に呼ばれる
    firebase.auth().onAuthStateChanged((user) => {
      // ログインしていたら次画面を表示
      if (user) {
        // Firebaseの固有ユーザIDを保持
        this.$store.commit('setUser', { uid: user.uid })

        // ルーム参加・作成画面を表示
        this.$router.push({ name: 'start' })
        return
      }

      this.nowProc = false
    })
  },

  methods: {
    // Googleアカウントで認証
    loginGoogle() {
      this.nowProc = true
      firebase.auth().signInWithRedirect(new firebase.auth.GoogleAuthProvider())
    }
  }
}
</script>

ルーム参加・作成画面

image.png

  • 受講者が参加するルームの入力、講師はルームの作成を行う画面です。
  • 講師がルームを作成する際は、最初に表示するタイトルとして「準備中」を設定しています。
  • ルームの作成ついでに、24時間が経過したルームデータを削除します。
ソース
<template>
  <b-container class="container">
    <b-row>
      <b-col class="center">
        <h1 class="title">KAIGI-Feedbacker</h1>
      </b-col>
    </b-row>
    <br />

    <b-row>
      <b-col md="3" />
      <b-col md="6">
        主催者から聞いたルーム名を入力します<br />
        <b-form-input v-model="joinRoomName" placeholder="参加するルーム名" size="lg" autocomplete="off" />
        <div class="err">{{ errors.joinRoomName }}</div>
      </b-col>
      <b-col md="3" />
    </b-row>
    <br />

    <b-row>
      <b-col md="4" />
      <b-col md="4">
        <b-button @click="joinClick" block variant="outline-primary" size="lg">参加</b-button>
      </b-col>
      <b-col md="4" />
    </b-row>
    <br />

    <hr />
    <br />
    <b-row>
      <b-col md="3" />
      <b-col md="6">
        主催者の方はこちらでルームを作成してください<br />
        <b-form-input v-model="roomName" placeholder="ルーム名" size="lg"></b-form-input>
        <div class="err">{{ errors.roomName }}</div>
      </b-col>
      <b-col md="3" />
    </b-row>
    <br />

    <b-row>
      <b-col md="4" />
      <b-col md="4">
        <b-button @click="addRoomClick" block variant="outline-primary" size="lg" autocomplete="off">
          ルームを作成する
        </b-button>
      </b-col>
      <b-col md="4" />
    </b-row>
  </b-container>
</template>

<script>
import firebase from '@/plugins/firebase'
const db = firebase.firestore()

export default {
  components: {},
  middleware: [],

  data() {
    return {
      joinRoomName: '',
      roomName: '',
      errors: {
        joinRoomName: '',
        roomName: ''
      }
    }
  },

  created() {},

  mounted() {},

  methods: {
    clearError() {
      this.errors.joinRoomName = ''
      this.errors.roomName = ''
    },

    // オーディエンス参加
    joinClick() {
      this.clearError()
      if (this.joinRoomName === '') {
        this.errors.joinRoomName = 'ルーム名を入力してください'
        return
      }

      // ルームの存在チェックを行い、オーディエンス画面を表示する
      const that = this
      const docRef = db.collection('rooms').doc(this.joinRoomName)
      docRef
        .get()
        .then((doc) => {
          if (doc.exists) {
            that.$store.commit('setRoom', { roomName: that.joinRoomName })
            that.$router.push({ name: 'audience' })
          } else {
            that.errors.joinRoomName = '入力のルームは存在しません'
          }
        })
        .catch((error) => {
          console.error('Error getting document:', error)
        })
    },

    // 主催者のルーム作成
    addRoomClick() {
      this.clearError()
      if (this.roomName === '') {
        this.errors.roomName = 'ルーム名を入力してください'
        return
      }

      // 古いデータを削除
      this.removeOldRoom()

      // ルームを作成して、管理画面を表示する
      const that = this
      db.collection('rooms')
        .doc(this.roomName)
        .set({
          roomName: this.roomName,
          active: {
            title: '準備中'
          },
          createdAt: firebase.firestore.FieldValue.serverTimestamp()
        })
        .then(() => {
          this.$store.commit('setRoom', { roomName: that.roomName })
          this.$router.push({ name: 'manage' })
        })
        .catch((error) => {
          console.error('Error writing document: ', error)
        })
    },

    // 24時間前のデータを検索して削除
    removeOldRoom() {
      const delDt = new Date()
      delDt.setHours(delDt.getHours() - 24)
      db.collection(`rooms`)
        .where('createdAt', '<', delDt)
        .get()
        .then(function(querySnapshot) {
          querySnapshot.forEach(function(doc) {
            db.collection(`rooms`)
              .doc(doc.id)
              .delete()
          })
        })
        .catch(function(error) {
          console.error('Error getting documents: ', error)
        })
    }
  }
}
</script>

フィードバック画面(受講者用)

image.png

  • 受講者用の画面です。
  • 講師が576個のポイントをすごい勢いで話してくるので「待って」をポチります。
  • 自分の理解をポチった場合、Firestoreの自分のデータを一度削除して、登録しなおします。
  • これは講師側の画面にて集計する際に、削除⇒デクリメント、登録⇒インクリメントという形で処理を単純化するためとなります。
  • 講師側がタイトルを更新した際、その通知イベントを受けて、本画面のタイトルの書き換えと理解の選択状態を初期状態(理解中)に戻します。
ソース
<template>
  <b-container class="container">
    <b-row>
      <b-col class="center">
        <a href="/" class="link">
          <h1 class="title hand">KAIGI-Feedbacker</h1>
        </a>
      </b-col>
    </b-row>

    <b-row>
      <b-col md="3" />
      <b-col md="6" class="center">
        <h1>{{ roomName }}</h1>
      </b-col>
      <b-col md="3" />
    </b-row>
    <hr />

    <b-row>
      <b-col md="3" />
      <b-col md="6" class="center">
        <h2>{{ activeTitle }}</h2>
      </b-col>
      <b-col md="3" />
    </b-row>
    <br />

    <b-row>
      <b-col md="4" />
      <b-col md="4">
        <div @click="stateClick('1')" class="hand">
          <span v-show="nowState === '1'" class="badge badge-pill badge-danger aud--state-badge"></span>
          <span v-show="nowState !== '1'" class="badge badge-pill badge-light aud--state-badge"></span>
          <img src="/images/state1.svg" class="state-mng" />
          <span class="state--text">分かった!</span>
        </div>
        <div @click="stateClick('2')" class="hand">
          <span v-show="nowState === '2'" class="badge badge-pill badge-danger aud--state-badge"></span>
          <span v-show="nowState !== '2'" class="badge badge-pill badge-light aud--state-badge"></span>
          <img src="/images/state2.svg" class="state-mng" />
          <span class="state--text">理解中</span>
        </div>
        <div @click="stateClick('3')" class="hand">
          <span v-show="nowState === '3'" class="badge badge-pill badge-danger aud--state-badge"></span>
          <span v-show="nowState !== '3'" class="badge badge-pill badge-light aud--state-badge"></span>
          <img src="/images/state3.svg" class="state-mng" />
          <span class="state--text">待って!</span>
        </div>
      </b-col>
      <b-col md="4" />
    </b-row>
  </b-container>
</template>

<script>
import firebase from '@/plugins/firebase'
const db = firebase.firestore()

export default {
  components: {},
  middleware: [],

  data() {
    return {
      roomName: '',
      activeTitle: '',
      uid: '',
      nowState: '2'
    }
  },

  created() {},

  mounted() {
    this.roomName = this.$store.state.room.roomName
    this.uid = this.$store.state.user.uid

    // 最初のstateを2で更新
    this.updateState('2')

    // タイトル更新の受信
    const that = this
    db.collection('rooms')
      .doc(this.roomName)
      .onSnapshot((doc) => {
        // タイトル最新化
        that.activeTitle = doc.data().active.title

        // stateの初期化
        this.nowState = '2'
        that.updateState(this.nowState)
      })
  },

  methods: {
    // stateクリック
    stateClick(state) {
      // 現在の同じstateの場合は何もしない
      if (state === this.nowState) {
        return
      }

      this.updateState(state)
    },

    // ステートの更新
    updateState(state) {
      this.nowState = state

      // 一旦削除してからstateを追加(データ更新の通知受信側でのカウントを楽にするため)
      db.collection(`rooms/${this.roomName}/audience`)
        .doc(this.uid)
        .delete()
      const doc = db.collection(`rooms/${this.roomName}/audience`).doc(this.uid)
      doc.set(
        {
          state
        },
        { merge: true }
      )
    }
  }
}
</script>

ダッシュボード画面(講師用)

image.png

  • 講師用の画面です。
  • 各受講者がポチッた理解度が集計表示されます。
  • 皆「待って」と言っていますが、どんどん進めます。
  • タイトルの変更時はFirestoreのデータを更新し、その通知を受けた受講者側の画面も更新されます。
ソース
<template>
  <b-container class="container">
    <b-row>
      <b-col class="center">
        <a href="/" class="link">
          <h1 class="title">KAIGI-Feedbacker</h1>
        </a>
      </b-col>
    </b-row>
    <br />

    <b-row>
      <b-col md="3" />
      <b-col md="6" class="center">
        <h1>{{ roomName }}</h1>
      </b-col>
      <b-col md="3" />
    </b-row>
    <hr />

    <b-row>
      <b-col md="3" />
      <b-col md="6" class="center">
        <b-form-input v-model="title" @keydown.enter="nextClick"
          placeholder="お話しのタイトル" size="md" autocomplete="off" />
        <br />
        <img @click="nextClick" src="/images/icon-shita.svg" class="icon-shita hand" />
      </b-col>
      <b-col md="3" />
    </b-row>
    <hr />

    <b-row>
      <b-col md="3" />
      <b-col md="6" class="center">
        <h2>{{ activeTitle }}</h2>
        <br />
        <b-row>
          <b-col cols="4">
            <StateCountBox :state-count="stateCount['1']" img-src="/images/state1.svg" />
            分かった!
          </b-col>
          <b-col cols="4">
            <StateCountBox :state-count="stateCount['2']" img-src="/images/state2.svg" />
            理解中
          </b-col>
          <b-col cols="4">
            <StateCountBox :state-count="stateCount['3']" img-src="/images/state3.svg" />
            待って!
          </b-col>
        </b-row>
        <div class="float--clear" />
      </b-col>
      <b-col md="3" />
    </b-row>
    <div id="footer" />
  </b-container>
</template>

<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.2.3/velocity.min.js"></script>

<script>
import firebase from '@/plugins/firebase'
import StateCountBox from '../components/state-countbox'
const db = firebase.firestore()

export default {
  components: {
    StateCountBox
  },
  middleware: [],

  data() {
    return {
      roomName: '',
      title: '',
      activeTitle: '',
      stateCount: {}
    }
  },

  created() {},

  mounted() {
    if (!this.$store.state.room) {
      this.$router.push({ name: 'start' })
      return
    }
    this.roomName = this.$store.state.room.roomName

    const that = this

    // アクティブタイトルの初期値取得
    const docRef = db.collection('rooms').doc(this.roomName)
    docRef
      .get()
      .then((doc) => {
        if (!doc.exists) {
          that.$store.commit('setRoom', {})
          that.$router.push({ name: 'start' })
          return
        }

        that.activeTitle = doc.data().active.title
      })
      .catch((error) => {
        console.error('Error getting document:', error)
      })

    // オーディエンスの件数の受信
    const query = db.collection(`rooms/${this.roomName}/audience`)
    query.onSnapshot((snapshot) => {
      snapshot.docChanges().forEach((change) => {
        let val = 1
        if (change.type === 'removed') {
          val = -1
        }

        // 当該stateがなければゼロで始める
        const state = change.doc.data().state
        const nowTotal = that.stateCount[state] || 0

        that.$set(that.stateCount, state, nowTotal + val)
      })
    })
  },

  methods: {
    nextClick(event) {
      if (!this.title || this.title === '') {
        return
      }

      // 日本語入力中のEnterキー操作は無効にする
      if (event.keyCode && event.keyCode !== 13) {
        return
      }

      const that = this
      db.collection('rooms')
        .doc(this.roomName)
        .set({
          roomName: this.roomName,
          active: {
            title: this.title
          }
        })
        .then(() => {
          that.activeTitle = that.title
          that.title = ''
        })
        .catch((error) => {
          console.error('Error writing document: ', error)
        })
    }
  }
}
</script>
集計値表示コンポーネント(StateCountBox)のソース
<template>
  <div class="state--countbox">
    <img :src="imgSrc" class="state-mng" />
    <div class="state--countbox-count">
      <h3>
        <span class="badge badge-danger">
          {{ stateCount }}
        </span>
      </h3>
    </div>
  </div>
</template>

<script>
export default {
  components: {},
  middleware: [],

  props: {
    imgSrc: {
      type: String,
      required: true,
      default: ''
    },
    stateCount: {
      type: Number,
      required: false,
      default: 0
    }
  },

  data() {
    return {}
  },

  created() {},

  mounted() {}
}
</script>

という感じでショートケーキをまた作りました。
ショートケーキを沢山作ったらチャーハンになったりしますかね?


リンク情報システム株式会社では**一緒に働く仲間を随時募集**しています!
また、お仕事のご依頼ビジネスパートナー様も募集しております。お気軽にご連絡ください。
Facebookはこちらです。
 
 
 

213
93
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
213
93

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?