In the previous post we have built our GraphQL API using Laravel, now let’s focus on the front end and build the Twitter like app using Vue.js, Vuex & Vue router, it will be a completed SPA app, only the auth part will be taken care by laravel out of box authentication, once user logs in then we will load our single page application which we will create in this post using Vue.
Laravel Auth
Run php artisan make:auth
to scaffold auth system. By default all the view markup will be in bootstrap, we will be updating it next to use Bulma.
Install Bulma
By default laravel 5.4 ships with Bootstrap as front end choice but I am going to use Bulma which is very minimalistic and Flexbox CSS library. To do that open up the package.json and remove the "bootstrap-sass": "^3.3.7"
from dependencies now open the resources/assets/js/bootstrap.js
and remove bootstrap-sass.
try {
window.$ = window.jQuery = require('jquery');
// remove it
// require('bootstrap-sass');
} catch (e) {}
With that, we have removed the bootstrap references from the project. Now Install Bulma by running npm install bulma --save
, now update the auth views from repo for login, register, forgot and reset password to use Bulma instead of bootstrap.
Setup Vue
Now we need to setup Vue.js, it’s already installed but we need to tweak the bootstrapping a little. Create a utils folder inside resources/assets/js/utils
, we will keep all the utilities for this app here, let’s create an index.js and one more http.js file in this folder.
http.js
This is a wrapper for axios HTTP library, we are creating this so we can easily swap the library if needed in future and to pass configuration, global error handling from a single point.
// axios instance
import axios from 'axios'
// Laravel CSRF token
let token = document.head.querySelector('meta[name="csrf-token"]');
// Create an Instance
const instance = axios.create({
// change this url to your api
baseURL: '//localhost:8000/',
// any other headers you want to include
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': token ? token.content : null
}
});
// Error interceptor can be used for global error handling
instance.interceptors.response.use(function (response) {
// Do something with response data
return response;
}, function (error) {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// redirect to root if un authenticated
if(error.response.status === 401) {
window.location = '/login';
}
} else {
// Something happened in setting up the request that triggered an Error
console.log('Error', error.message);
}
});
export default instance
index.js
Now in index.js file we will export this module.
// All utilities
import http from './http';
export {
http
}
Now let’s update the bootsrap.js file, we only going to use lodash library, remove everything else. Now bootstrap.js will look like this.
window._ = require('lodash');
Setup Vuex store
Install the Vuex by running npm install vuex --save
, with that now we can setup the resources/assets/js/store
folder and related files in it.
With vuex you will get an idea and plan ahead how you are going to manage state your app. The first thing we should create the state.js which where we will define the state of our app.
// State of your app
const state = {
me: {},
feed: [],
profilePage: {
profile: {
bio: ''
},
avatar: '',
username: '',
tweets: [],
is_following: false
},
tweetDetail: {
user: {},
replies: []
},
openTweetDetails: null,
followSuggestions: [],
isLoading: false,
appName: 'QTwitter'
}
export default state;
It’s best to setup all the nested properties up front to avoid any errors, don’t worry you will fill this as you encounter errors along the way. I will not cover all of the getters, mutations, actions in this post but you can find more information on this in my earlier post Build complete RSS feed reader app – Front-end with Vue.js, Vuex & Element UI. Go ahead and create all the files as in GitHub repo.
Configure vue router
Now let’s fetch the vue-router by running npm install vue-router --save
and let’s define some routes and configs router according to our need. create a file in router folder under resources/assets/js/router/index.js
. Now write following code:
import Vue from 'vue'
import Router from 'vue-router'
// Page Components
import Dashboard from '../Pages/Dashboard'
import Profile from '../Pages/Profile'
import Followers from '../Pages/Followers'
import Following from '../Pages/Following'
// Use the router
Vue.use(Router)
export default new Router({
// All router active link
linkActiveClass: 'is-active',
// All routes
routes: [
{
path: '/',
name: 'dashboard',
component: Dashboard
},
{
path: '/@:username',
name: 'profile',
component: Profile,
props: true
},
{
path: '/@:username/followers',
name: 'followers',
component: Followers,
props: true
},
{
path: '/@:username/following',
name: 'following',
component: Following,
props: true
}
],
// mode: 'history', // Enable it to enable scroll behavior
scrollBehavior (to, from) {
return { x: 0, y: 0 }
}
})
As you can see we will need four components (Page) for our four routes, go ahead and create the page components under resources/assets/js/Pages folder, you can just copy paste the resources/assets/js/components/Example.vue
to and rename it as Dashboard, Profile, Followers & Following for the time being, we will come back to them later and complete them.
You should enable mode: ‘history’ to enable the scroll behaviour functionality
I have user props: true
on some routes so I can access the username in the Component as a property and linkActiveClass
is set to is-active to apply active state by Bulma css.
Now we have the router and vuex setup let’s create our Root App Component. It will b a wrapper and act like master layout which will hold nav bar, router-view and anything which is required by child components.
App Component
I am going to put it inside resources/assets/js/components/
folder as App.vue
file.
<template>
<div class="app-wrap">
<nav class="navbar ">
<div class="container" :class="{'is-loading': isLoading}">
<div class="navbar-brand">
<a href="#/" class="navbar-item">
<img src="/img/qTwitter-logo.png" alt="QTwitter" height="38">
</a>
<div data-target="navMenubd-example" class="navbar-burger burger"><span></span> <span></span> <span></span>
</div>
</div>
<div id="navMenubd-example" class="navbar-menu">
<div class="navbar-start"></div>
<div class="navbar-end">
<router-link class="navbar-item" :to="{ name: 'notification', params: { username: me.username }}">
<a href="#" class="navbar-item">
<span class="icon has-text-grey-light">
<i class="fa fa-bell-o"></i>
</span>
</a>
</router-link>
<div class="navbar-item has-dropdown is-hoverable">
<a href="#" class="navbar-link is-active"><img :src="me.avatar" alt="" class="is-circle">
</a>
<div class="navbar-dropdown is-boxed is-right">
<div class="navbar-item"><strong>
{{ me.name }}
</strong>
</div>
<router-link class="navbar-item" :to="{ name: 'profile', params: { username: me.username }}">
Profile
</router-link>
<hr class="navbar-divider"> <a href="http://localhost:8000/logout" onclick="event.preventDefault();
document.getElementById('logout-form').submit();" class="navbar-item ">
Logout
</a>
<form id="logout-form" action="http://localhost:8000/logout" method="POST" style="display: none;">
<input type="hidden" name="_token" value="c91zxFt0PHQZyqHkOIorF2iEgBtwW5qpDc3Nbc12">
</form>
</div>
</div>
</div>
</div>
</div>
</nav>
<transition name="content">
<router-view></router-view>
</transition>
<!-- main router view outlet -->
</div>
<!-- end app wrap -->
</template>
<script>
export default {
computed: {
isLoading() {
return this.$store.getters.isLoading;
},
me() {
return this.$store.getters.me
}
},
created() {
this.$store.dispatch('loginUser');
}
}
</script>
In this component we have navbar and the router-view which will replaced by route Component. For logout we used same Laravel boilerplate code which submits a form to logout endpoint.
In created component lifecycle hook, we have dispatched an action to loginUser, which gets currently logged in user and put it in state as me
.
Create actions file resources/assets/js/store/actions.js
and add following code:
import { http, str } from '../utils'
// Get authenticated user
export const loginUser = ({ commit }) => {
// show loading
commit('TOGGLE_LOADING');
return http.get( '/me').then(res => {
// hide loader
commit('TOGGLE_LOADING');
commit('ADD_ME', res.data );
return res.data;
})
}
We have used our http wrapper to make call and get the currently logged in user and store them in me state using mutation. We have also used some commit('TOGGLE_LOADING');
which toggles the status of loader spinner. Here is how the mutations looks like, create mutaion file in resources/assets/js/store/mutations.js
and add following code:
// Add me user
export const ADD_ME = (state, user) => {
state.me = user
}
// Toggle loading
export const TOGGLE_LOADING = state => {
state.isLoading = !state.isLoading
}
Now we have our app main App Component done, lets move on to the next creating vue instance.
Create Vue Instance with Vuex and Vue-Router
We have all the required things for this app, now its time to create our Vue instance with router and vuex store.
// Bootstrap the app
require('./bootstrap');
// Import the Vue
window.Vue = require('vue');
// Import router and vuex store
import router from './router'
import store from './store'
// Configs
Vue.config.devtools = true
Vue.config.productionTip = false
// Components
Vue.component('profile-card', require('./components/ProfileCard'));
Vue.component('follow-suggestions', require('./components/FollowSuggestions'));
Vue.component('side-footer', require('./components/SideFooter'));
// App root Component
import App from './components/App'
// Vue instance
const app = new Vue({
el: '#app',
router,
store,
render: h => h(App)
});
In here we have added three more components which will fit in left and right sidebar. Go ahead and create some placeholder component for ProfileCard, FollowSuggestions and SideFooter.
Now we can build and server app by running npm run watch
to keep the watcher running and we can go ahead building each features one by one.
Don’t forgot to serve your application using php artisan serve which will launch it on http://localhost:8000/
Load the Vue App from / route of Laravel
By default laravel auth redirect url is /home
, you need to change in all the Auth controller so it redirects back to / by updating the protected $redirectTo = '/';
property of all the conrollers inside app/Http/Controllers/Auth/ folder.
Next let’s create the dashboard.blade.php
in resources/views/
directory and add these markup:
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'QTwitter') }}</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<!-- Styles -->
<link href="{{ asset('css/app.css') }}" rel="stylesheet">
</head>
<body>
<div id="app">
<div class="hero has-text-centered is-fullheight">
<div class="hero-body">
<div class="has-text-centered" style="width: 100%">
<img width="200" src="/img/qTwitter-logo.png" alt="QTwitter"> <br>
<span class="has-text-grey-light">Loading...</span>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}"></script>
</body>
</html>
The main div is with id="app"
which contains some child element for loading screen purpose, it will show while vue mounts and eventually it will be replace by Root App Component.
Now let’s update the routes/web.php
to show the dashboard when user logged in. I am using anonymous function closer for this route but you might extract it in a controller.
Route::get('/', function () {
return auth()->check() ? view('dashboard') : view('welcome');
});
With that you should see the Vue app loaded when you visit by login to http://localhost:8000 or whatever is your app dev url happens to be.
Build the Twitter Dashboard
Let’s see what we need in this dashboard feed, one component to list the tweet, and a tweet component for a single tweet.
Tweet Component
Tweet component will be styled as media object, it will get the tweet as prop and show it. Create a file resources/assets/js/components/Tweet.vue
and add following code:
<template>
<article class="media tweet">
<figure class="media-left">
<p class="image is-64x64 is-circle">
<router-link :to="{ name: 'profile', params: { username: tweetData.user.username }}">
<img :src="tweetData.user.avatar">
</router-link>
</p>
</figure>
<div class="media-content">
<div class="content">
<p class="tweet-meta">
<router-link class="has-text-dark" :to="{ name: 'profile', params: { username: tweetData.user.username }}">
<strong>{{ tweetData.user.name }}</strong>
</router-link>
<small>@{{ tweetData.user.username }}</small>
<small class="has-text-grey-light">{{ tweetData.created_at }}</small>
</p>
<div class="tweet-body has-text-grey" @click="tweetDetail" v-html="tweetData.body"></div>
</div>
<nav class="level is-mobile">
<div class="level-left">
<a class="level-item has-text-grey-light">
<span class="icon is-small"><i class="fa fa-comment-o"></i></span> <small> {{ tweetData.replies_count }}</small>
</a>
<a class="level-item has-text-grey-light">
<span class="icon is-small"><i class="fa fa-heart"></i></span> <small> {{ tweetData.likes_count }}</small>
</a>
</div>
</nav>
</div>
<div class="media-right" v-if="me.username === tweetData.user.username">
<button @click="remove(tweetData.id)" class="delete"></button>
</div>
</article>
</template>
<script>
export default {
name: 'Tweet',
props: ['tweet'],
data() {
return {
tweetData: this.tweet
}
},
computed: {
me() {
return this.$store.getters.me
}
},
methods: {
remove(tweetId) {
if( window.confirm('Are you sure want to delete this tweet?') ) {
this.$store.commit('DELETE_TWEET', tweetId)
}
}
}
}
</script>
In this media object, we have a dummy remove button if the user is the owner of current tweet. In a complex app it should have other features for example Like a tweet, activity and other meta info. Now we need the Tweet list component.
Tweet List component
This component will be the parent for tweet, it will simply list the tweets fetched from GraphQL Api.
Create resources/assets/js/components/TweetList.vue
and add following code:
<template>
<div class="tweets card-content p-x-1">
<tweet v-for="tweet in tweets" :key="tweet.id" :tweet="tweet"></tweet>
<p v-if="!tweets.length && !loading" class="has-text-centered has-text-grey-light">
<i class="fa fa-notification"></i> no tweets found...
</p>
</div>
</template>
<script>
import Tweet from "./Tweet";
export default {
components: {Tweet},
name: 'TweetList',
props: ['tweets'],
computed: {
loading() {
return this.$store.getters.isLoading;
}
}
}
</script>
We have used vuex state getter to get the current loading state to toggle the spinner in UI. Now we will be building the Dashboard Page component.
Dashboard Page Component
Open the resources/assets/js/Pages/Dashboard.vue
and update it with following code:
<template>
<div class="bg-light is-fullheight p-t-2">
<div class="container">
<div class="columns">
<div class="column is-3">
<div class="is-sticky" style="top: 5rem;">
<profile-card :user="me"></profile-card>
</div>
<!-- end profile card -->
</div>
<!--end sidebar-->
<div class="column is-6">
<div class="card">
<tweet-box :user="me"></tweet-box>
<tweet-list :tweets="tweets"></tweet-list>
</div>
<button :disabled="noMoreTweets" @click.prevent="loadMore" :class="{'is-loading': loading}" class="button m-t-1 m-b-1 is-fullwidth">
{{ noMoreTweets ? 'No more tweets...' : 'Load more...' }}
</button>
</div>
<!--end main content area-->
<div class="column is-3">
<div class="is-sticky" style="top: 5rem;">
<follow-suggestions></follow-suggestions>
<side-footer></side-footer>
</div>
</div>
<!--end right sidebar-->
</div>
</div>
</div>
</template>
<script>
import TweetBox from "../components/TweetBox";
export default {
components: {TweetBox},
data() {
return {
loading: false,
nextOffset: 26,
perPage: 26,
noMoreTweets: false
}
},
computed: {
me() {
return this.$store.getters.me
},
tweets() {
return this.$store.getters.feed
}
},
created() {
this.fetchFeed();
console.log('Get user feed')
},
methods: {
fetchFeed() {
this.noMoreTweets = false;
this.$store.dispatch('getDashboardFeed')
},
loadMore() {
let vm = this;
vm.loading = true;
this.$store.dispatch('getDashboardFeed', {offset: this.nextOffset}).then(function (res) {
vm.loading = false;
if( res.data.tweets.length === vm.perPage ) {
vm.nextOffset = vm.nextOffset+vm.perPage;
} else {
vm.noMoreTweets = true;
}
});
}
},
watch: {
'$route' () {
this.fetchFeed();
}
}
}
</script>
Everything is self-explanatory, in fetchFeed()
method we are getting the tweets for the user according to his / her followings by dispatching action getDashboardFeed
. We have a loadMore
method to paginate the data, and we are watching the route change to load the feed whenever route changes.
Open the resources/assets/js/store/actions.js
and here is the code to fetch the feed for dashboard, main part is the query.
// Get dashboard feed
export const getDashboardFeed = ({commit}, pager={}) => {
let from = pager.offset ? ',offset:' + pager.offset : '';
let to = pager.limit ? 'first:' + pager.limit : 'first:26';
let query = `{
tweets(${to}${from}){
id,
body,
created_at,
replies_count,
likes_count,
user{
username,
name,
avatar
}
}
}`;
// show loading
commit('TOGGLE_LOADING');
let cacheBuster = new Date().getMilliseconds();
return http.get('/graphql?query=' + str.stripLines(query) + '&t=' + cacheBuster).then(function (res) {
// hide loader
commit('TOGGLE_LOADING');
if( pager.offset ) {
commit('APPEND_TWEETS_IN_FEED', res.data.data.tweets)
} else {
commit('ADD_TWEETS_IN_FEED', res.data.data.tweets)
}
return res.data
})
}
We have called the API with a query and if we have asked for an offset in pagination then we will append the Tweets in current feed, but if it’s a fresh call we have add the entire tweets on the feed.
GraphQL API has been updated with new query and I have optimized it with eager loading relations. You should checkout first part where we have implemented GraphQL in Laravel
You must have notice I have used str.stripLines
which is a string utility I created in resources/assets/js/utils/str.js to clean the newline in GET request, its not required though.
Post Tweet using Tweet Box component
As you might notice I have added a Tweet Box component <tweet-box :user="me"></tweet-box>
in Dashboard above the Tweet List. let’s create this component which we will use to post a Tweet and later to post a reply on a tweet.
Create a component resources/assets/js/components/TweetBox.vue
and add following code.
<template>
<div class="card-content bg-light">
<div class="media">
<div class="media-left">
<figure class="image is-32x32 is-circle">
<img :src="user.avatar" alt="Image">
</figure>
</div>
<div class="media-content">
<form action="">
<div class="field">
<div class="control">
<textarea :maxlength="maxLength" rows="3" v-model="body" class="textarea" :placeholder="placeholder"></textarea>
</div>
<p v-if="errorMsg" class="help-block help is-danger">{{ errorMsg }}</p>
</div>
<div class="level">
<div class="level-left">
<a class="has-text-grey-light">
<span class="icon">
<i class="fa fa-image"></i>
</span>
</a>
</div>
<div class="level-right">
<div class="level-item has-text-grey">{{ maxLength - body.length }}</div>
<div class="level-item">
<button @click.prevent="submit" class="button is-outlined is-primary" :class="{'is-loading': loading }">
{{ btnText }}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
<!--tweet box end-->
</div>
</template>
<script>
export default {
name: 'TweetBox',
props: {
user: Object,
maxLength: {default: 200, type: Number},
isReply: false,
btnText: {default: 'Tweet'},
placeholder: {default: 'Whats happening?'},
},
computed: {
tweetId() {
return this.$store.getters.tweetDetail.id;
},
},
data() {
return {
body: '',
errorMsg: '',
loading: false
}
},
created() {
console.log('TweetBox Component created.')
},
methods: {
displayError(res) {
if(res.errors) {
this.errorMsg = res.errors[0].validation.body[0];
} else {
this.body = this.errorMsg = '';
}
},
submit() {
this.loading = true
if(this.isReply) {
this.$store.dispatch('postReply', { body: this.body, id: this.tweetId}).then((res) => {
this.loading = false;
this.displayError(res)
})
} else {
this.$store.dispatch('postTweet', this.body).then((res) => {
this.loading = false;
this.displayError(res)
})
}
}
}
}
</script>
It has a textarea with a max length constrain to prevent a user entering more than 200 chars in this box, by vue binding it shows character count on type, and we handle the submit by checking the isReply
flag to dispatch actions accordingly, and displayErrors take cares the validation error notification part.
Here is the action for mutation of GraphQL API. open the resources/assets/js/store/actions.js
and add these:
// Post Tweet
export const postTweet = ({ commit }, body) => {
let mutation = `mutation {
createTweet(body:"${body}")
{
id,
body,
created_at,
replies_count,
likes_count,
user{
id,
username,
name,
avatar
}
}
}`;
return http.get( '/graphql?query=' + mutation ).then((res) => {
if(res.data.data.createTweet) {
commit('PREPEND_TWEET_IN_FEED', res.data.data.createTweet);
}
return res.data;
});
}
It saves the tweet to DB and prepends the saved tweet in the feed. That’s it now a user can post a tweet.
Twitter Profile Card component
Profile card will be a read only type of component, we will not create the upload avatar, cover feature for simplicity, it’s only going to display a user detail. Create a file resources/assets/js/components/ProfileCard.vue
and add following:
<template>
<div class="card profile-card">
<div class="card-image">
<figure class="image">
<img :src="user.cover" class="image" alt="">
</figure>
</div>
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-64x64 is-circle">
<router-link :to="{ name: 'profile', params: { username: user.username }}">
<img :src="user.avatar" alt="">
</router-link>
</figure>
</div>
<div class="media-content">
<p class="title is-5">
{{ user.name }}
</p>
<p class="subtitle is-6">
<router-link :to="{ name: 'profile', params: { username: user.username }}">
@{{ user.username }}
</router-link>
</p>
</div>
</div>
<nav class="level">
<div class="level-item has-text-centered">
<div>
<router-link :to="{ name: 'profile', params: { username: user.username }}">
<p class="is-6 has-text-grey-light">
<small>Tweets</small>
</p>
<p class="is-4">{{ user.tweets_count || 0 }}</p>
</router-link>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<router-link :to="{ name: 'following', params: { username: user.username }}">
<p class="is-6 has-text-grey-light">
<small>Following</small>
</p>
<p class="is-4">{{ user.following_count || 0 }}</p>
</router-link>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<router-link :to="{ name: 'followers', params: { username: user.username }}">
<p class="is-6 has-text-grey-light">
<small>Followers</small>
</p>
<p class="is-4">{{ user.followers_count || 0 }}</p>
</router-link>
</div>
</div>
</nav>
</div>
</div>
</template>
<script>
export default {
name: 'ProfileCard',
props: ['user']
}
</script>
By bulma card component I have created the user Profile Card with user info and followers, following and tweets count.
User Profile Component
Lets complete user profile component which will list all the tweets by user, now we can reuse TweetList component here, we just need to fetch the user info with tweets by given username
as route parameter.
Create a file resources/assets/js/Pages/Profile.vue
and add following:
<template>
<div class="bg-light is-fullheight">
<section class="hero is-medium is-primary profile-cover is-bold" :style="{'background': 'url(' + profileData.cover + ')'}">
<div class="hero-body">
<div class="container">
</div>
</div>
</section>
<section class="sub-nav">
<div class="container">
<div class="columns">
<div class="column is-3">
<img class="is-circle profile-pic image" width="90%" :src="profileData.avatar" alt="">
</div>
<div class="column is-6">
<profile-nav :user="profileData"></profile-nav>
</div>
<div class="column is-3 has-text-right">
<a v-if="profileData.id === $parent.me.id" href="" class="button">Edit Profile</a>
<follow-button :following.sync="profileData.is_following" v-if="profileData.id != $parent.me.id" :user="profileData"></follow-button>
</div>
</div>
</div>
</section>
<div class="container m-t-2">
<div class="columns">
<div class="column is-3">
<profile-info :user="profileData"></profile-info>
</div>
<!--end sidebar-->
<div class="column is-6">
<div class="card">
<tweet-list :tweets="profileData.tweets"></tweet-list>
</div>
<button v-if="profileData.tweets.length < profileData.tweets_count" :class="{'is-loading': loading}" class="button m-t-1 m-b-4 is-fullwidth">
Load more...
</button>
</div>
<!--end main content area-->
<div class="column is-3">
<div class="is-sticky" style="top: 4.5rem;">
<follow-suggestions></follow-suggestions>
<side-footer></side-footer>
</div>
</div>
<!--end right sidebar-->
</div>
</div>
</div>
</template>
<script>
import FollowButton from "../components/FollowButton";
import ProfileNav from "../components/ProfileNav";
import ProfileInfo from "../components/ProfileInfo";
export default {
components: {
ProfileInfo,
ProfileNav,
FollowButton
},
name: 'Profile',
props: ['username'],
data() {
return {
loading: false
}
},
computed: {
profileData() {
return this.$store.getters.profilePage
}
},
created() {
this.fetchTweets();
console.log('Profile Component created.')
},
methods: {
fetchTweets() {
this.$store.dispatch('getTweetsByUsername', this.username)
}
},
watch: {
'$route' () {
this.fetchTweets();
}
}
}
</script>
This component fetches tweets and we access the profileData from vuex store using computed property profileData
. ProfileNav and ProfileInfo are some static components which I have extracted to use in other pages.
Here is the action getTweetsByUsername
query details in actions.js:
// Get tweets for a user by username
export const getTweetsByUsername = ({commit}, username) => {
let query = `{
user(username:"${username}") {
id,
username,
name,
avatar,
cover,
created_at,
followers_count,
following_count,
is_following,
likes_count,
tweets_count,
profile {
bio,
city,
country
},
tweets(first:16){
id,
body,
created_at,
replies_count,
likes_count,
user{
id,
username,
name,
avatar
}
}
}
}`;
// show loading
commit('TOGGLE_LOADING');
return http.get( '/graphql?query=' + str.stripLines(query) ).then(function (res) {
// hide loader
commit('TOGGLE_LOADING');
commit('PROFILE_PAGE', res.data.data.user)
return res.data
})
}
Similar things, only the query has changed and we have used different PROFILE_PAGE
mutation to update the vuex state.
Follow User
Now we will be building this follow user toggle button, it will be a reusable component, so we can use it in the profile page as well in follow suggestion component and tweet details component which we will work next. This button will take a following prop to display current state, if user is following text and color will change to Un Follow and vice versa.
Create the component resources/assets/js/components/FollowButton.vue
and add following code:
<template>
<a href="" @click.prevent="toggleFollow" class="button" :class="btnClasses">
<span class="icon is-small">
<i class="fa fa-flash"></i>
</span>
<span>
{{ isFollowing ? 'Unfollow' : 'Follow' }}
</span>
</a>
</template>
<script>
export default {
name: 'followButton',
props: {
user: Object,
following: {type: Boolean, default: false}
},
data() {
return {
loading: false,
isFollowing: this.following
}
},
computed: {
btnClasses() {
return {
'is-danger': this.isFollowing,
'is-primary' : !this.isFollowing,
'is-loading': this.loading
}
}
},
methods: {
toggleFollow() {
this.loading = true;
let action = this.user.is_following ? 'unFollowUser' : 'followUser';
this.$store.dispatch(action, this.user.id).then((res) => {
this.loading = false;
this.isFollowing = !this.isFollowing;
this.$emit(action, this.user);
})
}
},
watch: {
'following' (to) {
this.isFollowing = to;
}
}
}
</script>
Depending on state on click toggleFollow method gets triggered, in actions.js we need two more actions:
// Follow a user
export const followUser = ({commit}, userId) => {
let mutation = `mutation {
followUser(user_id: ${userId})
{
id,
name,
avatar
}
}`;
return http.get( '/graphql?query=' + str.stripLines(mutation) ).then((res) => {
commit('INCREMENT_FOLLOW_USER_COUNT');
});
}
// Unfollow a user
export const unFollowUser = ({commit}, userId) => {
let mutation = `mutation {
unFollowUser(user_id: ${userId})
{
id,
name,
avatar
}
}`;
return http.get( '/graphql?query=' + str.stripLines(mutation) ).then((res) => {
commit('DECREMENT_FOLLOW_USER_COUNT');
});
}
Now lets create the side bar Follow Suggestions component.
Create Follow Suggestions Component
This component will show random suggestion from unfollowed users in the system with a follow button to quickly follow a user.
Create the component resources/assets/js/components/FollowSuggestions.vue
and implement it:
<template>
<div class="card follow-suggestions">
<header class="card-header">
<p class="card-header-title">
Who to follow
-
<a :disabled="loading"
style="font-weight: normal"
class="button is-small is-link"
@click.prevent="refresh"> refresh</a>
</p>
</header>
<div class="card-content is-transparent" :class="{'is-loading': loading}">
<article class="media" v-for="user in suggestions" :key="user.id">
<figure class="media-left">
<router-link :to="{ name: 'profile', params: { username: user.username }}">
<p class="image is-64x64 is-circle">
<img alt="" :src="user.avatar">
</p>
</router-link>
</figure>
<div class="media-content">
<div class="content">
<p class="m-b-0">
<router-link :to="{ name: 'profile', params: { username: user.username }}">
<strong class="has-text-dark">{{ user.name }}</strong>
</router-link>
<small>@{{ user.username }}</small>
</p>
<follow-button :user="user" @followUser="handleFollowed" class="is-small"></follow-button>
</div>
</div>
</article>
<div v-if="!suggestions.length && !loading" class="has-text-centered">
<span class="has-text-grey">Currently no suggestions to follow user</span>
</div>
</div>
</div>
</template>
<script>
import FollowButton from "./FollowButton";
export default {
components: {FollowButton},
name: 'FollowSuggestions',
props: {
limit: {
default: 3, type: Number
}
},
data() {
return {
loading: true
}
},
computed: {
suggestions() {
return this.$store.getters.followerSuggestions;
}
},
created() {
this.fetchSuggestions();
},
methods: {
fetchSuggestions() {
this.loading = true
this.$store.dispatch('getFollowUserSuggestions', this.limit).then((res) => {
this.loading = false;
});
},
handleFollowed(user) {
this.$store.commit('REMOVE_FROM_FOLLOW_SUGGESTION', user);
},
refresh() {
this.fetchSuggestions()
}
},
watch: {
'suggestions' (to) {
if(to.length === 0) {
this.refresh();
}
}
}
}
</script>
This gets the data by calling getFollowUserSuggestions
action on vuex, in actions.js add these:
// Get follow user suggestions
export const getFollowUserSuggestions = ({commit}, limit) => {
let limitQuery = `(first:${limit})`;
let query = `{
followSuggestions${limitQuery}
{
id,
name,
username,
avatar
}
}`;
return http.get( '/graphql?query=' + str.stripLines(query) ).then(function (res) {
commit('ADD_FOLLOW_SUGGESTIONS', res.data.data.followSuggestions)
});
}
We hook into follow event by follow button component and remove the followed members from suggestions, and keep watch on the suggestions computed property which if its goes zero we refresh the suggestions.
List user Following & Followers
Similar to profile details we will add the followers and following the listing of a user by username. In this componet, we will reuse ProfileCard and a follower list component very similar to TweetList.
<template>
<div class="bg-light is-fullheight">
<section class="hero is-medium is-primary profile-cover is-bold" :style="{'background': 'url(' + profileData.cover + ')'}">
<div class="hero-body">
<div class="container">
</div>
</div>
</section>
<section class="sub-nav">
<div class="container">
<div class="columns">
<div class="column is-3">
<img class="is-circle profile-pic image" width="90%" :src="profileData.avatar" alt="">
</div>
<div class="column is-6">
<profile-nav :user="profileData"></profile-nav>
</div>
<div class="column is-3 has-text-right">
<a v-if="profileData.id === $parent.me.id" href="" class="button">Edit Profile</a>
<follow-button :following.sync="profileData.is_following" v-if="profileData.id != $parent.me.id" :user="profileData"></follow-button>
</div>
</div>
</div>
</section>
<div class="container m-t-2">
<div class="columns">
<div class="column is-3">
<profile-info :user="profileData"></profile-info>
</div>
<!--end sidebar-->
<div class="column is-9">
<follower-list :users="following"></follower-list>
<button v-if="following.length < profileData.following_count" :class="{'is-loading': loading}" class="button m-t-1 m-b-4 is-fullwidth">
Load more...
</button>
</div>
<!--end main content area-->
</div>
</div>
</div>
</template>
<script>
import FollowButton from "../components/FollowButton";
import FollowerList from "../components/FollowerList";
import ProfileNav from "../components/ProfileNav";
import ProfileInfo from "../components/ProfileInfo";
export default {
components: {
ProfileInfo,
ProfileNav,
FollowerList,
FollowButton},
name: 'Following',
props: ['username'],
data() {
return {
loading: false,
profileData: {
cover: '/img/cover-placeholder.jpg',
avatar: '/img/avatar-placeholder.svg',
profile: {},
following: []
}
}
},
computed: {
following() {
return this.profileData.following
}
},
created() {
this.fetchTweets();
console.log('Profile Component created.')
},
methods: {
fetchTweets() {
let vm = this;
this.$store.dispatch('getFollowUserByUsername', {username: this.username, type: 'following'}).then(function (res) {
vm.profileData = res.data.user
})
}
},
watch: {
'$route' () {
this.fetchTweets();
}
}
}
</script>
We are getting data in created method and rendering the user with FollowerList component which uses profileCard to display the user in a grid.
FollowerList Component
Here is the followers list component implementation
<template>
<div class="card-list">
<div class="columns" v-for="users in chunkedUsers">
<div class="column is-4" v-for="user in users" :key="user.id">
<profile-card class="card-equal-height" :user="user"></profile-card>
</div>
<p v-if="!users.length && !loading" class="has-text-centered has-text-grey-light">
no users found...
</p>
</div>
</div>
</template>
<script>
import ProfileCard from "./ProfileCard";
export default {
components: {ProfileCard},
name: 'FollowerList',
props: {users: {type: Array, default: []}},
data() {
return {
}
},
computed: {
loading() {
return this.$store.getters.isLoading;
},
chunkedUsers() {
return _.chunk(this.users, 3);
}
},
created() {
console.log('FollowerList Component created.')
}
}
</script>
On created method we chunked the following array data into groups of 3 using loadash chunk method, then we loop it into a grid. All the vuex actions for getting followers and following are very similar to previous, you check them in GitHub repo.
For followers, you can replicate everything just change the query in action to get the followers of user instead of following.
Tweet Details Popup
We have a listing of tweets, now let’s show the detail view in Twitter like popup with replies for a tweet. Lets build a Popup, but where should we put it, a tweet listing is reused in many places, including it in every component like Dashboard, User Profile will be not ideal, so I am going to put this on the root level App Component, and we will use vuex to open the popup and in a given time this will make sure only one popup is opened.
Let’s create the TweetDetails component in resources/assets/js/components/TweetDetail.vue
<template>
<transition name="modal">
<div class="modal-mask">
<div class="modal-wrapper">
<a class="button-close delete is-large" @click="$emit('close')"></a>
<div class="modal-container modal-loading" style="height: 30vh" :class="{'is-loading' : loading }" v-show="loading"></div>
<div class="modal-container" v-if="!loading">
<div class="modal-header">
<div class="level">
<div class="level-left">
<article class="media">
<figure class="media-left">
<a href="" class="has-text-dark" @click.prevent="goToProfile(tweetDetails.user.username)">
<p class="image is-circle is-64x64">
<img :src="tweetDetails.user.avatar">
</p>
</a>
</figure>
<div class="media-content">
<div class="content">
<p>
<a href="" class="has-text-dark" @click.prevent="goToProfile(tweetDetails.user.username)">
<strong>{{ tweetDetails.user.name }}</strong> <br>
</a>
<small class="has-text-grey">@{{ tweetDetails.user.username }}</small>
</p>
</div>
</div>
</article>
</div>
<div v-if="me.username != tweetDetails.user.username" class="level-right">
<follow-button class="is-outlined" :user="tweetDetails.user" :following="tweetDetails.user.is_following"></follow-button>
</div>
</div>
</div>
<!--end tweet header-->
<div class="modal-body">
<div class="tweet-body is-size-4" v-html="tweetDetails.body"></div>
<div class="tweet-meta m-t-1 level">
<div class="level-left">
<p class="has-text-grey-light">{{ tweetDetails.updated_at }} • {{ tweetDetails.created_at }} </p>
</div>
<div class="level-right">
<div class="level-item has-text-grey-light">
<span class="icon is-small">
<i class="fa fa-reply"></i>
</span>
<small> {{ tweetDetails.replies.length || 0 }}</small>
</div>
<div class="level-item has-text-grey-light">
<span class="icon is-small">
<i class="fa fa-heart"></i>
</span>
<small> {{ tweetDetails.likes_count || 0 }}</small>
</div>
</div>
</div>
</div>
<!--end modal body-->
<div class="modal-footer">
<tweet-box placeholder="Reply your thoughts..." btn-text="Reply" :user="me" :is-reply="true" class="m-b-1"></tweet-box>
<div class="replies">
<article v-for="(reply, index) in tweetDetails.replies" class="media reply p-l-1">
<figure class="media-left">
<p class="image is-64x64 is-circle">
<img :src="reply.user.avatar">
</p>
</figure>
<div class="media-content">
<div class="content">
<div class="has-text-grey reply-body" v-html="reply.body"></div>
<div class="reply-meta p-x-1">
<div class="level">
<div class="level-left">
<a class="has-text-grey" href="" @click.prevent="goToProfile(reply.user.username)">
<strong>{{ reply.user.name }}</strong> <small class="has-text-grey-light">@{{ reply.user.username }}</small>
</a>
</div>
<div class="level-right">
<small class="has-text-primary">{{ reply.created_at }}</small>
</div>
</div>
</div>
</div>
</div>
<div class="media-right">
<button @click="remove(index)" v-if="reply.user.username === me.username" class="delete"></button>
</div>
</article>
</div>
</div>
<!--end modal footer-->
</div>
</div>
</div>
</transition>
</template>
<script>
import FollowButton from "./FollowButton";
import TweetBox from "./TweetBox";
export default {
components: {
TweetBox,
FollowButton},
name: 'TweetDetail',
props: ['tweetId'],
data() {
return {
loading: true
}
},
computed: {
tweetDetails() {
return this.$store.getters.tweetDetail;
},
me() {
return this.$store.getters.me;
}
},
created() {
this.fetchTweetDetails()
document.body.classList.add('popup-shown')
console.log('TweetDetail Component created.')
},
destroyed() {
document.body.classList.remove('popup-shown')
console.log('TweetDetail Component destroyed.')
},
methods: {
fetchTweetDetails() {
let vm = this;
vm.loading = true
// Reset old tweet details
vm.$store.commit('ADD_TWEET_DETAIL', {
user: {},
replies: []
});
this.$store.dispatch('getTweetDetails', { id: this.tweetId }).then((res) => {
vm.loading = false
})
},
remove(index) {
if( window.confirm('Are you sure want to delete this reply?') ) {
this.$store.commit('DELETE_REPLY', index)
}
},
goToProfile(username) {
this.$emit('close');
this.$router.push({ name: 'profile', params: { username: username }});
}
}
}
</script>
It needs the TweetId as param, then it fetch the tweet details with replies and other meta info. On created
hook it adds popup-shown in and removes it on destroyed
for some css overlay behaviour.
The action getTweetDetails
queries the tweet info from GraphQL API
// Get tweet details with replies
export const getTweetDetails = ({commit}, options) => {
let from = options.offset ? ',offset:' + options.offset : '';
let to = options.limit ? 'first:' + options.limit : 'first:26';
let query = `{
tweets(id:${options.id}){
id,
body,
created_at,
updated_at,
replies_count,
likes_count,
user{
id,
username,
name,
avatar,
is_following
},
replies(${to}${from}){
body,
created_at,
user{
username,
name,
avatar,
}
}
}
}`;
let cacheBuster = new Date().getMilliseconds();
return http.get('/graphql?query=' + str.stripLines(query) + '&t=' + cacheBuster).then(function (res) {
commit('ADD_TWEET_DETAIL', res.data.data.tweets[0]);
return res.data
})
}
Tweet Reply
We have also added TweetBox as a reply box here by passing is-reply prop as true. It posts a reply to current Tweet on submit and prepends it into replies array of tweet, it has a dummy reply remove method as well which you can implement to persist it on server currently it only remove from vuex store.
Conclusion
This post is already very long, It will be not feasible to develop all the features of Twitter in a single post, but with above knowledge, you can get going and improve upon this and add features you like. We have also learned how we can query a GraphQL API and build one on laravel. I hope it helps you in understanding how you can create a SPA with vue.js and vuex which can be scalable and maintainable. As always you have full source code, play with it and let me know if you need any help.
Hi Mohd, you are doing a very good job here. I have learnt a lot through this page. I will recommend this to anyone willing to learn Laravel practically. Thanks.