YouTube like app with Vue.js and Laravel

Earlier we have build our API Create REST API with authentication using Laravel Passport for youtube like app to share videos and comment on them, In this part we are going to implement front-end with Vue.js v2 and improve our API. This will give you very good idea on how you can create app with Vue.js Component, Vue Router and Laravel. Since Laravel 5.4 comes with bootstrap out of the box we will use it for UI. Let’s get started by installing Vue router configuring it.

qtube using laravel and vue js

Source Code Demo

Vue Router

Pull the vue-router by running npm install vue-router --save in terminal, now go to resources/assets/js/ and create a folder routes and inside it create a file index.js, lets define routes in this file.

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router);

Now what routes we will need, for start we need root / page, Trending & Subscription route. Lets add them:

import HomePage from '../components/page/HomePage.vue'
import TrendingPage from '../components/page/TrendingPage.vue'
import SubscriptionPage from '../components/page/SubscriptionPage.vue'

export default new Router({
    // mode: 'history', // to enable html5 history api
    routes: [
        {
            path: '/',
            name: 'HomePage',
            component: HomePage
        },
        {
            path: '/trending',
            name: 'TrendingPage',
            component: TrendingPage
        },
        {
            path: '/subscriptions',
            name: 'SubscriptionPage',
            component: SubscriptionPage
        }
    ]
});

Before going further lets create some page components, under components folder create page and add above components by just copying and pasting Example.vue which comes with Laravel installation and renaming them into HomePage.vueTrendingPage.vue and SubscriptionPage.vue we will later update them as we progress.

Now you can fire npm run watch command in terminal so you can keep working and it will be running webpack and mixing sass whenever you change file. In a second terminal window run php artisan serve and you should be able to access you laravel app on http://127.0.0.1:8000.

In order to see our routes we need to add <router-view></router-view> in our resources/views/welcome.blade.php and add the routes on our vue instance which is in resources/assets/js/app.js.

Vue <router-view> in welcome.blade.php
<router-view>
    {{--placeholder to show while Vue is loading on page load first time--}}
    <p class="text-center" style="padding: 2em;">
        <span class="glyphicon glyphicon-refresh spin"></span> Loading...
    </p>
</router-view>
Add our router on Vue Instance
import router from './routes';

// beforeEach route scroll to top
router.beforeEach( (to, from, next) => {
    window.scrollTo(0,0);
    next(true);
});

/**
 * Vue Instance
 */
