とあるハンズオンにて
講師「わかりましたか?」
受講者「しーん」
数名はうなずいている
先に進んで良いのか悪いのか判りません。
これをサポートするショートケーキを作成した話です。
※ショートケーキについては、こちらをお読みください。
https://jflute.hatenadiary.jp/entry/20180223/mastercurrent
この記事はリンク情報システムの勝手に始める「2020新春アドベントカレンダー Tech Connect」のリレー記事です。
engineer.hanzomon のグループメンバによってリレーされています。
本日は大伍がお届けします。
サービス概要
ハンズオンや勉強会などにおいて、
受講者が理解度をポチポチし、
講師が聞かなくても理解を確認できるサービスです。
(サービス公開はいずれ)
全体をざっくり図にすると以下の感じです。
技術要素
FirebaseとNuxtは、プロトを作成する際の近年ベストチョイスなのでは?と思っています。
Firebase
HostingとFirestoreを利用。
Spark(無料)プラン。
Nuxt
最近Nuxtづいているので一択。
その他
・GoogleのOAuth
受講者のポチポチをカウントするため、Googleアカウントでログインしてもらいます。
・Bootstrap
すごくBootstrap感がでています。
画面とソース
ログイン画面
- ボタンイベントにて
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>
ルーム参加・作成画面
- 受講者が参加するルームの入力、講師はルームの作成を行う画面です。
- 講師がルームを作成する際は、最初に表示するタイトルとして「準備中」を設定しています。
- ルームの作成ついでに、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>
フィードバック画面(受講者用)
- 受講者用の画面です。
- 講師が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>
ダッシュボード画面(講師用)
- 講師用の画面です。
- 各受講者がポチッた理解度が集計表示されます。
- 皆「待って」と言っていますが、どんどん進めます。
- タイトルの変更時は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はこちらです。