How to make an image uploading app with Vue, Quasar, Firebase Storage and Cordova - Part 2
Jonathan P
Posted on October 13, 2019
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 thestate
object fromIndex.vue
. - We exposed the method
initWorker
which initializes the worker instance. - We added some CRUD methods for persisting the
pics
collection inlocalStorage
:addPic
,loadPictures
anddelete
. -
updatePicUploaded
andupdatePicFailed
changes theloading
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 theuploading
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 :)
Posted on October 13, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.