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,
};
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 GitHub here: vue-firebase-image-upload.
Enjoy :)