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.

Setup

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).

Database migration

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

REST API

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');

Modifying the store

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.

Tweeting with an image

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%;
+ }

Displaying the image

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>
⚠️ **GitHub.com Fallback** ⚠️