How to make an image uploading app with Vue, Quasar, Firebase Storage and Cordova - Part 2

johnnymakestuff

Jonathan P

Posted on October 13, 2019

How to make an image uploading app with Vue, Quasar, Firebase Storage and Cordova - Part 2

What we're building

We'll build a cross-platform mobile app for taking photos and uploading to firebase.

In Part 1, we saw how to take a picture and save it to Firebase Cloud Storage.
In this post we'll move the uploading to a separate thread via web worker, and use the blueimp library to generate a thumbnail locally and show it while uploading.

Web Workers

What is a web worker

Web Workers are a simple means for web content to run scripts in background threads. The worker thread can perform tasks without interfering with the user interface. (mozilla.org)

So we'll use a web worker to offload code that can potentially block our UI thread - in our case the Firebase Storage code - to another thread.

Configuring workerize-loader

We'll use workerize-loader to make using web workers a little easier (web workers interface is a little weird).

yarn add workerize-loader

We need to add some webpack configuration to tell webpack to use workerize-loader. In quasar.conf.js in the build section add:

extendWebpack(cfg) {
    cfg.module.rules.push({
        test: /\.worker\.js$/,
        loader: 'workerize-loader',
    })
},

chainWebpack (chain, { isServer, isClient }) {
    chain.output.globalObject('self')
}

This tells webpack to load the file worker.js using workerize-loader.

Moving code to the worker

Let's add the file src/services/worker.js and move our uploading code into it:

import cloudStorage from './cloud-storage'

export async function uploadPicture(imageData) {
    let storageId = new Date().getTime().toString();

    let downloadURL = await cloudStorage.uploadBase64(
        imageData,
        storageId
    );

    return {
        storageId,
        downloadURL,
    }
}

export function initFB() {
    cloudStorage.initialize();
}

export async function deletePic(storageId) {
    await cloudStorage.deleteFromStorage(storageId)
}

We've also added a deletePic call which we'll see later in cloudStorage.js.

Adding simple state management

We'll want to create an instance of the worker and save it in the app's state. We don't have state management yet, so let's add a simple state management pattern using a store.js file:

import worker from "workerize-loader!./worker.js";

var store = {
    debug: true,
    state: {
        pics: [],
        uploading: false
    },
    initWorker() {
        this.state.workerInstance = worker();
        this.state.workerInstance.initFB();
    },
    async loadPictures() {
        if (this.debug) console.log("loadPictures triggered")
        let picsJson = await localStorage.getItem("pics");
        if (!picsJson) this.state.pics = [];
        else this.state.pics = JSON.parse(picsJson);
        this.state.pics = this.state.pics.filter(pic => !pic.uploading)
    },
    async addPic(pic) {
        if (this.debug) console.log("addPic triggered with", pic)

        pic.failed = false;
        this.state.pics.splice(0, 0, pic);
        localStorage.setItem("pics", JSON.stringify(this.state.pics));
    },
    async deletePic(idx) {
        if (this.debug) console.log("deletePic triggered with", idx)

        this.state.pics[idx].uploading = true;

        if (this.state.pics[idx].storageId) {
            await this.state.workerInstance.deletePic(this.state.pics[idx].storageId)
        }
        this.state.pics.splice(idx, 1);
        localStorage.setItem("pics", JSON.stringify(this.state.pics));
    },
    async updatePicUploaded(oldPic, newPic) {
        if (this.debug) console.log("updatePicUploaded triggered with", oldPic, newPic)

        oldPic.uploading = false
        oldPic.url = newPic.downloadURL
        oldPic.storageId = newPic.storageId
        oldPic.width = newPic.width
        oldPic.height = newPic.height

        localStorage.setItem("pics", JSON.stringify(this.state.pics));
    },
    async updatePicFailed(pic) {
        if (this.debug) console.log("updatePicFailed triggered with", pic)

        pic.failed = true
    },
}

export default {
    ...store
}

A few things going on here:

  • We're importing our worker using the prefix workerize-loader! which tells webpack to use the loader we configured earlier.
  • We moved the pics collection to the state object from Index.vue.
  • We exposed the method initWorker which initializes the worker instance.
  • We added some CRUD methods for persisting the pics collection in localStorage: addPic, loadPictures and delete.
  • updatePicUploaded and updatePicFailed changes the loading property of the picture. We'll use this to show the spinner.

Generating thumbnails

We're going to generate a thumbnail (on the client) to show while the image is uploading. We'll use the blueimp library for this:

yarn add blueimp-load-image

Let's add another service for manipulating images: src/services/image-ops.js:

import loadImage from 'blueimp-load-image'

const base64JpegPrefix = "data:image/jpeg;base64,";

function removeBase64Prefix(base64Str) {
    return base64Str.substr(base64Str.indexOf(",") + 1);
}

function addBase64Prefix(imageData) {
    return base64JpegPrefix + imageData
}

async function generateThumbnail(imageData, maxWidth) {
    return new Promise(async resolve => {
        let url = base64JpegPrefix + imageData
        let res = await fetch(url)
        let blob = await res.blob()

        loadImage(
            blob,
            (canvas) => {
                let dataURL = canvas.toDataURL('image/jpeg');
                resolve(removeBase64Prefix(dataURL));
            }, {
                maxWidth: maxWidth,
                canvas: true
            }
        );
    });
}


