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.
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.vue, TrendingPage.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 theexact
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 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 body & video_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.
Holy jezus, this is great! Nice build, so smooth and fast !
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.
This is amazing. Thumbs up 🙂
Thanks Saqueib for this great app! How can I integrate laravel cashier for subscription?
I would be really nice if you can guide us.
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
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
i am using L 5.4 and get this error while seeding
[InvalidArgumentException]
Unable to locate factory with name [default] [AppChannel].
It looks like you forgot to import the Channel model, you can prefix the namespace or import it
i solve it, i forget to write the factory, can you tell me why not we use json web token instead of passport
Where was declared Laravel variable at javascript?
Its in main app layout file. https://github.com/saqueib/qtube/blob/v1.0/resources/views/layouts/app.blade.php#L21
I’m looking for a vue js app for a Youtube like app to learn and work with rest api and route.
But i do not understand why using php and Lavarel when you can do the exact same app with just vue.js javascript and html.
I build the api for this app on Laravel and since laravel works with vue js I used it. You are right this apps front end can built on vue.js only. Only auth is connected to laravel, you can still learn how its build.
Can we upload large video (more than 1Gb)?
In this demo app we don’t actually upload any video, we just show video from YouTube, but you can check out this for upload http://qcode.in/reusable-upload-component-in-laravel-with-dropzone-js/
Handing big file size as upload depends on server setup. You should Google chunked upload