- 投稿日:2019-02-09T23:55:56+09:00
VuetifyをStorybookで動かすための設定
vue-cliを使ってサクッとお試しで触ろうと思ったのにハマってしまったのでまとめておきます。
環境はvue-cli uiを使ってインストールしました。
環境
CLI Or CLI-PLUGIN インストールされたライブラリ バージョン vue-cli(3.4.0) vue 2.6.2 vue-cli-plugin-storybook(0.5.1) @storybook/vue 4.1.11 vue-cli-plugin-vuetify(0.4.6) vuetify 1.5.0 設定
config/storybook/config.js// 以下のコードを追加する import Vue from 'vue'; // webpackで読み込めるように、ビルドされたmin.jsとmin.cssを読み込む import Vuetify from 'vuetify/dist/vuetify.min'; import 'vuetify/dist/vuetify.min.css'; // Themeの設定を行っている場合は同様の設定を行う。 Vue.use(Vuetify, { iconfont: 'md', });config/storybook/config.js<head> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons"> </head>補足
vue-cli-plugin-vuetifyを使ってインストールすると、下記のような
src/plugins/vuetify.ts
が作成されるvuetify.tsimport Vue from 'vue'; import Vuetify from 'vuetify/lib'; import 'vuetify/src/stylus/app.styl'; Vue.use(Vuetify, { iconfont: 'md', });TypeScriptプロジェクトの場合、storybookのwebpackでビルドできないので、Vuetifyが使用できない。ビルド後のjs, cssを読み込むことで回避可能。
最後に
storybookの4系だとwebpackのデフォルトを上書きしたいのに、webpack.configがどこにあるかわからない...
- 投稿日:2019-02-09T15:20:55+09:00
Vue CLIでVue.jsプロジェクトをライブラリにビルドする
Vue CLI 3の
vue-cli-service build
にtargetオプション(ビルドターゲット)を指定することで、Vueプロジェクトをライブラリ形式にビルドできる。指定できるtargetオプションは以下の通り。
App
- コマンド:
vue-cli-service build (--target app)
- デフォルトのビルドターゲット
- 依存ライブラリをリンクしたindex.htmlが生成される。
- publicディレクトリの静的コンテンツもコピーされる。
Library
- コマンド:
vue-cli-service build --target lib [name] [entry]
[entry]
に指定したVueコンポーネントもしくはjsファイルをエントリーポイントとして、CommonJSとUMD形式で出力される。- Vueはバンドルされず、静的コンテンツもコピーされない。
- 使用例
<script src="https://.../vue.js"></script> <script src="https://.../myLib.js"></script> <link rel="stylesheet" href="./myLib.css"> <div id="app"> <my-lib></my-lib> </div> <script> new Vue({ components: { 'my-lib': myLib } }).$mount('#app') </script>
- IEをサポートするには、current-script-polyfillを読み込む必要がある。
Web Component
- コマンド:
vue-cli-service build --target wc [name] [entry]
[entry]
に指定したVueコンポーネントをエントリーポイントとして、Web Componentとして出力される。- Vueはバンドルされず、静的コンテンツもコピーされない。
- 使用例
<script src="https://.../vue.js"></script> <script src="https://.../my-wc.js"></script> <my-wc></my-wc>
- IE 11以下はサポートされない。
おまけ:package.jsonへのビルドコマンド追加
Vue CLIで作成したプロジェクトであればpackage.jsonにビルドコマンドが記載されているため、ライブラリ用のビルドコマンドもそこに追加してしまうのがよい。
package.json記述例
{ "scripts": { "build:lib": "vue-cli-service build --target lib myLib",
- 投稿日:2019-02-09T12:58:41+09:00
【Vue】eval電卓
概要
Vue.jsの入門として、何か作ってみたいという人向けです。
まず、通常のjsでeval電卓を作成し、Vue.jsを使って抽象化していきます。実装
以下のような電卓を、最小構成で実装する。
index.html
のonClick
イベントにより、calc
関数が呼ばれ、
クリックされたボタンに応じた処理をします。通常のjsの場合
button
タグの羅列が冗長であることが確認できる。とりあえず、動かしたい人は、cloneして下さい。
clonegit clone https://github.com/Naoto92X82V99/calc.gitindex.html<html> <head> <meta charset ="utf-8"> <script src="script.js"></script> </head> <body> <table id="calcTable"> <tr> <td colspan="3"><input type="text" id="output" value="0"></td> <td><button value="C" onClick="calc('C')">C</button></td> </tr> <tr> <td><button onClick="calc('7')">7</button></td> <td><button onClick="calc('8')">8</button></td> <td><button onClick="calc('9')">9</button></td> <td><button onClick="calc('/')">/</button></td> </tr> <tr> <td><button onClick="calc('4')">4</button></td> <td><button onClick="calc('5')">5</button></td> <td><button onClick="calc('6')">6</button></td> <td><button onClick="calc('*')">*</button></td> </tr> <tr> <td><button onClick="calc('1')">1</button></td> <td><button onClick="calc('2')">2</button></td> <td><button onClick="calc('3')">3</button></td> <td><button onClick="calc('-')">-</button></td> </tr> <tr> <td><button onClick="calc('0')">0</button></td> <td><button onClick="calc('.')">.</button></td> <td><button onClick="calc('+')">+</button></td> <td><button onClick="calc('=')">=</button></td> </tr> </table> </body> </html>script.jsfunction calc(cmd){ const element = document.getElementById('output') const value = element.value if(cmd === '='){ element.value = eval(value) }else if(cmd === 'C'){ element.value = '0' }else if(value === '0') { element.value = cmd }else{ element.value += cmd } }Vue.jsの場合
上記実装において、
button
タグの羅列が冗長であるため、Vue.jsで書き直してみる。
v-forを使うことで、繰り返し処理を記述できる。とりあえず、動かしたい人は、cloneして下さい。
clonegit clone https://github.com/Naoto92X82V99/vue-calc.gitindex.html<html> <head> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> </head> <body> <table id="app"> <tr> <td colspan="3"><input type="text" v-model="output"></td> <td><button value="C" v-on:click="calc('C')">C</button></td> </tr> <tr v-for="row in items"> <td v-for="item in row"> <button v-on:click="calc(item)">{{ item }}</button> </td> </tr> </table> <script src="script.js"></script> </body> </html>script.jsvar app = new Vue({ el: '#app', data: { output: '0', items: [ ['7', '8', '9', '/'], ['4', '5', '6', '*'], ['1', '2', '3', '-'], ['0', '.', '+', '='] ] }, methods: { calc: function (cmd) { if(cmd === '='){ this.output = eval(this.output) }else if(cmd === 'C'){ this.output = '0' }else if(this.output === '0') { this.output = cmd }else{ this.output += cmd } } } })まとめ
Vue.jsを用いて、最小構成でeval電卓を実装しました。
間違い・指摘等があればコメントお願いします。参考文献
- 投稿日:2019-02-09T12:04:23+09:00
nuxt.js で axios から外部APIを叩くとCORSエラーを解決
開発環境 localhost:3000 から外部の rest api を叩きたいという時にエラー
〜 has been blocked by CORS policy: Invalid response.CORS ... CSRF ... 聞いたことはあってなんとなく知ってるけど, 特に困ってないから深追いはしてなくて ... という感じの存在でした
以下の記事が分かりやすかったです CORS
https://qiita.com/tomoyukilabs/items/81698edd5812ff6acb34
- Cross-Origin Resource Sharing の略
- 信頼のないドメインを蹴ったりするのに使うもの
- localhost -> 外部api でドメインが変わるので怒られていた
nuxt での CORS 対策は, 調べてみると, @nuxt/proxy を install してる記事が多かったですが @nuxt/axios 自体に proxy 設定がありました
https://axios.nuxtjs.org/options#proxyので以下の変更でいけました
plugin/axios.js
を追加+ export default function({ $axios, redirect }) { + $axios.setToken('access_token') + + // 注: ここの引数を今は使わないからと _ とかにするとエラーになる + $axios.onResponse(config => { + $axios.setHeader('Access-Control-Allow-Origin', /** 許可するドメイン http://exsample.com あるいは通すだけなら '*' **/) + }) + }
nuxt.config.js
に以下を追加- plugins: ['@/plugins/vuetify'], + plugins: ['@/plugins/vuetify', '~/plugins/axios'], ... ... axios: { // See https://github.com/nuxt-community/axios-module#options + proxy: true, }, + proxy: { + '/api': 'https://api.〜', + },たったこれだけのことに半日持ってかれました
- 投稿日:2019-02-09T11:17:26+09:00
VueとFirebaseでとくめいチャットサービスを爆速で作ろう
こんにちは、ネコチャ運営者のアカネヤ(@ToshioAkaneya)です。
3日で作成したチャットサービスネコチャが好評をいただき、Twitter上で多数のつぶやきをいただきました。どんなサービスか
ネコチャは、とくめいチャットをすることが出来るサービスです。
仕組みは質問箱(Peingなど)と似ていて、自分のリンクをSNSで共有することでフォロワーがそのリンクから匿名でメッセージを送ることが出来るというものです。
質問箱と違うのは、会話を続けることが出来る点です。
話しかける側を匿名にすることでコミュケーションのハードルを下げることが可能になっています。
ネコチャトップページ開発の経緯
2018年に流行ったとくめいチャットアプリのNYAGOがありました。
急激なユーザーの増加に対し、開発体制が整っておらずやむなく一時停止を発表しました。
匿名チャットアプリ「NYAGO」が一時停止を発表、公開1週間で1万ユーザー突破も課題を痛感僕はNYAGOの再開を心待ちにしていたのですが、待てども待てども発表はなく...
「じゃあ作ろう」と思ったのが始まりでした。
(ネコチャのことはUNDEFINEDの方にもお話しているのでご安心を。)構成
構成はサーバーレスで、Firebase Realtime DatabaseとVue.jsを使用しています。
Firebase Realtime Database は、リアルタイムにデータを保存およびユーザー間で同期できる、クラウドホスト型 NoSQL データベースです。
リアルタイムチャットをもっとも簡単に実現出来る構成だと思います。
(現在のネコチャはリニューアルをしたもので、Ruby on Rails APIとNuxtを使用したものに変わっています。こちらについてもまた記事にできればと思います。)コード
完成品
完成するのは、こちらのリニューアル前のネコチャになります。ぜひ試しに僕にメッセージを送ってみて下さい。(「新しいバージョンのネコチャに移動しますか?(推奨)」でキャンセルを選択するとアクセス出来ます。)
なおリリース後の3日で約200名のユーザー登録を達成し、送られたメッセージ数は1500件以上にもなりました。
リニューアル前のネコチャFirebaseプロジェクトの設定
mioさんの認証付きの簡易チャットを作る!を参考にしてFirebaseプロジェクトを設定して下さい。
なお、TwitterログインではなくGoogleログインを使用するのでAuthenticationタブではGoogleログインを有効にして下さい。
また、データベースのルールでは次の内容をコピペして下さい。(「公開」ボタンを押すのを忘れずに!){ "rules": { "chats": { "$chatUid": { ".write": "auth!=null", ".read": " root.child('chats/'+$chatUid+'/expireAt').val()> now &&root.child('members/'+$chatUid).child(auth.uid).exists()", } }, "users": { "$userUid": { ".write": "auth!=null", ".read": "true", } }, "messages": { "$chatUid": { ".write": " root.child('chats/'+$chatUid+'/expireAt').val()> now&& (root.child('members/'+$chatUid).child(auth.uid).exists())", ".read": " root.child('chats/'+$chatUid+'/expireAt').val()> now &&root.child('members/'+$chatUid).child(auth.uid).exists()", } }, "members": { "$chatUid": { ".write": "auth!=null", ".read": " root.child('chats/'+$chatUid+'/expireAt').val()> now &&root.child('members/'+$chatUid).child(auth.uid).exists()", } } } }ネコチャを作るにあたり。このチュートリアルはとても参考になりました。mioさんありがとうございます。
準備
次のコマンドを実行して下さい
npm install -g firebase-tools @vue/cli vue init webpack necocha #基本的にエンターでOKですが、以下の質問にはNoと答えて下さい。 ? Install vue-router? No ? Use ESLint to lint your code? No ? Set up unit tests No ? Setup e2e tests with Nightwatch? No cd necocha firebase login firebase init # Hostingをスペースバーで選択してエンター。 # Firebaseコンソールで作成したプロジェクトを選択。 # 残りはあとで設定ファイルを編集するので、エンターを押していけば大丈夫です。 npm install axios crypto-js express firebase firebase-admin firebase-functions jquery moment vue vue-axios vue-clipboard2 vue-cookie vue-meta vue-moment vue-nl2brそして
firebase.json
を以下のように編集してください。これがfirebaseの設定ファイルです。firebase.json{ "hosting": { "public": "dist", "ignore": [ "firebase.json", "**/.*", "**/node_modules/**" ], "rewrites": [ { "source": "**", "destination": "/index.html" } ] } }コード
index.html
src/main.js
src/App.js
src/components/TheRoot.js
の3ファイルを編集(なければ作成)します。
まずはコピペして動作させてみることをオススメします。まずはindex.htmlを次のように作成・編集します。
index.html<!DOCTYPE html> <html> <head> <!-- Global site tag (gtag.js) - Google Analytics --> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta property="og:description" content="ネコチャはとくめいのネコになりチャットすることができるサービスです。"> <meta property="og:title" content="ネコチャ|とくめいチャットサービス"> <meta property="og:image" content="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/Screen%20Shot%202019-01-14%20at%200.36.18.png?alt=media&token=96df5db8-ed00-44b3-9e97-8e11160093e2"> <meta property="twitter:card" content="summary_large_image"> <link rel="shortcut icon" type="image/png" href="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/animal_mark04_neko.png?alt=media&token=4bd55443-839a-4bd9-bcd6-6f56cf7353c4" /> <link href="https://fonts.googleapis.com/earlyaccess/nikukyu.css" rel="stylesheet"> <title>ネコチャ|とくめいチャットサービス</title> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.3/css/all.css" integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/" crossorigin="anonymous"> </head> <body> <div id="app"></div> <!-- built files will be auto injected --> </body> </html>
src/main.js
ではVueインスタンスの初期化を行います。var config =
にはFirebaseコンソールで取得した値をペーストして下さい。src/main.jsimport firebase from 'firebase' import("firebase/functions"); import Vue from 'vue' import App from './App' import axios from 'axios' import VueAxios from 'vue-axios' import VueClipboard from 'vue-clipboard2' import Meta from 'vue-meta' // Vue.use(Meta) VueClipboard.config.autoSetContainer = true // add this line Vue.use(VueClipboard) Vue.use(VueAxios, axios) var VueCookie = require('vue-cookie'); // Tell Vue to use the plugin // import moment from 'vue-moment' // moment.locale('ja'); Vue.use(VueCookie); // Vue.use(moment); Vue.config.productionTip = false // Initialize Firebase var config = { // ここにFirebaseから取得したconfigをペースト }; firebase.initializeApp(config); new Vue({ el: '#app', components: { App }, template: '<App/>' })
src/App.vue
は次のようにして下さい。これがrootコンポーネントになります。src/App.vue<template> <div id="app"> <header class="header"> <h1> <a href="/" style="font-family: Nikukyu; color: white;">ネコチャ</a> </h1> </header> <section v-if="isChat"> <!-- 入力フォーム --> <form action @submit.prevent="sendMessage" class="form"> <textarea v-model="input" :disabled="!user"></textarea> <button type="submit" :disabled="!user" class="send-button">送信</button> </form> <transition-group name="chat" tag="div" class="list content"> <section v-for="{ key, name, image, message, isFromGuest } in chat" :key="key" class="item"> <div class="item-image"> <img :src="image" width="40" height="40" > </div> <div class="item-detail"> <div class="item-name"></div> <div class="item-message"> <nl2br tag="div" :text="message"/> </div> </div> </section> </transition-group> </section> <section v-else> <the-root :user="user" :chats="chats" :authPending="authPending" :clickHandler="signInTwitter"></the-root> </section> </div> </template> <script> // firebase モジュール // 改行を <br> タグに変換するモジュール import firebase from "firebase"; import Nl2br from "vue-nl2br"; import TheRoot from "./components/TheRoot"; export default { components: { TheRoot, Nl2br }, data() { return { authPending: true, user: null, // ユーザー情報 chats: [], // 取得したメッセージを入れる配列 chat: [], input: "", isChat: false, // 入力したメッセージ isGuest: false, key: "" }; }, mounted() { const authUser = Object.keys(window.localStorage) .filter(item => item.startsWith('firebase:host:necocha-me.firebaseio.com'))[0] if (!authUser) { this.authPending = false; } }, beforeCreate() { firebase.auth().onAuthStateChanged(async user => { this.authPending = false this.user = user; if (user) { const refUser = firebase .database() .ref("users/" + this.user.uid) await refUser.child('name').set(user.displayName) const userSnap = await refUser.once('value'); this.user.chats = userSnap.val().chats } const url = new URL(location.href); this.chatUid = url.searchParams.get("chatUid"); // "/ca this.isChat = !!this.chatUid; for (let chatUid of Object.keys(this.user.chats)) { const refChats = firebase .database() .ref("chats/" + chatUid); refChats.once('value', (snap) => { const newChat = snap.val() this.chats.push(({...newChat, uid: chatUid, isCreator: this.user.chats[chatUid] === 'creator'})) }); } if (!user) { if (this.isChat) { alert('ログインして下さい') return this.signInTwitter() } return; } if (!this.isChat) { return; } const ref_messages = firebase .database() .ref("messages/" + this.chatUid); this.chat = []; const refUsers = firebase .database() .ref("users/" + this.user.uid + '/chats/' + this.chatUid); refUsers.once('value', (snap) => { this.isGuest = snap.val() === 'guest' }) ref_messages.limitToLast(100).on("child_added", this.childAdded, e => { }); }); }, created() { const url = new URL(location.href); this.chatUid = url.searchParams.get("chatUid"); // "/ca this.isChat = !!this.chatUid; if (!this.isChat) { return; } if (+url.searchParams.get("expireAt") < Date.now()) { alert("有効期限が過ぎています"); return (window.location = "/"); } }, methods: { // ログイン処理 // スクロール位置を一番下に移動 scrollBottom() { this.$nextTick(() => { window.scrollTo(0, document.body.clientHeight); }); }, sendMessage() { firebase .database() .ref("messages/" + this.chatUid) .push({ image: (this.isGuest ? 'https://firebasestorage.googleapis.com/v0/b/necocha-io.appspot.com/o/animal_mark04_neko.png?alt=media&token=ba4e9920-bf1f-45ea-a3e6-0a34e3a85b21' : this.user.photoURL), message: this.input, isFromGuest: this.isGuest }, () => { this.input = ""; // フォームを空にする }); }, childAdded(snap) { const message = snap.val(); this.chat.push({ key: snap.key, name: message.name, image: message.image, message: message.message, isFromGuest: message.isFromGuest }); this.scrollBottom(); }, signInTwitter() { const providerTwitter = new firebase.auth.GoogleAuthProvider(); firebase.auth().signInWithRedirect(providerTwitter); } } }; </script> <style> * { margin: 0; box-sizing: border-box; font-family: sans-serif; } section { text-align: center; } .header { background: #3ab383; margin-bottom: 1em; padding: 0.4em 0.8em; color: #fff; } .content { margin: 80px auto 0; padding: 0 10px; max-width: 600px; } .form { position: fixed; display: flex; justify-content: center; align-items: center; top: 50px; height: 80px; width: 100%; background: #f5f5f5; } .form textarea { border: 1px solid #ccc; border-radius: 2px; height: 4em; width: calc(100% - 6em); resize: none; } .list { margin-bottom: 100px; } .item { position: relative; display: flex; align-items: flex-end; margin-bottom: 0.8em; } .item-image img { border-radius: 20px; vertical-align: top; } .item-detail { margin: 0 0 0 1.4em; } .item-name { font-size: 75%; } .item-message { position: relative; display: inline-block; padding: 0.8em; background: #deefe8; border-radius: 4px; line-height: 1.2em; } .item-message::before { position: absolute; content: " "; display: block; left: -16px; bottom: 12px; border: 4px solid transparent; border-right: 12px solid #deefe8; } .send-button { height: 4em; } /* トランジション用スタイル */ .chat-enter-active { transition: all 1s; } .chat-enter { opacity: 0; transform: translateX(-1em); } a { text-decoration: none; } </style>
TheRoot
コンポーネントをsrc/components/TheRoot.vue
に次のように定義します。src/components/TheRoot.vue<template> <section> <img width="100%" src="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/logo.png?alt=media&token=e84771e8-f590-4a4b-a2f9-06dca4712490"> <div v-if="authPending"> <img style="margin: 0 auto;" src="https://loading.io/spinners/palette-ring/index.svg"> </div> <div v-if="creator"> {{creator.name}}にとくめいでチャットしますか? <button type="button" @click="createChat">チャットする</button> </div> <div v-if="user"> <a :href="'https://twitter.com/intent/tweet?text=だれかとくめいのネコになってお話ししてくれませんか?送る側だけとくめいのチャット、ネコチャしよう!%20%23とくめいチャット%20%23ネコチャ&url=' + encodeURIComponent( `https://${window.location.host}/?creatorUid=${user.uid}`)" >あなたのリンクをTwitterで共有する</a> <div v-for="{uid,creatorName,expireAt,isCreator} in chats"> <a :href="`./?chatUid=${uid}&expireAt=${expireAt}`"> {{isCreator ? 'とくめいのネコ':creatorName}}さんとのチャット あと{{computeDate(expireAt).slice(0,computeDate(expireAt).length-1)}} </a> </div> </div> <div class="description"> <p>ネコチャはとくめいのネコとチャットすることができるサービスです。</p> <p>ルームを作成してリンクを共有すると、みんなはあなたととくめいでチャットすることができます。</p> <p>ルームは6時間で誰もアクセス出来なくなります。</p> <button type="button" @click="clickHandler">Googleではじめる</button> <p>とくめいのネコを募集して、エモいひとときをお楽しみ下さい。</p> </div> <img width="100%" src="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/sample.png?alt=media&token=66991375-4adc-44cb-8e48-6ed5469cf712"> <button type="button" @click="clickHandler">Googleではじめる</button> </section> </template> <script> import $ from "jquery"; import firebase from "firebase"; import "moment/locale/ja"; import moment from "moment"; // import 'moment/locale/ja' function getRandomInt(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min)) + min; //The maximum is exclusive and the minimum is inclusive } export default { name: "TheRoot", props: ["chats", "user", "authPending", "clickHandler"], data() { return { creator: null, window: {} }; }, computed: { chatData: () => { this.chats.map(chat => { return { uid: Object.keys(chat)[0], ...Object.values(chat) }; }); } }, created: async function() { this.window = window const url = new URL(location.href); const creatorUid = url.searchParams.get("creatorUid"); // "/ca if (creatorUid) { const creatorSnap = await firebase .database() .ref("users/" + creatorUid) .once("value"); this.creator = creatorSnap.val(); this.creator.uid = creatorUid; } const refChats = firebase.database().ref("chats"); refChats.on("child_added", snap => { // if (!this.user.chats) { // return // } // const newChat = snap.val() // if (this.user.chats[snap.key] === 'creator') { // this.chats.creator.push(newChat) // } else if (this.user.chats[snap.key] === 'guest') { // this.chats.guest.push(newChat) // } }); }, methods: { createChat: async function() { if (!this.user) { alert("Googleログインが必要です(名前は相手には分かりません)"); return; } if (!(this.user && this.creator)) { return; } const refChats = firebase.database().ref("chats"); const expireAt = Date.now() + 1000 * 60 * 60 * 6; const chatUid = refChats.push().getKey(); const refUser = firebase.database().ref("users"); const updateObj = {}; updateObj["name"] = this.user.displayName; updateObj["chats/" + chatUid] = "guest"; refUser.child(this.user.uid).update(updateObj); await refChats .child(chatUid) .set({ creatorName: this.creator.name, expireAt }); delete updateObj.name; updateObj["chats/" + chatUid] = "creator"; await refUser.child(this.creator.uid).update(updateObj); const refMembers = await firebase .database() .ref("members/" + chatUid) .set({ [this.user.uid]: "guest", [this.creator.uid]: "creator" }); await firebase .database() .ref("messages/" + chatUid) .push({ image: "https://firebasestorage.googleapis.com/v0/b/necocha-io.appspot.com/o/animal_mark04_neko.png?alt=media&token=ba4e9920-bf1f-45ea-a3e6-0a34e3a85b21", message: "こんにちは!とくめいのネコさんが入室しました!By運営", isFromGuest: true }); return (location = `/?chatUid=${chatUid}&expireAt=${expireAt}`); }, computeDate(timestamp) { return moment(timestamp).fromNow(); } } }; </script> <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped> section { max-width: 500px; margin: 0 auto; text-align: center; } .description { margin-top: 10px; line-height: 30px; padding: 0 50px; } button { border: none; border-radius: 2px; margin: 10px 0; color: white; font-size: 20px; background-color: #3ab383; } p { margin-bottom: 5px; } .ribbon11 { margin-bottom: 30px; display: inline-block; position: relative; height: 45px; vertical-align: middle; text-align: center; box-sizing: border-box; } .ribbon11:before { /*左側のリボン端*/ content: ""; position: absolute; width: 10px; bottom: -10px; left: -35px; z-index: -2; border: 20px solid #6cbf86; border-left-color: transparent; /*山形に切り抜き*/ } .ribbon11:after { /*右側のリボン端*/ content: ""; position: absolute; width: 10px; bottom: -10px; right: -35px; z-index: -2; border: 20px solid #6cbf86; border-right-color: transparent; /*山形に切り抜き*/ } .ribbon11 h3 { display: inline-block; position: relative; margin: 0; padding: 0 20px; line-height: 45px; font-size: 15px; color: #fff; background: #42bc8e; /*真ん中の背景色*/ } .ribbon11 h3:before { position: absolute; content: ""; top: 100%; left: 0; border: none; border-bottom: solid 10px transparent; border-right: solid 15px #318c69; /*左の折り返し部分*/ } .ribbon11 h3:after { position: absolute; content: ""; top: 100%; right: 0; border: none; border-bottom: solid 10px transparent; border-left: solid 15px #318c69; /*右の折り返し部分*/ } </style>デプロイ
最後にデプロイです。
npm run build firebase deploy表示されたURLにアクセスすると、デプロイの完了が確認できます。
最後に
いかがでしたでしょうか。ご感想などをぜひコメントして下さると幸いです。
はてなブックマーク・Pocketはこちらから
- 投稿日:2019-02-09T11:17:26+09:00
VueとFirebaseでチャットサービスを爆速で作ろう
こんにちは、ネコチャ運営者のアカネヤ(@ToshioAkaneya)です。
3日で作成したチャットサービスネコチャが好評をいただき、Twitter上で多数のつぶやきをいただきました。どんなサービスか
ネコチャは、とくめいチャットをすることが出来るサービスです。
仕組みは質問箱(Peingなど)と似ていて、自分のリンクをSNSで共有することでフォロワーがそのリンクから匿名でメッセージを送ることが出来るというものです。
質問箱と違うのは、会話を続けることが出来る点です。
話しかける側を匿名にすることでコミュケーションのハードルを下げることが可能になっています。
ネコチャトップページ開発の経緯
2018年に流行ったとくめいチャットアプリのNYAGOがありました。
急激なユーザーの増加に対し、開発体制が整っておらずやむなく一時停止を発表しました。
匿名チャットアプリ「NYAGO」が一時停止を発表、公開1週間で1万ユーザー突破も課題を痛感僕はNYAGOの再開を心待ちにしていたのですが、待てども待てども発表はなく...
「じゃあ作ろう」と思ったのが始まりでした。
(ネコチャのことはUNDEFINEDの方にもお話しているのでご安心を。)構成
構成はサーバーレスで、Firebase Realtime DatabaseとVue.jsを使用しています。
Firebase Realtime Database は、リアルタイムにデータを保存およびユーザー間で同期できる、クラウドホスト型 NoSQL データベースです。
リアルタイムチャットをもっとも簡単に実現出来る構成だと思います。
(現在のネコチャはリニューアルをしたもので、Ruby on Rails APIとNuxtを使用したものに変わっています。こちらについてもまた記事にできればと思います。)コード
完成品
完成するのは、こちらのリニューアル前のネコチャになります。ぜひ試しに僕にメッセージを送ってみて下さい。(「新しいバージョンのネコチャに移動しますか?(推奨)」でキャンセルを選択するとアクセス出来ます。)
なおリリース後の3日で約200名のユーザー登録を達成し、送られたメッセージ数は1500件以上にもなりました。
リニューアル前のネコチャFirebaseプロジェクトの設定
mioさんの認証付きの簡易チャットを作る!を参考にしてFirebaseプロジェクトを設定して下さい。
なお、TwitterログインではなくGoogleログインを使用するのでAuthenticationタブではGoogleログインを有効にして下さい。
また、データベースのルールでは次の内容をコピペして下さい。(「公開」ボタンを押すのを忘れずに!){ "rules": { "chats": { "$chatUid": { ".write": "auth!=null", ".read": " root.child('chats/'+$chatUid+'/expireAt').val()> now &&root.child('members/'+$chatUid).child(auth.uid).exists()", } }, "users": { "$userUid": { ".write": "auth!=null", ".read": "true", } }, "messages": { "$chatUid": { ".write": " root.child('chats/'+$chatUid+'/expireAt').val()> now&& (root.child('members/'+$chatUid).child(auth.uid).exists())", ".read": " root.child('chats/'+$chatUid+'/expireAt').val()> now &&root.child('members/'+$chatUid).child(auth.uid).exists()", } }, "members": { "$chatUid": { ".write": "auth!=null", ".read": " root.child('chats/'+$chatUid+'/expireAt').val()> now &&root.child('members/'+$chatUid).child(auth.uid).exists()", } } } }ネコチャを作るにあたり。このチュートリアルはとても参考になりました。mioさんありがとうございます。
準備
次のコマンドを実行して下さい
npm install -g firebase-tools @vue/cli vue init webpack necocha #基本的にエンターでOKですが、以下の質問にはNoと答えて下さい。 ? Install vue-router? No ? Use ESLint to lint your code? No ? Set up unit tests No ? Setup e2e tests with Nightwatch? No cd necocha firebase login firebase init # Hostingをスペースバーで選択してエンター。 # Firebaseコンソールで作成したプロジェクトを選択。 # 残りはあとで設定ファイルを編集するので、エンターを押していけば大丈夫です。 npm install axios crypto-js express firebase firebase-admin firebase-functions jquery moment vue vue-axios vue-clipboard2 vue-cookie vue-meta vue-moment vue-nl2brそして
firebase.json
を以下のように編集してください。これがfirebaseの設定ファイルです。firebase.json{ "hosting": { "public": "dist", "ignore": [ "firebase.json", "**/.*", "**/node_modules/**" ], "rewrites": [ { "source": "**", "destination": "/index.html" } ] } }コード
index.html
src/main.js
src/App.js
src/components/TheRoot.js
の3ファイルを編集(なければ作成)します。
まずはコピペして動作させてみることをオススメします。まずはindex.htmlを次のように作成・編集します。
index.html<!DOCTYPE html> <html> <head> <!-- Global site tag (gtag.js) - Google Analytics --> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta property="og:description" content="ネコチャはとくめいのネコになりチャットすることができるサービスです。"> <meta property="og:title" content="ネコチャ|とくめいチャットサービス"> <meta property="og:image" content="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/Screen%20Shot%202019-01-14%20at%200.36.18.png?alt=media&token=96df5db8-ed00-44b3-9e97-8e11160093e2"> <meta property="twitter:card" content="summary_large_image"> <link rel="shortcut icon" type="image/png" href="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/animal_mark04_neko.png?alt=media&token=4bd55443-839a-4bd9-bcd6-6f56cf7353c4" /> <link href="https://fonts.googleapis.com/earlyaccess/nikukyu.css" rel="stylesheet"> <title>ネコチャ|とくめいチャットサービス</title> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.3/css/all.css" integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/" crossorigin="anonymous"> </head> <body> <div id="app"></div> <!-- built files will be auto injected --> </body> </html>
src/main.js
ではVueインスタンスの初期化を行います。var config =
にはFirebaseコンソールで取得した値をペーストして下さい。src/main.jsimport firebase from 'firebase' import("firebase/functions"); import Vue from 'vue' import App from './App' import axios from 'axios' import VueAxios from 'vue-axios' import VueClipboard from 'vue-clipboard2' import Meta from 'vue-meta' // Vue.use(Meta) VueClipboard.config.autoSetContainer = true // add this line Vue.use(VueClipboard) Vue.use(VueAxios, axios) var VueCookie = require('vue-cookie'); // Tell Vue to use the plugin // import moment from 'vue-moment' // moment.locale('ja'); Vue.use(VueCookie); // Vue.use(moment); Vue.config.productionTip = false // Initialize Firebase var config = { // ここにFirebaseから取得したconfigをペースト }; firebase.initializeApp(config); new Vue({ el: '#app', components: { App }, template: '<App/>' })
src/App.vue
は次のようにして下さい。これがrootコンポーネントになります。src/App.vue<template> <div id="app"> <header class="header"> <h1> <a href="/" style="font-family: Nikukyu; color: white;">ネコチャ</a> </h1> </header> <section v-if="isChat"> <!-- 入力フォーム --> <form action @submit.prevent="sendMessage" class="form"> <textarea v-model="input" :disabled="!user"></textarea> <button type="submit" :disabled="!user" class="send-button">送信</button> </form> <transition-group name="chat" tag="div" class="list content"> <section v-for="{ key, name, image, message, isFromGuest } in chat" :key="key" class="item"> <div class="item-image"> <img :src="image" width="40" height="40" > </div> <div class="item-detail"> <div class="item-name"></div> <div class="item-message"> <nl2br tag="div" :text="message"/> </div> </div> </section> </transition-group> </section> <section v-else> <the-root :user="user" :chats="chats" :authPending="authPending" :clickHandler="signInTwitter"></the-root> </section> </div> </template> <script> // firebase モジュール // 改行を <br> タグに変換するモジュール import firebase from "firebase"; import Nl2br from "vue-nl2br"; import TheRoot from "./components/TheRoot"; export default { components: { TheRoot, Nl2br }, data() { return { authPending: true, user: null, // ユーザー情報 chats: [], // 取得したメッセージを入れる配列 chat: [], input: "", isChat: false, // 入力したメッセージ isGuest: false, key: "" }; }, mounted() { const authUser = Object.keys(window.localStorage) .filter(item => item.startsWith('firebase:host:necocha-me.firebaseio.com'))[0] if (!authUser) { this.authPending = false; } }, beforeCreate() { firebase.auth().onAuthStateChanged(async user => { this.authPending = false this.user = user; if (user) { const refUser = firebase .database() .ref("users/" + this.user.uid) await refUser.child('name').set(user.displayName) const userSnap = await refUser.once('value'); this.user.chats = userSnap.val().chats } const url = new URL(location.href); this.chatUid = url.searchParams.get("chatUid"); // "/ca this.isChat = !!this.chatUid; for (let chatUid of Object.keys(this.user.chats)) { const refChats = firebase .database() .ref("chats/" + chatUid); refChats.once('value', (snap) => { const newChat = snap.val() this.chats.push(({...newChat, uid: chatUid, isCreator: this.user.chats[chatUid] === 'creator'})) }); } if (!user) { if (this.isChat) { alert('ログインして下さい') return this.signInTwitter() } return; } if (!this.isChat) { return; } const ref_messages = firebase .database() .ref("messages/" + this.chatUid); this.chat = []; const refUsers = firebase .database() .ref("users/" + this.user.uid + '/chats/' + this.chatUid); refUsers.once('value', (snap) => { this.isGuest = snap.val() === 'guest' }) ref_messages.limitToLast(100).on("child_added", this.childAdded, e => { }); }); }, created() { const url = new URL(location.href); this.chatUid = url.searchParams.get("chatUid"); // "/ca this.isChat = !!this.chatUid; if (!this.isChat) { return; } if (+url.searchParams.get("expireAt") < Date.now()) { alert("有効期限が過ぎています"); return (window.location = "/"); } }, methods: { // ログイン処理 // スクロール位置を一番下に移動 scrollBottom() { this.$nextTick(() => { window.scrollTo(0, document.body.clientHeight); }); }, sendMessage() { firebase .database() .ref("messages/" + this.chatUid) .push({ image: (this.isGuest ? 'https://firebasestorage.googleapis.com/v0/b/necocha-io.appspot.com/o/animal_mark04_neko.png?alt=media&token=ba4e9920-bf1f-45ea-a3e6-0a34e3a85b21' : this.user.photoURL), message: this.input, isFromGuest: this.isGuest }, () => { this.input = ""; // フォームを空にする }); }, childAdded(snap) { const message = snap.val(); this.chat.push({ key: snap.key, name: message.name, image: message.image, message: message.message, isFromGuest: message.isFromGuest }); this.scrollBottom(); }, signInTwitter() { const providerTwitter = new firebase.auth.GoogleAuthProvider(); firebase.auth().signInWithRedirect(providerTwitter); } } }; </script> <style> * { margin: 0; box-sizing: border-box; font-family: sans-serif; } section { text-align: center; } .header { background: #3ab383; margin-bottom: 1em; padding: 0.4em 0.8em; color: #fff; } .content { margin: 80px auto 0; padding: 0 10px; max-width: 600px; } .form { position: fixed; display: flex; justify-content: center; align-items: center; top: 50px; height: 80px; width: 100%; background: #f5f5f5; } .form textarea { border: 1px solid #ccc; border-radius: 2px; height: 4em; width: calc(100% - 6em); resize: none; } .list { margin-bottom: 100px; } .item { position: relative; display: flex; align-items: flex-end; margin-bottom: 0.8em; } .item-image img { border-radius: 20px; vertical-align: top; } .item-detail { margin: 0 0 0 1.4em; } .item-name { font-size: 75%; } .item-message { position: relative; display: inline-block; padding: 0.8em; background: #deefe8; border-radius: 4px; line-height: 1.2em; } .item-message::before { position: absolute; content: " "; display: block; left: -16px; bottom: 12px; border: 4px solid transparent; border-right: 12px solid #deefe8; } .send-button { height: 4em; } /* トランジション用スタイル */ .chat-enter-active { transition: all 1s; } .chat-enter { opacity: 0; transform: translateX(-1em); } a { text-decoration: none; } </style>
TheRoot
コンポーネントをsrc/components/TheRoot.vue
に次のように定義します。src/components/TheRoot.vue<template> <section> <img width="100%" src="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/logo.png?alt=media&token=e84771e8-f590-4a4b-a2f9-06dca4712490"> <div v-if="authPending"> <img style="margin: 0 auto;" src="https://loading.io/spinners/palette-ring/index.svg"> </div> <div v-if="creator"> {{creator.name}}にとくめいでチャットしますか? <button type="button" @click="createChat">チャットする</button> </div> <div v-if="user"> <a :href="'https://twitter.com/intent/tweet?text=だれかとくめいのネコになってお話ししてくれませんか?送る側だけとくめいのチャット、ネコチャしよう!%20%23とくめいチャット%20%23ネコチャ&url=' + encodeURIComponent( `https://${window.location.host}/?creatorUid=${user.uid}`)" >あなたのリンクをTwitterで共有する</a> <div v-for="{uid,creatorName,expireAt,isCreator} in chats"> <a :href="`./?chatUid=${uid}&expireAt=${expireAt}`"> {{isCreator ? 'とくめいのネコ':creatorName}}さんとのチャット あと{{computeDate(expireAt).slice(0,computeDate(expireAt).length-1)}} </a> </div> </div> <div class="description"> <p>ネコチャはとくめいのネコとチャットすることができるサービスです。</p> <p>ルームを作成してリンクを共有すると、みんなはあなたととくめいでチャットすることができます。</p> <p>ルームは6時間で誰もアクセス出来なくなります。</p> <button type="button" @click="clickHandler">Googleではじめる</button> <p>とくめいのネコを募集して、エモいひとときをお楽しみ下さい。</p> </div> <img width="100%" src="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/sample.png?alt=media&token=66991375-4adc-44cb-8e48-6ed5469cf712"> <button type="button" @click="clickHandler">Googleではじめる</button> </section> </template> <script> import $ from "jquery"; import firebase from "firebase"; import "moment/locale/ja"; import moment from "moment"; // import 'moment/locale/ja' function getRandomInt(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min)) + min; //The maximum is exclusive and the minimum is inclusive } export default { name: "TheRoot", props: ["chats", "user", "authPending", "clickHandler"], data() { return { creator: null, window: {} }; }, computed: { chatData: () => { this.chats.map(chat => { return { uid: Object.keys(chat)[0], ...Object.values(chat) }; }); } }, created: async function() { this.window = window const url = new URL(location.href); const creatorUid = url.searchParams.get("creatorUid"); // "/ca if (creatorUid) { const creatorSnap = await firebase .database() .ref("users/" + creatorUid) .once("value"); this.creator = creatorSnap.val(); this.creator.uid = creatorUid; } const refChats = firebase.database().ref("chats"); refChats.on("child_added", snap => { // if (!this.user.chats) { // return // } // const newChat = snap.val() // if (this.user.chats[snap.key] === 'creator') { // this.chats.creator.push(newChat) // } else if (this.user.chats[snap.key] === 'guest') { // this.chats.guest.push(newChat) // } }); }, methods: { createChat: async function() { if (!this.user) { alert("Googleログインが必要です(名前は相手には分かりません)"); return; } if (!(this.user && this.creator)) { return; } const refChats = firebase.database().ref("chats"); const expireAt = Date.now() + 1000 * 60 * 60 * 6; const chatUid = refChats.push().getKey(); const refUser = firebase.database().ref("users"); const updateObj = {}; updateObj["name"] = this.user.displayName; updateObj["chats/" + chatUid] = "guest"; refUser.child(this.user.uid).update(updateObj); await refChats .child(chatUid) .set({ creatorName: this.creator.name, expireAt }); delete updateObj.name; updateObj["chats/" + chatUid] = "creator"; await refUser.child(this.creator.uid).update(updateObj); const refMembers = await firebase .database() .ref("members/" + chatUid) .set({ [this.user.uid]: "guest", [this.creator.uid]: "creator" }); await firebase .database() .ref("messages/" + chatUid) .push({ image: "https://firebasestorage.googleapis.com/v0/b/necocha-io.appspot.com/o/animal_mark04_neko.png?alt=media&token=ba4e9920-bf1f-45ea-a3e6-0a34e3a85b21", message: "こんにちは!とくめいのネコさんが入室しました!By運営", isFromGuest: true }); return (location = `/?chatUid=${chatUid}&expireAt=${expireAt}`); }, computeDate(timestamp) { return moment(timestamp).fromNow(); } } }; </script> <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped> section { max-width: 500px; margin: 0 auto; text-align: center; } .description { margin-top: 10px; line-height: 30px; padding: 0 50px; } button { border: none; border-radius: 2px; margin: 10px 0; color: white; font-size: 20px; background-color: #3ab383; } p { margin-bottom: 5px; } .ribbon11 { margin-bottom: 30px; display: inline-block; position: relative; height: 45px; vertical-align: middle; text-align: center; box-sizing: border-box; } .ribbon11:before { /*左側のリボン端*/ content: ""; position: absolute; width: 10px; bottom: -10px; left: -35px; z-index: -2; border: 20px solid #6cbf86; border-left-color: transparent; /*山形に切り抜き*/ } .ribbon11:after { /*右側のリボン端*/ content: ""; position: absolute; width: 10px; bottom: -10px; right: -35px; z-index: -2; border: 20px solid #6cbf86; border-right-color: transparent; /*山形に切り抜き*/ } .ribbon11 h3 { display: inline-block; position: relative; margin: 0; padding: 0 20px; line-height: 45px; font-size: 15px; color: #fff; background: #42bc8e; /*真ん中の背景色*/ } .ribbon11 h3:before { position: absolute; content: ""; top: 100%; left: 0; border: none; border-bottom: solid 10px transparent; border-right: solid 15px #318c69; /*左の折り返し部分*/ } .ribbon11 h3:after { position: absolute; content: ""; top: 100%; right: 0; border: none; border-bottom: solid 10px transparent; border-left: solid 15px #318c69; /*右の折り返し部分*/ } </style>デプロイ
最後にデプロイです。
npm run build firebase deploy表示されたURLにアクセスすると、デプロイの完了が確認できます。
最後に
いかがでしたでしょうか。ご感想などをぜひコメントして下さると幸いです。
はてなブックマーク・Pocketはこちらから
- 投稿日:2019-02-09T11:17:26+09:00
VueとFirebaseで爆速でチャットサービスを作ろう
こんにちは、ネコチャ運営者のアカネヤ(@ToshioAkaneya)です。
3日で作成したチャットサービスネコチャが好評をいただき、Twitter上で多数のつぶやきをいただきました。どんなサービスか
ネコチャは、とくめいチャットをすることが出来るサービスです。
仕組みは質問箱(Peingなど)と似ていて、自分のリンクをSNSで共有することでフォロワーがそのリンクから匿名でメッセージを送ることが出来るというものです。
質問箱と違うのは、会話を続けることが出来る点です。
話しかける側を匿名にすることでコミュケーションのハードルを下げることが可能になっています。
ネコチャトップページ開発の経緯
2018年に流行ったとくめいチャットアプリのNYAGOがありました。
急激なユーザーの増加に対し、開発体制が整っておらずやむなく一時停止を発表しました。
匿名チャットアプリ「NYAGO」が一時停止を発表、公開1週間で1万ユーザー突破も課題を痛感僕はNYAGOの再開を心待ちにしていたのですが、待てども待てども発表はなく...
「じゃあ作ろう」と思ったのが始まりでした。
(ネコチャのことはUNDEFINEDの方にもお話しているのでご安心を。)構成
構成はサーバーレスで、Firebase Realtime DatabaseとVue.jsを使用しています。
Firebase Realtime Database は、リアルタイムにデータを保存およびユーザー間で同期できる、クラウドホスト型 NoSQL データベースです。
リアルタイムチャットをもっとも簡単に実現出来る構成だと思います。
(現在のネコチャはリニューアルをしたもので、Ruby on Rails APIとNuxtを使用したものに変わっています。こちらについてもまた記事にできればと思います。)コード
完成品
完成するのは、こちらのリニューアル前のネコチャになります。ぜひ試しに僕にメッセージを送ってみて下さい。(「新しいバージョンのネコチャに移動しますか?(推奨)」でキャンセルを選択するとアクセス出来ます。)
なおリリース後の3日で約200名のユーザー登録を達成し、送られたメッセージ数は1500件以上にもなりました。
リニューアル前のネコチャFirebaseプロジェクトの設定
mioさんの認証付きの簡易チャットを作る!を参考にしてFirebaseプロジェクトを設定して下さい。
なお、TwitterログインではなくGoogleログインを使用するのでAuthenticationタブではGoogleログインを有効にして下さい。
また、データベースのルールでは次の内容をコピペして下さい。(「公開」ボタンを押すのを忘れずに!){ "rules": { "chats": { "$chatUid": { ".write": "auth!=null", ".read": " root.child('chats/'+$chatUid+'/expireAt').val()> now &&root.child('members/'+$chatUid).child(auth.uid).exists()", } }, "users": { "$userUid": { ".write": "auth!=null", ".read": "true", } }, "messages": { "$chatUid": { ".write": " root.child('chats/'+$chatUid+'/expireAt').val()> now&& (root.child('members/'+$chatUid).child(auth.uid).exists())", ".read": " root.child('chats/'+$chatUid+'/expireAt').val()> now &&root.child('members/'+$chatUid).child(auth.uid).exists()", } }, "members": { "$chatUid": { ".write": "auth!=null", ".read": " root.child('chats/'+$chatUid+'/expireAt').val()> now &&root.child('members/'+$chatUid).child(auth.uid).exists()", } } } }ネコチャを作るにあたり。このチュートリアルはとても参考になりました。mioさんありがとうございます。
準備
次のコマンドを実行して下さい
npm install -g firebase-tools @vue/cli vue init webpack necocha #基本的にエンターでOKですが、以下の質問にはNoと答えて下さい。 ? Install vue-router? No ? Use ESLint to lint your code? No ? Set up unit tests No ? Setup e2e tests with Nightwatch? No cd necocha firebase login firebase init # Hostingをスペースバーで選択してエンター。 # Firebaseコンソールで作成したプロジェクトを選択。 # 残りはあとで設定ファイルを編集するので、エンターを押していけば大丈夫です。 npm install axios crypto-js express firebase firebase-admin firebase-functions jquery moment vue vue-axios vue-clipboard2 vue-cookie vue-meta vue-moment vue-nl2brそして
firebase.json
を以下のように編集してください。これがfirebaseの設定ファイルです。firebase.json{ "hosting": { "public": "dist", "ignore": [ "firebase.json", "**/.*", "**/node_modules/**" ], "rewrites": [ { "source": "**", "destination": "/index.html" } ] } }コード
index.html
src/main.js
src/App.js
src/components/TheRoot.js
の3ファイルを編集(なければ作成)します。
まずはコピペして動作させてみることをオススメします。まずはindex.htmlを次のように作成・編集します。
index.html<!DOCTYPE html> <html> <head> <!-- Global site tag (gtag.js) - Google Analytics --> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta property="og:description" content="ネコチャはとくめいのネコになりチャットすることができるサービスです。"> <meta property="og:title" content="ネコチャ|とくめいチャットサービス"> <meta property="og:image" content="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/Screen%20Shot%202019-01-14%20at%200.36.18.png?alt=media&token=96df5db8-ed00-44b3-9e97-8e11160093e2"> <meta property="twitter:card" content="summary_large_image"> <link rel="shortcut icon" type="image/png" href="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/animal_mark04_neko.png?alt=media&token=4bd55443-839a-4bd9-bcd6-6f56cf7353c4" /> <link href="https://fonts.googleapis.com/earlyaccess/nikukyu.css" rel="stylesheet"> <title>ネコチャ|とくめいチャットサービス</title> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.3/css/all.css" integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/" crossorigin="anonymous"> </head> <body> <div id="app"></div> <!-- built files will be auto injected --> </body> </html>
src/main.js
ではVueインスタンスの初期化を行います。var config =
にはFirebaseコンソールで取得した値をペーストして下さい。src/main.jsimport firebase from 'firebase' import("firebase/functions"); import Vue from 'vue' import App from './App' import axios from 'axios' import VueAxios from 'vue-axios' import VueClipboard from 'vue-clipboard2' import Meta from 'vue-meta' // Vue.use(Meta) VueClipboard.config.autoSetContainer = true // add this line Vue.use(VueClipboard) Vue.use(VueAxios, axios) var VueCookie = require('vue-cookie'); // Tell Vue to use the plugin // import moment from 'vue-moment' // moment.locale('ja'); Vue.use(VueCookie); // Vue.use(moment); Vue.config.productionTip = false // Initialize Firebase var config = { // ここにFirebaseから取得したconfigをペースト }; firebase.initializeApp(config); new Vue({ el: '#app', components: { App }, template: '<App/>' })
src/App.vue
は次のようにして下さい。これがrootコンポーネントになります。src/App.vue<template> <div id="app"> <header class="header"> <h1> <a href="/" style="font-family: Nikukyu; color: white;">ネコチャ</a> </h1> </header> <section v-if="isChat"> <!-- 入力フォーム --> <form action @submit.prevent="sendMessage" class="form"> <textarea v-model="input" :disabled="!user"></textarea> <button type="submit" :disabled="!user" class="send-button">送信</button> </form> <transition-group name="chat" tag="div" class="list content"> <section v-for="{ key, name, image, message, isFromGuest } in chat" :key="key" class="item"> <div class="item-image"> <img :src="image" width="40" height="40" > </div> <div class="item-detail"> <div class="item-name"></div> <div class="item-message"> <nl2br tag="div" :text="message"/> </div> </div> </section> </transition-group> </section> <section v-else> <the-root :user="user" :chats="chats" :authPending="authPending" :clickHandler="signInTwitter"></the-root> </section> </div> </template> <script> // firebase モジュール // 改行を <br> タグに変換するモジュール import firebase from "firebase"; import Nl2br from "vue-nl2br"; import TheRoot from "./components/TheRoot"; export default { components: { TheRoot, Nl2br }, data() { return { authPending: true, user: null, // ユーザー情報 chats: [], // 取得したメッセージを入れる配列 chat: [], input: "", isChat: false, // 入力したメッセージ isGuest: false, key: "" }; }, mounted() { const authUser = Object.keys(window.localStorage) .filter(item => item.startsWith('firebase:host:necocha-me.firebaseio.com'))[0] if (!authUser) { this.authPending = false; } }, beforeCreate() { firebase.auth().onAuthStateChanged(async user => { this.authPending = false this.user = user; if (user) { const refUser = firebase .database() .ref("users/" + this.user.uid) await refUser.child('name').set(user.displayName) const userSnap = await refUser.once('value'); this.user.chats = userSnap.val().chats } const url = new URL(location.href); this.chatUid = url.searchParams.get("chatUid"); // "/ca this.isChat = !!this.chatUid; for (let chatUid of Object.keys(this.user.chats)) { const refChats = firebase .database() .ref("chats/" + chatUid); refChats.once('value', (snap) => { const newChat = snap.val() this.chats.push(({...newChat, uid: chatUid, isCreator: this.user.chats[chatUid] === 'creator'})) }); } if (!user) { if (this.isChat) { alert('ログインして下さい') return this.signInTwitter() } return; } if (!this.isChat) { return; } const ref_messages = firebase .database() .ref("messages/" + this.chatUid); this.chat = []; const refUsers = firebase .database() .ref("users/" + this.user.uid + '/chats/' + this.chatUid); refUsers.once('value', (snap) => { this.isGuest = snap.val() === 'guest' }) ref_messages.limitToLast(100).on("child_added", this.childAdded, e => { }); }); }, created() { const url = new URL(location.href); this.chatUid = url.searchParams.get("chatUid"); // "/ca this.isChat = !!this.chatUid; if (!this.isChat) { return; } if (+url.searchParams.get("expireAt") < Date.now()) { alert("有効期限が過ぎています"); return (window.location = "/"); } }, methods: { // ログイン処理 // スクロール位置を一番下に移動 scrollBottom() { this.$nextTick(() => { window.scrollTo(0, document.body.clientHeight); }); }, sendMessage() { firebase .database() .ref("messages/" + this.chatUid) .push({ image: (this.isGuest ? 'https://firebasestorage.googleapis.com/v0/b/necocha-io.appspot.com/o/animal_mark04_neko.png?alt=media&token=ba4e9920-bf1f-45ea-a3e6-0a34e3a85b21' : this.user.photoURL), message: this.input, isFromGuest: this.isGuest }, () => { this.input = ""; // フォームを空にする }); }, childAdded(snap) { const message = snap.val(); this.chat.push({ key: snap.key, name: message.name, image: message.image, message: message.message, isFromGuest: message.isFromGuest }); this.scrollBottom(); }, signInTwitter() { const providerTwitter = new firebase.auth.GoogleAuthProvider(); firebase.auth().signInWithRedirect(providerTwitter); } } }; </script> <style> * { margin: 0; box-sizing: border-box; font-family: sans-serif; } section { text-align: center; } .header { background: #3ab383; margin-bottom: 1em; padding: 0.4em 0.8em; color: #fff; } .content { margin: 80px auto 0; padding: 0 10px; max-width: 600px; } .form { position: fixed; display: flex; justify-content: center; align-items: center; top: 50px; height: 80px; width: 100%; background: #f5f5f5; } .form textarea { border: 1px solid #ccc; border-radius: 2px; height: 4em; width: calc(100% - 6em); resize: none; } .list { margin-bottom: 100px; } .item { position: relative; display: flex; align-items: flex-end; margin-bottom: 0.8em; } .item-image img { border-radius: 20px; vertical-align: top; } .item-detail { margin: 0 0 0 1.4em; } .item-name { font-size: 75%; } .item-message { position: relative; display: inline-block; padding: 0.8em; background: #deefe8; border-radius: 4px; line-height: 1.2em; } .item-message::before { position: absolute; content: " "; display: block; left: -16px; bottom: 12px; border: 4px solid transparent; border-right: 12px solid #deefe8; } .send-button { height: 4em; } /* トランジション用スタイル */ .chat-enter-active { transition: all 1s; } .chat-enter { opacity: 0; transform: translateX(-1em); } a { text-decoration: none; } </style>
TheRoot
コンポーネントをsrc/components/TheRoot.vue
に次のように定義します。src/components/TheRoot.vue<template> <section> <img width="100%" src="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/logo.png?alt=media&token=e84771e8-f590-4a4b-a2f9-06dca4712490"> <div v-if="authPending"> <img style="margin: 0 auto;" src="https://loading.io/spinners/palette-ring/index.svg"> </div> <div v-if="creator"> {{creator.name}}にとくめいでチャットしますか? <button type="button" @click="createChat">チャットする</button> </div> <div v-if="user"> <a :href="'https://twitter.com/intent/tweet?text=だれかとくめいのネコになってお話ししてくれませんか?送る側だけとくめいのチャット、ネコチャしよう!%20%23とくめいチャット%20%23ネコチャ&url=' + encodeURIComponent( `https://${window.location.host}/?creatorUid=${user.uid}`)" >あなたのリンクをTwitterで共有する</a> <div v-for="{uid,creatorName,expireAt,isCreator} in chats"> <a :href="`./?chatUid=${uid}&expireAt=${expireAt}`"> {{isCreator ? 'とくめいのネコ':creatorName}}さんとのチャット あと{{computeDate(expireAt).slice(0,computeDate(expireAt).length-1)}} </a> </div> </div> <div class="description"> <p>ネコチャはとくめいのネコとチャットすることができるサービスです。</p> <p>ルームを作成してリンクを共有すると、みんなはあなたととくめいでチャットすることができます。</p> <p>ルームは6時間で誰もアクセス出来なくなります。</p> <button type="button" @click="clickHandler">Googleではじめる</button> <p>とくめいのネコを募集して、エモいひとときをお楽しみ下さい。</p> </div> <img width="100%" src="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/sample.png?alt=media&token=66991375-4adc-44cb-8e48-6ed5469cf712"> <button type="button" @click="clickHandler">Googleではじめる</button> </section> </template> <script> import $ from "jquery"; import firebase from "firebase"; import "moment/locale/ja"; import moment from "moment"; // import 'moment/locale/ja' function getRandomInt(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min)) + min; //The maximum is exclusive and the minimum is inclusive } export default { name: "TheRoot", props: ["chats", "user", "authPending", "clickHandler"], data() { return { creator: null, window: {} }; }, computed: { chatData: () => { this.chats.map(chat => { return { uid: Object.keys(chat)[0], ...Object.values(chat) }; }); } }, created: async function() { this.window = window const url = new URL(location.href); const creatorUid = url.searchParams.get("creatorUid"); // "/ca if (creatorUid) { const creatorSnap = await firebase .database() .ref("users/" + creatorUid) .once("value"); this.creator = creatorSnap.val(); this.creator.uid = creatorUid; } const refChats = firebase.database().ref("chats"); refChats.on("child_added", snap => { // if (!this.user.chats) { // return // } // const newChat = snap.val() // if (this.user.chats[snap.key] === 'creator') { // this.chats.creator.push(newChat) // } else if (this.user.chats[snap.key] === 'guest') { // this.chats.guest.push(newChat) // } }); }, methods: { createChat: async function() { if (!this.user) { alert("Googleログインが必要です(名前は相手には分かりません)"); return; } if (!(this.user && this.creator)) { return; } const refChats = firebase.database().ref("chats"); const expireAt = Date.now() + 1000 * 60 * 60 * 6; const chatUid = refChats.push().getKey(); const refUser = firebase.database().ref("users"); const updateObj = {}; updateObj["name"] = this.user.displayName; updateObj["chats/" + chatUid] = "guest"; refUser.child(this.user.uid).update(updateObj); await refChats .child(chatUid) .set({ creatorName: this.creator.name, expireAt }); delete updateObj.name; updateObj["chats/" + chatUid] = "creator"; await refUser.child(this.creator.uid).update(updateObj); const refMembers = await firebase .database() .ref("members/" + chatUid) .set({ [this.user.uid]: "guest", [this.creator.uid]: "creator" }); await firebase .database() .ref("messages/" + chatUid) .push({ image: "https://firebasestorage.googleapis.com/v0/b/necocha-io.appspot.com/o/animal_mark04_neko.png?alt=media&token=ba4e9920-bf1f-45ea-a3e6-0a34e3a85b21", message: "こんにちは!とくめいのネコさんが入室しました!By運営", isFromGuest: true }); return (location = `/?chatUid=${chatUid}&expireAt=${expireAt}`); }, computeDate(timestamp) { return moment(timestamp).fromNow(); } } }; </script> <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped> section { max-width: 500px; margin: 0 auto; text-align: center; } .description { margin-top: 10px; line-height: 30px; padding: 0 50px; } button { border: none; border-radius: 2px; margin: 10px 0; color: white; font-size: 20px; background-color: #3ab383; } p { margin-bottom: 5px; } .ribbon11 { margin-bottom: 30px; display: inline-block; position: relative; height: 45px; vertical-align: middle; text-align: center; box-sizing: border-box; } .ribbon11:before { /*左側のリボン端*/ content: ""; position: absolute; width: 10px; bottom: -10px; left: -35px; z-index: -2; border: 20px solid #6cbf86; border-left-color: transparent; /*山形に切り抜き*/ } .ribbon11:after { /*右側のリボン端*/ content: ""; position: absolute; width: 10px; bottom: -10px; right: -35px; z-index: -2; border: 20px solid #6cbf86; border-right-color: transparent; /*山形に切り抜き*/ } .ribbon11 h3 { display: inline-block; position: relative; margin: 0; padding: 0 20px; line-height: 45px; font-size: 15px; color: #fff; background: #42bc8e; /*真ん中の背景色*/ } .ribbon11 h3:before { position: absolute; content: ""; top: 100%; left: 0; border: none; border-bottom: solid 10px transparent; border-right: solid 15px #318c69; /*左の折り返し部分*/ } .ribbon11 h3:after { position: absolute; content: ""; top: 100%; right: 0; border: none; border-bottom: solid 10px transparent; border-left: solid 15px #318c69; /*右の折り返し部分*/ } </style>デプロイ
最後にデプロイです。
npm run build firebase deploy表示されたURLにアクセスすると、デプロイの完了が確認できます。
最後に
いかがでしたでしょうか。ご感想などをぜひコメントして下さると幸いです。
はてなブックマーク・Pocketはこちらから
- 投稿日:2019-02-09T09:47:39+09:00
Element から学ぶ Vue.js の component の作り方 その2 (button)
button
前提
Elemnt 2.52
公式ページ
https://element.eleme.io/#/en-USボタン
用意されている機能
- サイズの指定 3種
- タイプの指定によるデザインテーマの指定
- ボタンの形式(プレーン、角丸、円形)
- ローディングアクション
- 非活性
- アイコンの利用
- オートフォーカス
- ネイティブタイプ(button / submit / reset)
構成
<button class="el-button" @click="handleClick" :disabled="buttonDisabled || loading" :autofocus="autofocus" :type="nativeType" :class="[ type ? 'el-button--' + type : '', buttonSize ? 'el-button--' + buttonSize : '', { 'is-disabled': buttonDisabled, 'is-loading': loading, 'is-plain': plain, 'is-round': round, 'is-circle': circle } ]" >disable は buttonDisabled と loadng のいずれか。
buttonDisabled は下記の computed で定義。
親から渡した prop か、elForm を inject しているのでフォームの中においてるとフォームと連動する。buttonDisabled() { return this.disabled || (this.elForm || {}).disabled; }他は prop の値を素直に受け取っている。
<i class="el-icon-loading" v-if="loading"></i> <i :class="icon" v-if="icon && !loading"></i> <span v-if="$slots.default"><slot></slot></span>loading が true の場合は loading icon が表示される。
icon が指定されていて、loading 中でなければ icon が表示される。
slot の指定が可能になっている。その他の computed
computed: { _elFormItemSize() { return (this.elFormItem || {}).elFormItemSize; }, buttonSize() { return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size; },buttonSize は prop か _elFormItemSize の帰り値の inject している elForm か elFormItems のサイズ、
あと、this.$ELEMENT は何だろう。グローバルな規定があるのかな。※ 要調査所感
第二回目だが、一回目と比べてあまり成長がないな。。
次こそ頑張ろう。
- 投稿日:2019-02-09T00:31:32+09:00
Vue.jsとFirebaseを使ってイベントの販売管理アプリを作ってみた
イベントの販売管理を行うモバイル向けのWEBアプリ Iventer を作りました。
このWEBアプリについての詳細は以下記事をご覧ください。この記事ではIventerの構成(主にvue.js)についてまとめようと思います。
ディレクトリ構成について
Iventerは以下のディレクトリ構成から成り立っています。
IventerDirectorysrc L - components - mixin - module - api - views App.vue main.js router.js store.jsそれぞれのディレクトリの説明について
views
ページに相当するvue fileを格納します。
HeaderとFouterもここに格納されています。components
各ページで使用するcomponent vue fileが格納されています。
ここでは更にページごとのディレクトリに切られており、主にモーダルのvue fileが格納されています。mixin
各ページ共通で使用されるvue fileが格納されています。
主に認証チェック系の処理や、処理成功と失敗時にだすトースターなどが格納されています。module
vuexで使用されるmodule vue fileが格納されています。
api
firestoreやfireauthに対してAPI callするjs fileが格納されています。
また、firebaseの初期化を行うjs fileもここに格納されています。各モジュールの責務について
vue-router
各ページ間の移動は全てscript側で行なっています。
HTMLはあくまでページを表示することに集中させ、処理は全てscriptに寄せることで、scriptとHTMLの責務を綺麗に切り分けることを実現しています。vuex
vuexの内部構造は以下のようになっています。
- mutation: stateに対して変化を加える唯一の要因
- action: 外部からstateに対して変化を与えるために存在する
- getter: 外部からstateを取得するために存在する
このように明確に各メソッドの責務を厳密に分けることで実装時のブレをなくします。
api
外部のエンドポイントに対して、リクエストを送り、component、vuexへレスポンスを返します。
ここではレスポンスの加工やエラーハンドリングは行いません。
データの操作と管理は全て呼び出し元のcomponent、vuexで行います。振り返り
vuexとvue-routerはとりあえず入れとけ
開発当初は親と子でpropsとemitを駆使しデータをやり取りしていたが、リファクタリングにあたりデータフローが想定より複雑になり整理する時間を取られてしまった。
vuexはmodule化を行えばそこまで肥大化することはないため、とりあえず入れておいたほうがいいだろう。何をやらせるかを決めておけ
今回モジュール毎に責務を決めておいたおかげで、どこに何を実装すればいいんだろうという迷いはほとんど生まれませんでした。
強いて言えばvuexのactionがやることが増えていきそうな感じがするので、逐次外部ファイルに処理を切り出して行かないと見通しが悪くなる感じがしました。