export default {
    removeBase64Prefix,
    generateThumbnail,
    addBase64Prefix,
};

Notice we've moved the removeBase64Prefix and addBase64Prefix methods here.
The generateThumbnail function takes imageData - a base64 string, uses the fetch API, converts it to a blob, and then uses blueimp's loadImage to change it's size to maxWidth.
We're loading maxWidth from a new config file we've added to make things tidy - src/services/config.js:

export default {
    photos: {
        maxWidth: 1000,
        thumbnailMaxWidth: 30
    }
}

Adding the image-uploader service

We'll extract all the image uploading flow to a service to keep our UI component clean. Add the file src/services/image-uploader.js:

import store from "./store";
import cordovaCamera from "./cordova-camera";
import imageOps from "./image-ops";
import config from "./config";


async function uploadImageFromCamera() {
    let base64 = await cordovaCamera.getBase64FromCamera();

    let imageData = imageOps.removeBase64Prefix(base64);

    let thumbnailImageData = await imageOps.generateThumbnail(
        imageData,
        config.photos.thumbnailMaxWidth
    );

    let localPic = {
        url: imageOps.addBase64Prefix(thumbnailImageData),
        uploading: true
    };
    store.addPic(localPic);

    let uploadedPic = await store.state.workerInstance.uploadPicture(
        imageData
    );
    store.updatePicUploaded(localPic, uploadedPic);
}

export default {
    uploadImageFromCamera
}

What we're doing in the uploadImageFromCamera method is: getting base64 from the cordova camera -> generating thumbnails using our imageOps -> generating a new pic object in our state with uploading=true -> uploading the picture to Firebase using the web worker -> updating the pic's uploading state property when uploading is finished.

Putting it together

Now we've added all these services, we need to call them from our components.

In App.vue, we'll use the mounted lifecycle hook to initialize the worker and load the saved picture urls from the saved state:

<template>
  <div id="q-app">
    <router-view />
  </div>
</template>

<script>
import store from "./services/store.js";

export default {
  name: "App",
  async mounted() {
    store.initWorker();
    store.loadPictures();
  }
};
</script>

<style>
</style>

Finally, we'll add the interface for viewing all this in Index.vue:

<template>
  <q-page class>
    <div class="q-pa-md">
      <div class="row justify-center q-ma-md" v-for="(pic, idx) in pics" :key="idx">
        <div class="col">
          <q-card v-if="pic">
            <q-img spinner-color="white" :src="pic.url">
              <div class="spinner-container" v-if="pic.uploading && !pic.failed">
                <q-spinner color="white" size="4em" />
              </div>
              <div class="spinner-container" v-if="pic.failed">
                <q-icon name="cloud_off" style="font-size: 48px;"></q-icon>
              </div>
            </q-img>
            <q-card-actions align="around">
              <q-btn flat round color="red" icon="favorite" @click="notifyNotImplemented()" />
              <q-btn flat round color="teal" icon="bookmark" @click="notifyNotImplemented()" />
              <q-btn
                flat
                round
                color="primary"
                icon="delete"
                @click="deletePic(idx)"
                :disable="pic.uploading"
              />
            </q-card-actions>
          </q-card>
        </div>
      </div>
    </div>
  </q-page>
</template>

<style scoped>
.spinner-container {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
}

</style>

<script>
import store from "../services/store";
import { EventBus } from "../services/event-bus";
import imageUploader from "../services/image-uploader";

export default {
  name: "PageIndex",
  data() {
    return {
      state: store.state
    };
  },
  mounted() {
    EventBus.$off("takePicture");
    EventBus.$on("takePicture", this.uploadImageFromCamera);
  },
  computed: {
    pics() {
      return this.state.pics;
    }
  },
  methods: {
    notifyNotImplemented() {
      this.$q.notify({ message: "Not implemented yet :/" });
    },
    async deletePic(idx) {
      try {
        await store.deletePic(idx);
        this.$q.notify({ message: "Picture deleted." });
      } catch (err) {
        console.error(err);
        this.$q.notify({ message: "Delete failed. Check log." });
      }
    },
    async uploadImageFromCamera() {
      try {
        imageUploader.uploadImageFromCamera();
      } catch (err) {
        console.error("Uploading failed");
        console.dir(err);
        store.updatePicFailed(localPic);
        this.$q.notify({ message: "Uploading failed. Check log." });
      }
    }
  }
};
</script>

What's going on here:

  • We've added a q-spinner to show when the uploading property of the picture is true.
  • We're calling imageUploader.uploadImageFromCamera when clicking on the photo button, which handles our uploading.
  • We've added some actions to the q-card of the picture (only delete is implemented for now)

Final app

That's it! We have an image uploading app that shows a blurred thumbnails with a spinner, a little like WhatsApp's image upload.
Running all this using quasar dev -m android/ios will show the final result:

Further improvements

Some ideas for improving this app further would be:

  • Offload thumbnail creation to another web worker
  • Handle long lists of images with a virtual list component like this one
  • Add a carousel / gallery component for viewing images full-size
  • Add a retry mechanism for failed uploads
  • Use firebase auth - so that users can only delete their own photos

Source code

The full code is on GutHub here: vue-firebase-image-upload.

Enjoy :)

💖 💪 🙅 🚩
johnnymakestuff
Jonathan P

Posted on October 13, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related