この記事は小高の産業高校の講座のための記事です。授業で補足していたりする関係で歯抜けになっていたりしますが、随時記事を修正していく予定です。
Bootstrapのコンポーネントをいろいろ使ってみよう
ナビゲーションバーをつくる
<body>
<div id="app">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="#">町案内アプリ</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
設定
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#">設定</a>
<a class="dropdown-item" href="#">ログアウト</a>
</div>
</li>
</ul>
</div>
</nav>
{{ message }}
スポットの一覧をつくる(まずは張りぼて)
<!-- card listを追加 -->
<div>
<div class="card mb-3" style="max-width: 540px;">
<div class="row no-gutters">
<div class="col-md-4">
<img src="https://picsum.photos/400/400/?image=20" class="card-img" alt="...">
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title">HOGEHOGE</h5>
<p class="card-text">This is a wider card with supporting text below as a natural lead-in to additional
content. This content is a little bit longer.</p>
<p class="card-text"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</div>
</div>
</div>
<!-- 追加ここまで -->
{{ message }}
一覧を動的に変化させられるようにしてみよう
<script>
var app = new Vue({
el: '#app',
data: {
message : 'Hello',
places: [
{ name: 'Odaka Micro Stand Bar', description:'コーヒーとタピオカが飲めます' },
{ name: '故郷喫茶カミツレ', description:'コーヒーとランチが売りです' },
{ name: 'Kaon Coffee', description:'コーヒーとケーキがおいしいです' }
]
}
})
</script>
まるまる書き換えてもらってよいです。
<!-- card listを追加 -->
<div>
<div class="card mb-3" style="max-width: 540px;">
<div class="row no-gutters" v-for="place in places">
<div class="col-md-4">
<img src="https://picsum.photos/400/400/?image=20" class="card-img" alt="...">
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title">{{place.name}}</h5> <!--ここを書き換える 1 -->
<p class="card-text">{{place.description}}</p> <!--ここを書き換える 2 -->
<p class="card-text"><small class="text-muted">Last updated 3 mins ago</small></p>
</div>
</div>
</div>
</div>
</div>
<!-- 追加ここまで -->
HTMLに、{{message}}とにたような形で記載してみましょう。3つのお店が表示されていれば成功です!
ナビゲーションバーの表示を変更する
スクロールするときえてしまったり、デザインもちょっとぱっとしないので色を変えてみましょう
常に上部に固定されるように
Bootstrapのページを探して変えてみましょう
ナビバーの色を変える
Bootstrapのページを探して変えてみましょう
一覧を増やせるようにする
2か所追加します。
<!-- card listの下に追加 -->
<h2>新しいお店の追加</h2>
<form v-on:submit.prevent="doAdd">
<div class="form-group">
<label for="placename">お店の名前</label>
<input type="text" class="form-control" id="placename" ref="name"
placeholder="Odaka Micro Stand Bar">
</div>
<div class="form-group">
<label for="description">詳細</label>
<textarea class="form-control" id="description" ref="description" placeholder="お店の詳細" rows="6"></textarea>
</div>
<button type="submit" class="btn btn-primary">追加</button>
</form>
data: {
places: [
{ name: 'Odaka Micro Stand Bar' },
{ name: '故郷喫茶カミツレ' },
{ name: 'Kaon Coffee' }
]
}, //ここのコンマの追加をわすれないように
methods: {
doAdd: function(event, value) {
// ref で名前を付けておいた要素を参照
var name = this.$refs.name;
var description = this.$refs.description;
// 入力がなければ何もしないで return
if (!name.value.length) {
return
}
this.places.push({
name: name.value,
description: description.value
})
name.value = ''
description.value = ''
}
}
ちょっと難しくなってきたのですが、v-on:submit.prevent="doAdd" という部分で、methods内のイベントを呼び出しています。
Webpackに改造(こちらでしちゃいます)
もともとこちらを使う予定だったのですが、ようやく環境が整ったのでこちらを。
ちょこちょこ変わっています。
たとえばindex.htmlでやっていたことがcomponents/xxxx.vueファイルに分割されました。
<template>
<div>
<b-card no-body class="overflow-hidden" v-for="place in places" :key="place">
<b-row no-gutters>
<b-col xs="4">
<b-card-img src="https://picsum.photos/200/200/?image=20" class="rounded-0"></b-card-img>
</b-col>
<b-col xs="8">
<b-card-body v-bind:title="place.name">
<b-card-text>
{{place.description}}
</b-card-text>
</b-card-body>
</b-col>
</b-row>
</b-card>
</div>
</template>
HTMLの記述ですが、templateというのに囲まれています。今はどうやらここが表示するメイン部分、とでも理解しておいてください。
また、bootstrapの記述もちょっと変わっています。これはbootstrapから、bootstrap-vueに変わったためです。
https://bootstrap-vue.js.org/
最後にひとつだけはまりやすいルールがあって、templateの下のHTMLタグは、複数おけません。この場合はdivですが、
<template>
<div>
...
</div>
<div> <!--templateの下にタグを二つ並べられない -->
...
</div>
</template>
こんな風にはできないということです。
export default {
name: 'Home',
data () {
return {
places: [
{ name: 'Odaka Micro Stand Bar', description: 'コーヒーとタピオカが飲めます' },
{ name: '故郷喫茶カミツレ', description: 'コーヒーとランチが売りです' },
{ name: 'Kaon Coffee', description: 'コーヒーとケーキがおいしいです' }
]
}
}
}
scriptの中の記述もちょっと変わっています。export defaultとはなんぞや?今の段階では気にしないようにしましょう。中身は見たことがありますね。
起動の確認
高校からは、setup.batを実行したプロンプトの画面から操作します。
npm run dev
を実行すると、しばらくしたのちに起動します。
http://localhost:8080
にアクセスしよう。前回と同様の画面が出ていればOK。
データベースと接続する
裏でGoogleが提供するFirebase Datastoreというものが動いています。そこまで自分でやると訳がわからなくなるので、
今回は裏側は用意しています。この章ではデータベースと接続します。
データベースにデータを追加できるようにしよう
components/ に新しいファイル NewPlace.vueを追加しよう
<template>
<div>
<h2>場所を追加する</h2>
<b-form>
<b-form-group id="input-group-name" label="場所の名前" label-for="input-2">
<b-form-input id="input-name" v-model="form.name" required placeholder="場所の名前"></b-form-input>
</b-form-group>
<b-form-group id="input-group-description" label="お店の詳細" label-for="input-description">
<b-form-textarea
id="input-description"
v-model="form.description"
placeholder="お店の詳細"
rows="3"
max-rows="6"
></b-form-textarea>
</b-form-group>
<b-form-group id="input-group-img-url" label="画像URL" label-for="input-img-url">
<b-form-input id="input-img-url" v-model="form.imgUrl" required placeholder="画像URL"></b-form-input>
</b-form-group>
<b-button type="submit" variant="primary">追加する</b-button>
</b-form>
</div>
</template>
<script>
export default {
name: 'NewPlace',
data () {
return {
form: {}
}
}
}
</script>
ここまではコピペでかまいません。
追加機能のページを分ける
前回は同じページにそのままフォームをつくりましたが、今回は違うページにしてみようと思います。
次に、この画面にアクセスさせるために、router/index.jsを開きます。
routerというのはroute(ルート)です。日本語だと経路のこのとですね。つまりページへの経路を指定するのに、このファイルを使うということです。
import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'
import NewPlace from '@/components/NewPlace' //1.これを追加
...
// 中略
routes: [{
path: '/',
name: 'Home',
component: Home,
meta: {
requiresAuth: true
}
}, { //2.追加
path: '/place/add',
name: 'NewPlace',
component: NewPlace,
meta: {
requiresAuth: true
}
}, { //追加ここまで
...
このようになるように追加してみよう。
importはJAVAと意味的にはほとんど同じです。要するにこのファイルでcomponent/NewPlaceを「NewPlaceという名前で」使います、という宣言です。
そして、経路を指定するのに
path(/place/add)にアクセスしたら、component(NewPlace)を呼び出します。というような記述になっています。
実際に
http://localhost:8080/#/place/add
にアクセスしたときにフォームが見えていたら成功です。
リンクをつくろう
でもこのままだとURLを直接指定しないとたどり着けないので、リンクをつくってあげよう。
今回は、とりあえずヘッダーの中に新規作成ができるメニューを作ることとしました。
将来的にこれは権限設定で、必要な人にだけ見えるようにできたらなぁ。間に合うかな。
<b-nav-item to="/">TOP</b-nav-item>
<b-nav-item to="/place/add">場所を追加</b-nav-item> <!--この行を追加-->
<b-nav-item to="/signout">サインアウト</b-nav-item>
components/Header.vue の中の、サインアウトの上に追加しました。
これで、アクセスできるようになっているはずです。
いよいよデータベースに接続
<template>
<div>
<h2>場所を追加する</h2>
<b-form @submit.prevent="sendItem"> <!-- @submit.prevent="sendItem"を追加 -->
...
</b-form>
</div>
</template>
<script>
import {db} from '@/firebase/firebase' //ここを追加
export default {
name: 'NewPlace',
data () {
return {
form: {}
}
},
methods: { //ここから追加(上のコンマもわすれない)
sendItem () {
const collection = db.collection('places')
// 保存用JSONデータを作成
const saveData = {
name: this.form.name,
description: this.form.description,
imgUrl: this.form.imgUrl
}
// addの引数に保存したいデータを渡す
collection
.add(saveData)
.then(function (docRef) {
this.form = {}
})
.catch(function (error) {
console.error('Error adding document: ', error)
})
}
} //ここまで追加
}
</script>
このようにしていこう。
@submit.prevent="sendItem" は、送信ボタンのデフォルトの機能(ページ遷移)は阻止して、sendItemというのをmethodsの中から探して実行してね。というような意味です。
sendItemは具体的に何をしているんでしょうか?
const collection = db.collection('places')
ここはまずデータベースのplacesというコレクション(学校の知識だとテーブルといったほうがわかりやすいかな?)をcollectionという名の定数に代入します。こうすることで、移行の記述を分かりやすくしようという意図です。
// 保存用JSONデータを作成
const saveData = {
name: this.form.name,
description: this.form.description,
imgUrl: this.form.imgUrl
}
ここは、保存用のデータを組み立てています。フォームのデータは this.form.xxxxで参照できるので、それをJavascriptのオブジェクトにしています。
ちょっとよくわからない?と思ったかもしれませんが、
{
name: 'Odaka Micro Stand Bar',
description: 'コーヒーとタピオカが飲めます',
imgUrl: 'http:xxxx'
}
これとまったく同じです。ここに固定の文字列が入っていた代わりに、変数を入れただけですね。
collection
.add(saveData)
.then(function (docRef) {
this.form = {}
})
.catch(function (error) {
console.error('Error adding document: ', error)
})
最後のこの部分はいよいよデータベースにデータを追加します。
collection.add(saveData) という処理がそれにあたります。これだけです。add()のかっこの中に、先ほど作ったデータオブジェクトを詰め込んで渡してあげるだけ。
後ろの.then()と.catch()は、うまくいったとき、失敗したときの処理です。
失敗したときの処理は本当はもうちょっと書いたりしますが、今回はこれでよいでしょう。
なお、then()の中では、this.form={}することで、フォームの内容をクリアするようにしています。
では、実際にやってみてください。データベースをみんなで共有しているので、試した分だけ増えていきます!
リストで表示する
こんどは元のホーム画面のリストが、きちんとデータベースのデータを読み込んでいるか確認してみよう。
<script>
import { db } from '@/firebase/firebase' //この行を追加
export default {
name: 'Home',
data () {
return {
places: [] //places:[]にして中身をからにしよう
}
}, //コンマを追加
//ここから追加
firestore () {
return {
places: db.collection('places')
}
}
//ここまで追加
}
</script>
これだけです。実際に読み込んでみましょう
詳細を表示できるようにする
一覧をタップすると詳細が表示され、詳しい情報を閲覧できるようにしましょう。
src/components/Place.vue を作成
<template>
<div>
<div>
<b-card no-body class="overflow-hidden">
<b-row no-gutters>
<b-col xs="4">
<b-card-img v-bind:src="place.imgUrl" class="rounded-0"></b-card-img>
</b-col>
<b-col xs="8">
<b-card-body v-bind:title="place.name">
<b-card-text>{{place.description}}</b-card-text>
</b-card-body>
</b-col>
</b-row>
</b-card>
</div>
</div>
</template>
<script>
import { db } from '@/firebase/firebase'
export default {
name: 'Place',
data () {
return {
place: {}
}
},
firestore () {
return {
place: db.collection('places').doc(this.$route.params.placeId)
}
}
}
</script>
Home.vueにリンクを追加します
<b-card no-body class="overflow-hidden" v-for="place in places" :key="place">
<!-- このrouter-linkというタグで挟み込みます -->
<router-link :to="{ name: 'Place' , params: { placeId: place.id}}">
<b-row no-gutters>
<b-col xs="4">
<b-card-img v-bind:src="place.imgUrl" class="rounded-0"></b-card-img>
</b-col>
<b-col xs="8">
<b-card-body v-bind:title="place.name">
<b-card-text>{{place.description}}</b-card-text>
</b-card-body>
</b-col>
</b-row>
</router-link>
<!-- 閉じたぐを忘れないように -->
</b-card>
router-linkタグというのは、HTMLのaタグの代わりだと思っていてください。動的にパラメーターを渡すことができます。
Routerに追加
let router = new Router({
routes: [{
path: '/',
name: 'Home',
component: Home
}, {
path: '/place/add',
name: 'NewPlace',
component: NewPlace,
meta: {
requiresAuth: true
}
}, { //この部分を追加。,{}の位置関係を間違えないように。
path: '/place/:placeId',
name: 'Place',
component: Place,
meta: {
requiresAuth: true
}
}, { //この閉じかっこまで追加
path: '/signup',
name: 'Signup',
component: Signup
},
//以下略
})
リンク先の指定のために、router/index.jsを変更します。:placeIdというところには動的な値が入りますよ、という意味です。
すでにつくったものを変更できるようにする
すでに作ったものを変更できるようにするために、詳細ページに編集ボタンを追加しましょう。
項目を追加
- 営業時間(hour)
- 予算(cost)
- 住所(address)
- おすすめポイント(recommend)
などを追加できるようにしてみましょう。
MAPを追加
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY"></script>
</body>
YOUR_API_KEYには、学校の共有サーバー上にある情報を指示します。
<div id="map"></div>
中略
methods: {
renderMap () {
this.mapOptions = {
center: new google.maps.LatLng(34,138),
zoom: 10,
mapTypeId: 'roadmap'
}
this.map = new google.maps.Map(document.getElementById("map"), this.mapOptions);
}
},
mounted: function() {
this.renderMap()
}
<style scoped>
# map {
height: 100vh;
width: 100%;
}
</style>
これで静的な状態のマップが表示されましたが、まだ場所によって表示を変えることができません
場所に座標を登録できるようにする
GoogleMapの場所は、緯度と経度を表す以下のような形式で座標を持っています。
この値を追加できるようにしましょう。
<b-form-group id="input-group-img-url" label="GoogleMap座標" label-for="input-img-url">
<b-form-input id="input-latLng" v-model="form.latLng" required placeholder="37.5634769,140.9930118"></b-form-input>
</b-form-group>
以下を適当な場所に追加します。b-form-groupの並びに追加してくれればどこでもよいです。
座標を読みだす
mounted: function() {
//mountedの中身を書き換える
let _this = this
db.collection("places").doc(this.$route.params.placeId).get().then(function(snapshot) {
_this.place = snapshot.data()
_this.renderMap()
})
},
firestore : {}//このブロックは削除
renderMap () {
//renderMap()の中身を書き換える
if(this.place.latLng) {
let arr = this.place.latLng.split(',')
let lat = parseFloat(arr[0])
let lng = parseFloat(arr[1])
this.mapOptions = {
center: new google.maps.LatLng(lat, lng),
zoom: 16,
mapTypeId: 'roadmap'
}
this.map = new google.maps.Map(document.getElementById("map"), this.mapOptions)
var marker = new google.maps.Marker({position: {lat,lng}, map: this.map})
}
}
画像をアップロードできるようにする
const firebaseConfig = {
//略
storageBucket: "odaka-app.appspot.com", //storageBucketを設定
//略
}
//中略
export const auth = firebase.auth()
export const db = firebase.firestore()
export const storage = firebase.firestore() //追加
ストレージ機能もfirebaseというサービスを使っています。
ストレージを使いますよ、という意味で、コードを書いていきます。
import { db, storage } from '@/firebase/firebase'
//dbのあとに ",storage "を追加
<b-form-group id="input-group-img-url" label="画像URL" label-for="input-img-url">
<b-form-file
placeholder="画像をドロップするかクリックして選択"
drop-placeholder="ここにドロップ"
v-on:change="uploadfile"
></b-form-file>
<!-- b-form-inputを書き換え -->
<img :src="form.imgUrl" v-if="form.imgUrl" width="300" />
<!-- 大きな画像を選択してもはみ出さないように width="300" を追加 -->
</b-form-group>
ファイルのアップロードボタンを追加します。まだ何も起きません。v-on:change="uploadfile"となっているので、
もし画像をアップするとuploadfileという関数が呼び出されるようになります
deleteItem() {
const router = this.$router;
const placeId = this.placeId;
const collection = db.collection("places");
if (confirm("本当に削除してしまってもよろしいですか?")) {
collection.doc(placeId).delete();
router.push("/");
}
},
uploadfile(e) {
const file = e.target.files[0]
const form = this.form
let storageRef = storage.ref();
let imgRef = storageRef.child("uploads/" + file.name);
imgRef.put(file).then(
function(snapshot) {
snapshot.ref.getDownloadURL().then(
function(downloadURL) {
form.imgUrl = downloadURL
},
function(error) {
alert('ダウンロードURLの取得でエラーが発生しました')
}
);
},
function(error) {
alert('アップロードでエラーが発生しました')
}
);
},
管理者権限を付与する
編集や削除をつくった結果、だれでも編集できるようになってしまいました。
でも通常のユーザーには編集できないように、管理者権限を作成して付与しましょう。
デプロイする
残念ながら学校のPCからビルド環境をつくれないので、毎週終わったらこちらでアップします。
デプロイしたのがここに・・・
https://odaka-app.firebaseapp.com/#/
追記:
https://odaka.app/
もとりました
どんどん機能追加
このアプリの説明をするページ
このアプリを説明するページを作ってみましょう
MAPで一覧を表示できるページ
すべての場所を一覧で表示できるようにしたい
<template>
<div>
<div id="map"></div>
</div>
</template>
<script>
import { db } from '@/firebase/firebase'
export default {
name: 'Map',
data () {
return {
places: [],
map: Object,
mapOptions: Object
}
},
mounted: function() {
const _this = this
db.collection('places').get().then(function(querySnapshot) {
_this.places = querySnapshot.docs.map(doc => {
var data = doc.data()
data.id = doc.id
return data
})
_this.renderMap()
})
},
methods: {
renderMap () {
const _this = this
//renderMap()の中身を書き換える
_this.mapOptions = {
center: new google.maps.LatLng(37.5638148, 140.9900094),
zoom: 16,
mapTypeId: 'roadmap'
}
_this.map = new google.maps.Map(document.getElementById("map"), _this.mapOptions)
_this.places.map(function(v) {
if(v.latLng) {
var latLng = v.latLng.split(',')
var lat = parseFloat(latLng[0])
var lng = parseFloat(latLng[1])
var marker = new google.maps.Marker({position: {lat,lng}, map: _this.map})
var infoWindow = new google.maps.InfoWindow({ // 吹き出しの追加
content: `<div><a href="/#/place/${v.id}" >${v.name}</a></div>` // 吹き出しに表示する内容
});
marker.addListener('click', function() { // マーカーをクリックしたとき
infoWindow.open(_this.map, marker); // 吹き出しの表示
});
}
})
}
}
}
</script>
<style scoped>
# map {
height: 50vh;
width: 100%;
}
</style>
let router = new Router({
routes: [{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/about',
name: 'About',
component: About,
}, {
path: '/map',
name: 'Map',
component: Map,
},
//以下略
<b-navbar fixed="bottom" type="light" variant="light" style="padding: 0;">
<b-navbar-nav fill justified style="width: 100%" class="h3">
<b-nav-item to="/about" exact-active-class="text-info"><font-awesome-icon icon="id-card" /><h6>About</h6></b-nav-item>
<b-nav-item to="/" exact-active-class="text-info"><font-awesome-icon icon="home" /><h6>一覧</h6></b-nav-item>
<b-nav-item to="/map" exact-active-class="text-info"><font-awesome-icon icon="map" /><h6>地図</h6></b-nav-item>
</b-navbar-nav>
</b-navbar>
説明を書いてみよう
説明ページをつくってみよう。
カメラを起動するページ
店舗のQRコードを読み込むとポイントがもらえるしくみとか
公開する
実は僕もPWAをAndroidのPlayストアに公開したことはないのですが、できるらしいです。
せっかくなのでやってみたいと思います。
授業の範囲外ですが
参考:【実践】Google Play Store でPWA配信 (TWA)
https://qiita.com/zprodev/items/181c1c8f19bc0beb1183
最後に
このような知識にどのようにたどり着けばよいか?
「簡単に感じてもらう」という目標設定からはだいぶ遠くなってしまいました。
今回は、「写経が多くなっても完成品をつくれることを目標にしましょう」と樋口先生と話しながらだったのですが、
毎回試行錯誤をしています。ちょっとしんどかった人、それなりに楽しかった人、いろいろいると思いますが、
なんとなく楽しい、という部分が1mmでもあったならうれしいです。
カメラ起動したり・・・はさすがに日程が足りませんでしたね。
とはいえ、実際にこういったものを「自分でできるようになるまで」は遠い道のりです。
一体どのようにすればよいのでしょうか?専門学校にいけたらよいですが、そうではない場合は?
とりあえず初めてみる
大事なことは、「やってみたい」はとりあえずはじめてみることです。
「これって本当に自分に向いているのかな・・・?できるかな・・・?」と悩んだ夜の数百時間があれば、実際にやってみてある程度のこともまでできるようになります。また、仮にやってみて向いてないと思っても無駄になることは実際にはほとんどありません。
スティーブジョブズもカリグラフィの授業を何かに生かそうとは考えていませんでしたが、のちのApple製品にはその知識が生かされました。
一番良いのはロールモデルを見つけること
何かを学ぶ上でもっともよいのは「それをやっている人に教えを乞うこと」です。
プログラマーやデザイナーのようなクリエイティブには、少なからず「コミュニティ」があります。
そういった集まりにアクセスしてみるのもよいです。
AIの時代だからこそ、情熱を呼び覚ましてほしい
こんなことを言うと先生方に怒られるのですが、
学校というのは少なからず「個人の意思を捨てても、言うことを聞く」ことが良いことだとみなされる傾向にあります。
また、学業や部活動の成績で比較されることで、自尊心や自分の考えを失ってしまう子も少なくないと思っています。
それでもそのままうまくレールに乗れているうちは安全です。
しかし、万が一レールにのれなくなってしまったときにもろさがあります。
専門学校にいけなかったら?就職できなかったら?会社が倒産したら?自分の職業が機械によって不要になったら?
そういったリスクに対して大事なのは「自分で考えて、決断し、失敗と挫折を繰り替えして踏ん張っていく」ということだと
僕は思っています。
だからこそ、「やってみたい」と思ったことはできるかできないかを考えるのであれば、まずはやってみたり、どうやったらできるか、を考えてみてほしいなと思っています。