Day 5 - BYU-CS260-Winter-2018/redbird GitHub Wiki
For Day 5, we will modify our application so that users can upload an image with a tweet. We're going to upload the images to our server, keep track of the path to the image, and then store this path with the tweet. This means the location where we store files better be visible to the web server where we are hosting the application! We'll just be working with this application in development mode, but if you deploy this application, you'll need to be sure the uploaded files are being stored where your webserver can get to them.
The first thing we need to do is install the multer library. When uploading files, we use a multipart form, and multer is built to handle this type of data.
npm install multer
Then we'll make a directory to store our uploaded files:
mkdir static/uploads
Finally, we'll edit server.js
to configure multer:
// multer setup
const multer = require('multer');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'static/uploads')
},
filename: (req, file, cb) => {
cb(null, `${req.userID}-${Date.now()}-${file.originalname}`);
}
});
const upload = multer({storage: storage});
This involves telling multer which directory to store images in (we'll use static/uploads
) and how
to form a filename for the uploaded file (we'll use the user's ID, the current date/time, and the
file's name).
Next, we need to add a column to the tweets
table to include the path to where the image is stored.
To do this, we'll create a migration:
npx knex migrate:make image
Edit the migration file so it reads like this:
exports.up = function(knex, Promise) {
return knex.schema.table('tweets', function(table) {
table.string('image').notNull().defaultTo("");
});
};
exports.down = function(knex, Promise) {
return knex.schema.table('tweets', function(table) {
table.dropColumn('image');
});
};
This adds a column called image
to the tweets table. Run the migration:
npx knex migrate:latest
For the rest API, edit the endpoint for adding a tweet in server.js
to make these changes:
-app.post('/api/users/:id/tweets', verifyToken, (req, res) => {
+app.post('/api/users/:id/tweets', verifyToken, upload.single('image'), (req, res) => {
let id = parseInt(req.params.id);
if (id !== req.userID) {
res.status(403).send();
return;
}
+ // check for an image
+ let path = ''
+ if (req.file)
+ path = req.file.path;
knex('users').where('id',id).first().then(user => {
- return knex('tweets').insert({user_id: id, tweet:req.body.tweet, created: new Date()});
+ return knex('tweets').insert({user_id: id, tweet:req.body.tweet, created: new Date(), image:path});
Note that we call upload
as middleware, right after we verify the token. Then we just need to get
the path to the file, which will be stored in req.file.path
and insert that into the database
with the new tweet.
We also need to return the new image column in every API endpoint where we send back a list of tweets. In the endpoint for getting a user's tweets:
knex('users').join('tweets','users.id','tweets.user_id')
.where('users.id',id)
.orderBy('created','desc')
- .select('tweet','username','name','created').then(tweets => {
+ .select('tweet','username','name','created','image').then(tweets => {
In the search endpoint:
.select('tweet','username','name','created','users.id as userID').then(tweets => {
+ .select('tweet','username','name','created','image','users.id as userID').then(tweets => {
In the hashtag endpoint:
- .select('tweet','username','name','created','users.id as userID').then(tweets => {
+ .select('tweet','username','name','created','image','users.id as userID').then(tweets => {
And in the feed endpoint:
- .select('tweet','username','name','created','users.id as userID');
+ .select('tweet','username','name','created','image','users.id as userID');
Now we need to modify the store on the front end to call this API. Edit src/store.js
so that addTweet
has the following changes:
addTweet(context,tweet) {
- axios.post("/api/users/" + context.state.user.id + "/tweets",tweet,getAuthHeader()).then(response => {
+ // setup headers
+ let headers = getAuthHeader();
+ headers.headers['Content-Type'] = 'multipart/form-data'
+ // setup form data
+ let formData = new FormData();
+ formData.append('tweet',tweet.tweet);
+ if (tweet.image) {
+ formData.append('image',tweet.image);
+ }
+ axios.post("/api/users/" + context.state.user.id + "/tweets",formData,headers).then(response => {
We have to setup a new FormData() object and set a new Content-Type
header when we send the post request.
To allow the user to include an image in their tweet, modify src/components/UserFeed.vue
so that
the form in the template includes an icon the user can click on to upload an image:
- <form v-on:submit.prevent="tweet" class="tweetForm">
- <textarea v-model="text" placeholder=""/><br/>
- <div class="buttonWrap">
- <button class="primary" type="submit">Tweet</button>
+ <form enctype="multipart/form-data" v-on:submit.prevent="tweet" class="tweetForm">
+ <textarea v-model="text" placeholder=""/>
+ <div v-bind:style="{inactive: !imagePreview, active:imagePreview }">
+ <img class="preview" v-bind:src="imageData">
+ </div>
+ <div class="buttons">
+ <div class="icon">
+ <label for="file-input">
+ <i class="far fa-image" aria-hidden="true"></i>
+ </label>
+ <input id="file-input" type="file" v-on:change="previewImage" accept="image/*" class="input-file">
+ </div>
+ <div class="buttonWrap">
+ <button class="primary" type="submit">Tweet</button>
+ </div>
Notice the following changes:
- We change the encoding type of the form, so it is multipart form data.
- We include a div where we can place a preview of the image.
- We add a hidden file input field for choosing the file, and then provide an icon the user can click on instead.
We are going to keep track of a few more items of data:
data () {
return {
text: '',
+ imageData: '',
+ imagePreview: false,
+ file: '',
}
These include the imageData
(used for previewing the image), and boolean that determines whether
to show this preview, and a file object.
When the user uploads an image, it will trigger the previewImage
method, defined here:
previewImage: function(event) {
const input = event.target;
// Ensure that you have a file before attempting to read it
if (input.files && input.files[0]) {
this.file = input.files[0];
// create a new FileReader to read this image and convert to base64 format
const reader = new FileReader();
// Define a callback function to run, when FileReader finishes its job
reader.onload = (e) => {
// Read image as base64 and set to imageData
this.imageData = e.target.result;
this.imagePreview = true;
}
// Start the reader job - read file as a data url (base64 format)
reader.readAsDataURL(input.files[0]);
}
}
In this method, we set this.file
to contain information about the file (e.g. its name), then we setup
a function to receive the uploaded image and store it in this.imageData
for the preview. This
is all asynchronous, so it starts once we call reader.readAsDataURL
.
Finally, when we create the tweet, we need to include the uploaded image file:
tweet: function() {
this.$store.dispatch('addTweet',{
tweet: this.text,
+ image: this.file,
}).then(tweet => {
this.text = "";
+ this.imageData = "";
+ this.imagePreview = false;
});
},
We're also going to change a few styles:
- .feed {
- width: 600px;
- }
- .buttonWrap {
- width: 100%;
+ .buttons {
display: flex;
+ justify-content: space-between;
+ }
+ .icon {
+ font-size: 2em;
+ padding: 0px;
+ }
+ .icon:active {
+ transform: translateY(4px);
+ }
+ .buttonWrap {
+ width: 20%;
}
button {
- margin-left: auto;
height: 2em;
font-size: 0.9em;
+ float:right;
}
We will add these styles to src/App.vue
to make our feed and image with apply globally:
+ .feed {
+ width: 600px;
+ }
+ .feed img {
+ width: 100%;
+ }
To display the image, we just need to modify src/components/FeedList.vue
:
<div v-for="item in feed" class="item">
<p class="idline"><span class="user">{{item.name}}</span><router-link :to="{ name: 'UserPage', params: {userID: item.userID}}"><span class="handle">@{{item.username}}</span></router-link><span class="time">{{item.created | since}}</span></p>
<p v-html="formatTweet(item.tweet)" class="tweet"></p>
+ <img v-bind:src="item.image"/>
</div>
</div>