Build a photo feed using Vue.js
A basic understanding of Vue.js and Node.js is needed to follow this tutorial.
When your app offers social sharing features, realtime notifications could be vital.
One example is Instagram, a popular app for editing and sharing photos and videos with friends & family. Users get realtime updates as feeds on posts made by their friends and other people they follow on the the platform.
In this tutorial, we will implement a similar social sharing app using some common developer tools, such as:
- Vue: Frontend framework to simplify our DOM interactions.
- Node: JavaScript server for handling requests from clients, as well as sending responses
- Pusher: Free realtime pub/sub service. Pusher makes realtime as easy as using basic events.
- Cloudinary: End-to-end image management solution that enables storage, manipulation, optimization and delivery.
Of course, there are other utility tools, like Bootstrap, Express and NeDB, that simplify some time-consuming tasks. We will learn about those while we walk through the demo.
Let’s first build a server for the app.
Setting Up a Node Server
A simple Node server is enough for the task at hand. To create one, run the following init command in an empty directory:
npm init -y
You should see a package.json file right in the folder. You can start installing the dependencies needed in the project:
npm install --save express nedb cors body-parser connect-multiparty pusher cloudinary
The dependencies help with the following outlined tasks:
- express: Routing framework for Node
- nedb: Disk database. This is not recommended for a large project, but is good enough to persist data in our demo.
- cors: Express middleware to enable CORS.
- body-parser: Express middleware that parses the request body and attaches to the express request object.
- connect-multiparty: Just like body-parser, but parses uploaded files
- pusher: Pusher SDK
- cloudinary: Cloudinary SDK
Next step is to import these installed dependencies into the entry JavaScript file. Create a file named index.js
at the root of the directory and start importing the dependencies:
// Import dependecies
const express = require('express');
const multipart = require('connect-multiparty');
const bodyParser = require('body-parser')
const cloudinary = require('cloudinary');
const cors = require('cors');
const Datastore = require('nedb');
const Pusher = require('pusher');
// Create an express app
const app = express();
// Create a database
const db = new Datastore()
// Configure middlewares
app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())
// Setup multiparty
const multipartMiddleware = multipart();
Not only have we imported the dependencies, we also configured the Express middleware that was installed.
Configurations
We need to configure Pusher and Cloudinary before actually making use of them. Configuration involves telling the SDKs who or what server should it talk to. This is done by passing it a config object that contains the credentials you retrieve after creating an account. (To learn how to set up both accounts, refer to Appendix 1 and 2 at the end of the article.)
// Pusher configuration
const pusher = new Pusher({
appId: process.env.PUSHER_APPID,
key: process.env.PUSHER_KEY,
secret: process.env.PUSHER_SECRET,
encrypted: true,
cluster: process.env.PUSHER_CLUSTER
});
// Cloudinary configuration
cloudinary.config({
cloud_name: process.env.CL_CLOUD_NAME,
api_key: process.env.CL_KEY,
api_secret: process.env.CL_SECRET
});
It’s bad practice to hard code credentials in your code, hence we have added them using environmental variables.
Routes
Two routes are needed for the application — one to serve all the gallery images and another to create new gallery images from a request. Here is one for listing images:
app.get('/', (req, res) => {
db.find({}, function (err, docs) {
if(err) {
return res.status(500).send(err);
}
res.json(docs)
});
})
This looks for all the items in our database, and if no error was encountered, sends them as a JSON response to the requesting client. If an error was encountered, the error will be sent as a server error (500).
Let’s now see how the images are uploaded, how data is persisted, and how Pusher emits a real time event that a new image was added to the collection:
app.post('/upload', multipartMiddleware, function(req, res) {
// Upload image
cloudinary.v2.uploader.upload(
req.files.image.path,
{ /* Transformation if needed */ },
function(error, result) {
if(error) {
return res.status(500).send(error)
}
// Save record
db.insert(Object.assign({}, result, req.body), (err, newDoc) => {
if(err) {
return res.status(500).send(err);
}
// Emit realtime event
pusher.trigger('gallery', 'upload', newDoc);
res.status(200).json(newDoc)
})
})
});
What’s going on will be better explained as points, so let’s do that:
- The middleware,
multipartMiddleware
, was not included in all the routes withuse
. Rather, it was added to the only route that needs it, which is the abovePOST /upload
route. - We use Cloudinary’s
upload()
method to send the image received to your server. It takes the path to the image being uploaded, a transformation object and the callback function. - If the upload was successful, we store the image upload response alongside the request body in our database.
- After storing the new data, we emit a Pusher
upload
event on the gallery channel. This event has a payload of the newly created item. All subscriptions to this channel’s event will be notified when an image is successfully uploaded.
Listen and Run
Finally, in the server, we can bind to a port:
app.listen(process.env.PORT || 5000, () => console.log('Running...'))
This uses the port provided in the environmental variable. If none, it sticks to port 5000.
You can start running the server with:
node index.js
Setting up Vue.js
Vue is the framework that powers our client app. With a server running, we can now implement a client that communicates with this server via HTTP requests and Pusher events.
Start with initializing a Vue project using the Vue CLI:
## Install Vue CLI
npm install -g vue-cli
## Scafold a project. Syntax: vue init <template> <name>
vue init webpack-simple gallery-client
Next, install dependencies:
npm install --save axios vodal pusher-js cloudinary-core
- axios: This is a HTTP library that simplifies how we make Ajax requests by enabling us to use promises to handle async.
- vodal: Vue widget for dialog boxes
- pusher-js: Pusher client SDK
- cloudinary-core: Cloudinary client SDK
Gallery items list
We need to display a list of existing images in the gallery at start up. Therefore, when the app is launched, the user should be presented with a list of all the images available. To achieve this, in the App.vue
(the entry component) created
lifecycle method, make a request for all the images using axios:
<script>
// ./App.vue
import axios from 'axios';
import cloudinary from 'cloudinary-core'
export default {
name: 'app',
data () {
return {
images: [],
cl: null,
spin: false
}
},
created() {
this.spin = true
this.cl = new cloudinary.Cloudinary({cloud_name: '<CLOUD_NAME>', secure: true})
axios.get('http://localhost:5000')
.then(({data}) => {
this.spin = false
this.images = data.map(image => {
image.url = this.cl.url(image.public_id, {width: 500, height: 400, crop: "fill"})
return image;
});
})
},
methods: {
// Coming soon
}
}
</script>
When the images are fetched, we transform them by manipulating the dimensions (width and height) to fit our design idea. The transformed data is then bound to the view by setting it as the value of the images
property :
<template>
<!-- ./App.vue -->
<div id="app">
<div class="container">
<h3 class="text-center">Realtime Gallery <button class="btn btn-info" @click="showModal"><span class="glyphicon glyphicon-upload"></span></button></h3>
<gallery-list :images="images"></gallery-list>
</div>
<span v-show="spin" class="glyphicon glyphicon-repeat fast-right-spinner"></span>
</div>
</template>
There is also a spin
boolean property that determines if a loading spinner should be shown or not. Soon, we will implement the showModal
method that is called when the upload button is clicked.
Rather than having native elements all over in the App
’s template, we have a created an abstraction. The gallery-list
element is used and is passed the list of images. For it to work, you need to create, import and declare the GalleryList
component in App.
First, import and declare it:
<script>
// ./App.vue
import GalleryList from './GalleryList.vue'
//...
export default {
components: {
'gallery-list': GalleryList
}
}
</script>
Then create the component:
<!-- ./GalleryList.vue -->
<template>
<div>
<div class="row" v-for="i in Math.ceil(images.length / 3)" :key="i">
<div class="col-md-4" v-for="image in images.slice((i - 1) * 3, i * 3)" :key="image._id">
<gallery-item :image="image">
</gallery-item>
</div>
</div>
</div>
</template>
<script>
import GalleryItem from './GalleryItem.vue'
export default {
props: ['images'],
components: {
'gallery-item': GalleryItem
}
}
</script>
The component receives images
sent from the parent App
component via props
. We then iterate over the images and display each of them with another component called gallery-item
:
<template>
<div class="card">
<h4 class="card-title">{{image.title}}</h4>
<div class="card-image">
<img :src="image.url" class="img-responsive"/>
</div>
<p class="card-description">{{image.description}}</p>
</div>
</template>
<script>
export default {
props: ['image']
}
</script>
With existing images (which I assume you don’t have yet), you should see the following at localhost:8080
when you run the app with npm run dev
:
Gallery image upload
Now that we have a list of images to show, the next question is how they came to exist. We have to upload them to the server. Fortunately, the server already made provision for that so all we have left to do is implement the upload logic.
Let’s start by creating an Upload
component which contains a form for the upload:
<template>
<div class="upload">
<form @submit.prevent="handleSubmit" enctype="multipart/form-data">
<div class="form-group">
<label>Title</label>
<input type="text" class="form-control" v-model="model.title" />
</div>
<div class="form-group">
<label>Image</label>
<input type="file" class="form-control" @change="handleUpload($event.target.files)" />
</div>
<div class="form-group">
<label>Description</label>
<textarea row="5" class="form-control" v-model="model.description"></textarea>
</div>
<div class="form-group">
<button class="btn btn-info">Submit</button>
</div>
</form>
</div>
</template>
<script>
export default {
data() {
return {
model: {
title: '',
description: '',
imageFile: null
}
}
},
methods: {
handleSubmit() {
this.$emit('submit', this.model)
},
handleUpload(files) {
this.model.imageFile = files[0];
}
}
}
</script>
The form contains a title (text), description (text area) and file inputs. These controls are tracked by the model
property, which is updated when the values change. title
and description
are automatic but imageFile
is not because it’s read only. Therefore, we have to manually update the model by calling handleUpload
every time the file control value changes.
When the form is submitted, we need to call handleSubmit
, which triggers an event that will be handled in the parent component (App
). Let’s have a look how App
handles this:
<template>
<div id="app">
<div class="container">
<h3 class="text-center">Realtime Gallery <button class="btn btn-info" @click="showModal"><span class="glyphicon glyphicon-upload"></span></button></h3>
<vodal :show="show" animation="zoom" @hide="show = false">
<upload @submit="handleSubmit"></upload>
</vodal>
<gallery-list :images="images"></gallery-list>
</div>
<span v-show="spin" class="glyphicon glyphicon-repeat fast-right-spinner"></span>
</div>
</template>
<script>
import Upload from './Upload.vue'
import GalleryList from './GalleryList.vue'
import axios from 'axios';
import cloudinary from 'cloudinary-core'
var Pusher = require('pusher-js');
export default {
name: 'app',
data () {
return {
images: [],
show: false,
cl: null,
spin: false
}
},
created() {
// truncated
},
methods: {
showModal() {
this.show = true
},
handleSubmit(model) {
this.show = false;
this.spin = true
const formData = new FormData()
formData.append('image', model.imageFile);
formData.append('title', model.title);
formData.append('description', model.description);
axios.post('http://localhost:5000/upload', formData)
.then(({data}) => {
this.spin = false
})
}
},
components: {
'gallery-list': GalleryList,
'upload': Upload
}
}
</script>
Because of the way we added GalleryList
, the Upload
container is imported and included in the list of components. The dialog plugin, vodal
is used to only show the form as a dialog when the upload button beside the header is clicked. This is possible by toggling show
.
Notice how the upload component handles the emitted submit event:
<upload @submit="handleSubmit"></upload>
It calls handleSubmit
on the containing (parent) component, which uploads the image with axios, hides the model and uses a loading spinner to tell us the status of the upload.
The vodal
plugin needs to be imported and configured for it to work. You can do this in the ./main.js
file:
import Vodal from 'vodal';
Vue.component(Vodal.name, Vodal);
Now you can run the app again (if you stopped it), and try to upload an image:
When you upload an image, you won’t see any UI updates unless you refresh the browser. Let’s implement realtime updates to make UI updates happen.
Realtime updates
We already have the upload feature fleshed out but we need to let the users know their upload was successful. The server is already triggering an event, all we need do is listen to this event and prepend incoming payload to the existing list of images:
<script>
import Upload from './Upload.vue'
import GalleryList from './GalleryList.vue'
import axios from 'axios';
import cloudinary from 'cloudinary-core'
var Pusher = require('pusher-js');
export default {
name: 'app',
data () {
return {
images: [],
show: false,
cl: null,
spin: false
}
},
created() {
this.spin = true;
var pusher = new Pusher('<APP_ID>', {
encrypted: true,
cluster: 'CLUSTER'
});
var channel = pusher.subscribe('gallery');
channel.bind('upload', (data) => {
data.url = this.cl.url(data.public_id, {width: 500, height: 400, crop: "fill"})
this.images.unshift(data)
});
// Truncated...
},
methods: {
// Truncated...
},
}
</script>
We bind to the upload
event, which we created on the gallery
channel, then add the new image that comes into the existing array of images. You can now see image upload and UI updates happen in real time:
Conclusion
At this point, if you have followed the article, you can stop wondering how realtime image sharing apps work and start building one for yourself.
Appendix 1: Pusher Setup
- Sign up for a free Pusher account
- Create a new app by selecting Apps on the sidebar and clicking Create New button on the bottom of the sidebar:
- Configure an app by providing basic information requested in the form presented. You can also choose the environment you intend to integrate Pusher with for a better setup experience:
- You can retrieve your keys from the App Keys tab
Appendix 2: Cloudinary Setup
- Sign up on Cloudinary for a free account:
- When you sign up successfully, you’re presented with a dashboard that holds your cloud credentials. You can safely store them for future use:
3 August 2017
by Christian Nwamba