とあるハンズオンにて
講師「わかりましたか?」
受講者「しーん」
数名はうなずいている
先に進んで良いのか悪いのか判りません。
これをサポートするショートケーキを作成した話です。
※ショートケーキについては、こちらをお読みください。
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はこちらです。