const app = new Vue({
    router,
    ...

Here we add router on Vue instance and also added a Global Navigation Guard which only handles scrolls to top on route change. Our router is now configured, now if you refresh app you can access the routes and see the component changes by visiting #/trending and #/subscriptions routes.

Main Navigation

Lets add top Navigation to display Home, Trending & Subscription page. open the welcome.blade.php and add this on top.

<div class="sub-nav text-center" v-cloak="">
    <router-link active-class="active" :to="{ name: 'HomePage'}" exact>Home</router-link>
    <router-link active-class="active" :to="{ name: 'TrendingPage'}">Trending</router-link>
    <router-link active-class="active" :to="{ name: 'SubscriptionPage'}">Subscriptions</router-link>
</div>

<router-link> will add the links to route & active class will be added automatically by vue router.

One consequence of this is that <router-link to="/"> will be active for every route! To force the link into “exact match mode”, use the exact prop which we have for HomePage.

You can refresh the page to check router link should take you to respective pages.

Now that we have router and top navigation ready its time to work on Individual components, lets start with HomePage.vue, in this we will list thumbnails of video with title and created at date time etc.

Vue-progressbar

We need to have the youtube like progress bar on the top, I am using this progress bar which serves our purpose. Please install and configure it. I kept it below our <router-view>.

HomePage.vue

Here is the code for this component, we have used axios to make http call and vue-progressbar to show it while request goes through.

<template>
    <div class="container">
        <div class="row">
            <div class="col-md-12">
                <div class="panel panel-default">
                    <div class="panel-heading">
                        Recommended
                    </div>
                    <div class="panel-body">
                        <video-thumb :list="videos.data"></video-thumb>
                    </div>
                </div>

                <div class="panel panel-default">
                    <div class="panel-heading">
                        Recently uploaded
                    </div>

                    <div class="panel-body">
                        <video-thumb :list="videos.data"></video-thumb>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
    export default {
        data() {
            return {
                videos: {
                    data: []
                }
            }
        },

        mounted() {
            this.$Progress.start();

            axios.get('/api/videos').then((res) => {
                this.$Progress.finish();
                this.videos = res.data;
            }).catch((err) => {
                this.$Progress.finish();
                console.log(err);
            });

            console.log('Home Component mounted.')
        }
    }
</script>

We have added two sections of video but you can add as many as you like.

Recently uploaded & Recommended both videos are same in this case, but you can fetch different sets of videos for any section by just changing some where clause on api.

It turns out we will be using this thumbnail list view throughout the app so Its good sign we should extract it into its own component, lets name it /components/VideoThumb.vue.

VideoThumb.vue

<template>
    <div class="row">
        <div v-for="video in list"  class="video-grid col-xs-6 col-sm-4 col-md-3">
            <div class="video">

                <div class="thumbnail">
                    <router-link :to="{ name: 'VideoDetailPage', params: { id: video.id, slug: $root.slug(video.title) }}">
                        <img :src="video.thumbnail" :alt="video.title">
                    </router-link>
                </div>

                <div class="caption">
                    <h3>
                        <router-link :to="{ name: 'VideoDetailPage', params: { id: video.id, slug: $root.slug(video.title) }}">
                            {{ video.title }}
                        </router-link>
                    </h3>
                    <p>
                        <router-link :to="{ name: 'ChannelPage', params: {id: video.channel_id, slug: $root.slug(video.channel.name)}}">
                            {{ video.channel.name }}
                        </router-link>
                        <br>
                        {{ video.views }} views • {{ video.created_at }}</p>
                </div>

            </div>
        </div>
    </div>
</template>

<script>
    export default {
        props: ['list'],

        mounted() {
            console.log('Video Thumb mounted.')
        }
    }
</script>

This component is simple, it takes a list of videos as array and loop through it in the grid. I created one method $root.slug() on root instance since it will be used on many places in this app to generate slug from string.

We also have added two more routes in this component VideoDetailPage and ChannelPage to show video and channel.

Update routes/index.js and add below routes.

import VideoDetailPage from '../components/page/VideoDetailPage.vue'
import ChannelPage from '../components/page/ChannelPage.vue'
...

{
    path: '/channel/:id/:slug',
    name: 'ChannelPage',
    component: ChannelPage,
    props: true
},
{
    path: '/video/:id/:slug',
    name: 'VideoDetailPage',
    component: VideoDetailPage,
    props: true
}

You see props in route definition, it passes route params directly to the Component, for example we need the video id in order to fetch that video from API. Home page is done now lets complete TrendingPage.vue which is similar with only more thing is list of categories for Video.

TrendingPage.vue

<template>
    <div class="container">
        <div class="row">
            <div class="col-md-12">
                <div class="panel panel-default">

                    <div class="panel-heading">
                        <div class="row">
                            <div class="col-sm-6">
                                <div class="dropdown">
                                    <button class="btn btn-default dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
                                        Trending in Category
                                        <span class="caret"></span>
                                    </button>
                                    <ul class="dropdown-menu" aria-labelledby="dropdownMenu1">
                                        <li v-for="cat in categories"><a href="#">{{ cat.name }}</a></li>
                                    </ul>
                                </div>
                            </div>
                            <div class="col-sm-6 text-right">
                               <h5>Category Name</h5>
                            </div>
                        </div>
                    </div>

                    <div class="panel-body">
                        <video-thumb :list="videos.data"></video-thumb>

                        <p class="text-center pager">
                            <a class="btn btn-default" href="#" role="button">Load More</a>
                        </p>
                    </div>

                </div>
            </div>
        </div>
    </div>
</template>

<script>
    export default {
        data() {
            return {
                videos: {
                    data: []
                },
                categories: []
            }
        },

        mounted() {
            this.$Progress.start();
            // change the title of page
            window.document.title = 'Trending on QTube';

            axios.get('/api/videos?trending=true&categories=true').then((res) => {
                this.$Progress.finish();
                this.videos = res.data;
                this.categories = res.data.categories;
            }).catch((err) => {
                this.$Progress.finish();
                console.log(err);
            });

            console.log('Trending Component mounted.')
        }
    }
</script>

As you can see we are passing two params in our get request /api/videos?trending=true&categories=true, in order to make it work I have modified our VideoController@index method like this.

public function index(Request $request)
{
    $query = $this->model->with(['channel']);

    // check for trending
    if ( $request->has('trending')) {
        $query->orderBy('views', 'desc');
    }

    // paginate the result
    $paginated = $query->latest()->paginate()->toArray();

    // check for categories
    if ($request->has('categories')) {
        $paginated['categories'] = Category::select('id', 'name')->get();
    }

    return $paginated;
}

We are just ordering by views in this case but in real world it can be based on most viewed videos today which are having lots of comment, likes etc.

I have skipped implementing subscription feature for brevity.

Now we have list of video thumbnails lets add the VideoDetailPage.vue to show the video with comments. This is going to be long component, but you can extract the comment section in a separate Component, since I am not using it anywhere else in this app I am leaving it in VideoDetailPage component.

VideoDetailPage.vue

Video details page show video and comments on it with some related (not really) videos on sidebar.

video details page

Video Player

Since our seed data is not adding any videos instead we have used lorempixel.com images I have added a check to see if its a youtube url or an image.

<div class="video-player">
    <div v-if="youtubeId" class="videoWrapper">
       <iframe width="560" height="349" :src="'http://www.youtube.com/embed/' + youtubeId + '?rel=0&hd=1&autoplay=1&showinfo=0'" frameborder="0" allowfullscreen></iframe>
    </div>
    <div v-if="!youtubeId"  class="video-card">
       <img class="img-responsive" :src="videoThumb(video.thumbnail)" alt="">
    </div>
</div>
<!-- End Video player -->

Here is the detection of youtubeId in our javascript.

data() {
  return {
      video: {
          channel: {
              id: 1,
              logo: '/img/avatar-placeholder.jpg'
          },
          category: {

          },
          related: []
      },
      youtubeId: false
  }
},
methods: {
    getVideo() {
        this.$Progress.start();
        axios.get('/api/videos/' + this.id + '?related=true' ).then((res) => {
            this.$Progress.finish();
            this.video = res.data;

            // set the youtube id if its youtube video
            this.youtubeId = this.isYoutube(this.video.url);

            // change the title of page
            window.document.title = this.video.title;
        }).catch((err) => {
            this.$Progress.finish();
        });
    },
    isYoutube(url) {
        let pattern = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;
        let matches = url.match(pattern);
        if(matches){
            return matches[1];
        }
        return false;
    }
}

About code fetches video with related video and isYoutube(url) returns VideoID if its a youtube url, we use it to embed the video if its false we simply show a random image from lorempixel.com.

Comment thread

Now since we have our video we need to get the related comments. I have used bootstraps media component to style the list of comments.

<div class="panel panel-default">
    <div class="panel-heading comment-box">Comment</div>

    <div class="panel-body">
        <div class="media" v-if="canComment()">
            <div class="media-left">
                <a href="#">
                    <img width="48" class="media-object" :src="$root.auth.avatar" :alt="$root.auth.name">
                </a>
            </div>
            <div class="media-body">
                <form @submit.prevent="saveComment()" method="post" action="">
                    <div class="form-group">
                        <textarea required name="comment" v-model="newComment" class="form-control" :placeholder="'Commenting as ' + $root.auth.name"></textarea>
                    </div>
                    <div class="text-right">
                        <button type="reset" @click="newComment = null" :disabled="!newComment || commenting" class="btn btn-sm btn-default">Cancel</button>
                        <button type="submit" :disabled="!newComment || commenting" class="btn btn-sm btn-info">
                            <span v-show="commenting" class="glyphicon glyphicon-refresh spin"></span> Comment
                        </button>
                    </div>
                </form>

            </div>
        </div>
        <!-- End Comment form -->

        <div class="text-center" v-if="!canComment()">
            Please <a class="btn btn-xs btn-primary" href="/login">Login</a> to post comment.
        </div>

        <hr>

        <div class="comment-thread">
            <div class="discussions">
                <div class="text-center" v-show="loading">
                    <span class="glyphicon glyphicon-refresh spin"></span>
                </div>

                <h5 v-show="comments.data.length">COMMENTS • {{ comments.data.length }}</h5>

                <div class="media" v-for="(comment, index) in comments.data">
                    <div class="media-left media-top">
                        <a href="">
                            <img width="48" class="media-object" :src="comment.user.avatar" :alt="comment.user.name">
                        </a>
                    </div>
                    <div class="media-body">
                        <div class="dropdown pull-right">
                            <button class="btn btn-sm btn-default dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
                                <span class="glyphicon glyphicon-option-vertical"></span>
                            </button>
                            <ul class="dropdown-menu" aria-labelledby="dropdownMenu1">

                                <li v-if="$root.auth && $root.auth.id == comment.user_id"><a @click.prevent="deleteComment(index)" href="#">
                                    <span v-show="!commenting" class="glyphicon glyphicon-trash text-danger"></span> Delete</a>
                                </li>
                                <!--<li v-if="$root.auth && $root.auth.id == comment.user_id"><a href="#"> <span class="glyphicon glyphicon-pencil"></span> Edit</a></li>-->
                                <li><a href="#"> <span class="glyphicon glyphicon-flag"></span> Report It</a></li>
                            </ul>
                        </div>
                        <h4 class="media-heading"><a href="">{{ comment.user.name }}</a> <small>{{ comment.created_at }}</small></h4>
                        <p class="desc-text">{{ comment.body }}</p>
                    </div>
                </div>
            </div>
        </div>
        <!-- End Comment thread -->
    </div>

</div>
<!-- End Comment -->

In this template you will find canComment() method, it checks if user is logged in, then only show the comment box. So to access the user object I have added it in Laravel gloabal variable where it keeps the reference of CSRF Token, open the resources/views/layouts/app.blade.php and update it like this.

<!-- Scripts -->
<script>
    window.Laravel = {!! json_encode(['csrfToken' => csrf_token()]) !!};

   @if(Auth::check())
     window.Laravel.Auth = {!! json_encode( Auth::user() ) !!};
     window.Laravel.Auth.Videos = {!! json_encode( Auth::user()->videos()->with(['channel', 'category'])->limit(4)->latest()->get() ) !!};
     window.Laravel.Channel = {!! json_encode( Auth::user()->channels()->select('id', 'name', 'logo')->first() ) !!};
  @endif
</script>

Here we have Auth which contains currently logged in user and Auth.Videos which keeps latest 4 video from user and users channel in Channel.

In our component we check logged in user by this canComment().

canComment() {
    return Laravel.hasOwnProperty('Auth')
},

I also assigned Auth object to the root instance of app, which I used in comment box to get the user name and avatar also to show the delete comment dropdown item only for auth user comment.

To save comment we make a post request with the bodyvideo_id from the form @submit.prevent=”saveComment()”.

saveComment() {
    let vm = this;
        vm.commenting = true;

    axios.post('/api/comments', {
        body: this.newComment,
        video_id: this.video.id
    }).then(function (res) {
        vm.newComment = '';
        vm.comments.data.unshift(res.data);
        vm.commenting = false;
    }).catch(function (error) {
        console.log(error);
        vm.commenting = false;
    });
}

I used our glyphicon spinner to show the comment processing state instead of top vue-progressbar in this case which makes more sense in this case.

But unfortunately it will not work since post request is protected on our API, to make a post request you must be have an access token, luckily Laravel passport makes it very easy to consume your own API.

Consuming Your API With JavaScript

Typically, if you want to consume your API from your JavaScript application, you would need to manually send an access token to the application and pass it with each request to your application. However, Passport includes a middleware that can handle this for you. All you need to do is add the CreateFreshApiToken middleware to your web middleware group:

'web' => [
    // Other middleware...
    \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
],

Now Authenticated user can also delete their comment which is handled by this method triggered by @click.prevent=”deleteComment(index)”.

deleteComment(index) {
    let vm = this;
    let comment = vm.comments.data[index];

    if( window.confirm('Are sure want to delete this comment?')) {
        vm.$Progress.start();
        axios.delete('/api/comments/' + comment.id).then(function (res) {
            vm.comments.data.splice(index, 1);
            vm.$Progress.finish();
        }).catch(function (error) {
            console.log(error);
            vm.$Progress.finish();
        });
    }
}

In sidebar we show related videos for this which is just a SidebarThumb.vue component with video list, its very similar to VideoThumb component so I am not covering it, you can check the source code afterwards.

Upload Video

Uploading a video is very simple, logged in user can copy and pastes a youtube url & it validates the url and user enters title, description with category, then we store it on our sever. But we need to protect this route from unauthorized access, to do this we need to add a Guard on our route of upload, of course we have auth check on server but on client we can do it like this.

Protected Vue Route

You can protect the route using beforeEnter guard on route.

{
    path: '/upload',
    name: 'UploadPage',
    component: UploadPage,
    beforeEnter: (to, from, next) => {
        if( window.Laravel.hasOwnProperty('Auth') ) {
            next(true);
        } else {
            next(false);
            alert('Please login to upload a video');
        }
    }
},

This will redirect user to root / page if note logged in.

UploadPage.vue

Here is complete component to handle uploading video with validation and thumbnail preview.

<template>
<div class="container">
    <div class="row">
        <div class="col-md-10 col-md-offset-1">
            <div class="panel panel-default">
                <div class="panel-heading">Upload a Video</div>
                <div class="panel-body">
                    <p class="alert text-center alert-warning">Choose a Category & Paste <a href="https://www.youtube.com/" target="_blank">Youtube</a> video link.</p>

                    <div class="row">
                        <div class="col-md-8">
                            <form method="post" @submit.prevent="uploadVideo()" class="form-horizontal">
                                <div class="form-group">
                                    <label for="category" class="col-sm-2 control-label">Category</label>
                                    <div class="col-sm-10">
                                        <select v-model="video.category_id" required name="category" class="form-control" id="category">
                                            <option :value="cat.id" v-for="cat in categories">{{ cat.name }}</option>
                                        </select>
                                    </div>
                                </div>

                                <div class="form-group">
                                    <label for="url" class="col-sm-2 control-label">Video URL</label>
                                    <div class="col-sm-10">
                                        <input v-model="video.url" @blur="validateYoutubeUrl()" required type="url" class="form-control" id="url" placeholder="YouTube video url">
                                    </div>
                                </div>

                                <div class="form-group">
                                    <label for="title" class="col-sm-2 control-label">Title</label>
                                    <div class="col-sm-10">
                                        <input v-model="video.title" required type="text" class="form-control" id="title" placeholder="Title of Video">
                                    </div>
                                </div>

                                <div class="form-group">
                                    <label for="desc" class="col-sm-2 control-label">Description</label>
                                    <div class="col-sm-10">
                                        <textarea v-model="video.description" minlength="10" required class="form-control" id="desc" placeholder="Description for video"></textarea>
                                    </div>
                                </div>

                                <div class="form-group">
                                    <div class="col-sm-offset-2 col-sm-10">
                                        <button :disabled="loading" type="submit" class="btn btn-info">
                                            <span v-show="loading" class="glyphicon glyphicon-refresh spin"></span> Upload
                                        </button>
                                        <button type="reset" :disabled="loading" class="btn btn-default">Cancel</button>
                                    </div>
                                </div>
                            </form>
                        </div>
                        <!-- End Video upload form -->

                        <div class="col-md-4">
                            <img class="img-responsive" :src="videoThumb" alt="">
                        </div>
                        <!-- End Video thumb preview -->
                    </div>
                </div>

                <div class="panel-heading">Trending Videos</div>

                <div class="panel-body">
                    <video-thumb :list="videos.data"></video-thumb>
                </div>
                <!-- End Latest videos -->
            </div>
        </div>
    </div>
</div>
</template>

<script>
    export default {
        data() {
            return {
                video: {},
                videos: {
                    data: []
                },
                categories: [],
                loading: false,
                videoThumb: '/img/video-thumb-placeholder.png'
            }
        },

        mounted() {
            this.getVideoAndCategories();
            console.log('Upload Component mounted.')
        },

        methods: {
            getVideoAndCategories() {
                this.$Progress.start();
                // change the title of page
                window.document.title = 'Upload a Video on QTube';

                axios.get('/api/videos?trending=true&categories=true').then((res) => {
                    this.$Progress.finish();
                    this.videos = res.data;
                    this.categories = res.data.categories;
                }).catch((err) => {
                    this.$Progress.finish();
                    console.log(err);
                });
            },

            uploadVideo() {
                let vm = this;
                vm.loading = true;

                // add other fields
                vm.video.thumbnail = vm.videoThumb;
                vm.video.channel_id = vm.$root.channel.id;

                axios.post('/api/videos', vm.video).then(function (res) {
                    vm.video = {};
                    vm.loading = false;
                    alert('Video has been uploaded, Go to home page to see it.');
                }).catch(function (error) {
                    console.log(error);
                    vm.loading = false;
                });
            },

            validateYoutubeUrl() {
              let videoCode = this.isYoutube(this.video.url);
              if ( videoCode ) {
                  this.videoThumb = 'http://img.youtube.com/vi/'+ videoCode +'/mqdefault.jpg';
              } else {
                  alert('URL is not a valid youtube video url.');
              }
            },

            isYoutube(url) {
                let pattern = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;
                let matches = url.match(pattern);
                if(matches){
                    return matches[1];
                }
                return false;
            },
        }
    }
</script>

Pretty simple, it’s just a post request to upload the video. In order to test it you must logged in and then on top right you will see upload icon, we also show some trending video below the form to make it look nice. In a production app you will be uploading an actual video, since I don’t have that much bandwidth and storage so I am just storing youtube link.

Conclusion

There you have it, Its not 100% but you can guess building a complete youtube app will take lot of time and resource but here you must have learned how to create fairly big app with lots of component, I  intentionally left some feature like, Subscription, Like, Search, Error handling etc. you can try implementing it by yourself, Let me know if you need any help in the comments. You can check the demo and source code to get better understanding of the app.

Source Code Demo

  • Nicolas

    Holy jezus, this is great! Nice build, so smooth and fast !

    • Saqueib

      Thanks Nicolas, Glad you liked it. I will be posting a feed reader app very soon with some cool ui and state management using vuex.

  • fausta leonardo

    This is amazing. Thumbs up 🙂

  • Ritesh Saw

    Thanks Saqueib for this great app! How can I integrate laravel cashier for subscription?
    I would be really nice if you can guide us.

    • Saqueib

      Hi Ritesh, I have already covered subscription in details in my earlier post here http://www.qcode.in/subscription-with-coupon-using-laravel-cashier-stripe/, you can check this out and let me know if you need any help

      • Ritesh Saw

        HI Saqueib, thanks for the info. I am new to laravel and just learning every concepts one by one. I tried to reproduce this qtube app but getting lots of issues. Could you help me learn to reproduce it? Please suggest what I will need to do for this?
        Thanks,
        Ritesh