In my quest to upgrade my portfolio for tech recruiters I decided to work on some inspirational and unconventional projects using a myriad of tools. I started that adventure with just a single page application in VueJs 3.
I was faced with quite some challenges in making the project a reality. Firstly, I went on research for some API, Vue Libraries, and the best approach to fulfill the project. I was initially satisfied with the information I had amassed for the project.
But right in the middle of the project, I observed that VueJs 3 had some changes with the way third-party libraries are being used, this was a shocker to me and it took me a while to get around with it.
The following codes will show you what I did to achieve my goal.
For images, I used the image API of Pixabay. I used the dom-to-image-more npm package for converting Dom elements to images. My goal was to build an app that converts a written quote into an image.
I designed the app with the following components and folder structure.
My app contained the app component along with five children components for the hero, result, lab, imager, and footer with each having its own functionality.
The hero component has the following codes.
<template>
<div class="hero__container">
<form
@submit.prevent="onSubmit"
class="hero__form"
:style="{
backgroundImage: `url(${require('@/assets/images/search.png')})`,
}"
>
<div class="inner-form">
<div class="input-field first-wrap">
<div class="svg-wrapper">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
></path>
</svg>
</div>
<input
id="search"
type="text"
placeholder="What are you looking for?"
v-model="keyword"
/>
</div>
<div class="input-field second-wrap">
<button class="btn-search" type="submit">
{{ searching ? "SEARCHING..." : "SEARCH" }}
</button>
</div>
</div>
<span class="info">ex. Love, Road, Flower, Garden, House</span>
</form>
</div>
</template>
<script>
import { mapActions, mapMutations } from "vuex";
export default {
data() {
return {
keyword: "",
searching: false,
};
},
methods: {
...mapActions(["searchImages"]),
...mapMutations(["setKeyword"]),
onSubmit() {
this.searching = true;
const keyword = this.keyword.split(" ").join("+");
this.searchImages({ keyword })
.then(() => this.setKeyword(keyword))
.catch((error) => console.log(error))
.finally(() => (this.searching = false))
},
},
};
</script>
<style scoped>
.hero__container {
min-height: 50vh;
display: -ms-flexbox;
display: flex;
-ms-flex-pack: center;
justify-content: center;
font-family: poppins, sans-serif;
background-position: bottom right;
background-repeat: no-repeat;
background-size: 100%;
padding: 15px;
}
.hero__form {
width: 100%;
max-width: 790px;
padding-top: 24vh;
}
.hero__form .inner-form {
display: -ms-flexbox;
display: flex;
width: 100%;
-ms-flex-pack: justify;
justify-content: space-between;
-ms-flex-align: center;
align-items: center;
box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
border-radius: 34px;
overflow: hidden;
margin-bottom: 30px;
}
.hero__form .inner-form .input-field.first-wrap {
-ms-flex-positive: 1;
flex-grow: 1;
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
background: #d9f1e3;
}
.hero__form .inner-form .input-field.first-wrap .svg-wrapper {
min-width: 80px;
display: -ms-flexbox;
display: flex;
-ms-flex-pack: center;
justify-content: center;
-ms-flex-align: center;
align-items: center;
}
.hero__form .inner-form .input-field input {
height: 100%;
background: 0 0;
border: 0;
display: block;
width: 100%;
padding: 10px 0;
font-size: 16px;
color: #000;
}
.hero__form .inner-form .input-field.second-wrap {
min-width: 216px;
}
.hero__form .info {
font-size: 15px;
color: #ccc;
padding-left: 26px;
}
.hero__form .inner-form .input-field.second-wrap .btn-search {
height: 100%;
width: 100%;
white-space: nowrap;
font-size: 16px;
color: #fff;
border: 0;
cursor: pointer;
position: relative;
z-index: 0;
background: #00ad5f;
transition: all 0.2s ease-out, color 0.2s ease-out;
font-weight: 300;
}
.hero__form .inner-form .input-field.second-wrap {
min-width: 100px;
}
.hero__form .inner-form .input-field {
height: 68px;
}
@media screen and (max-width: 992px) {
.hero__form .inner-form .input-field {
height: 50px;
}
}
</style>
With this output:
The hero component searches for images from Pixabay API and sends them to the state management system Vuex.
import axios from 'axios'
import { createStore } from "vuex"
const store = createStore({
state: {
images: [],
image: null,
selected: null,
keyword: ''
},
mutations: {
images: (state, payload) => (state.images.push(...payload)),
search: (state, payload) => (state.images = payload),
setKeyword: (state, payload) => (state.keyword = payload),
selected: (state, payload) => (state.selected = payload),
setImage: (state, payload) => (state.image = payload),
},
actions: {
setSelected: (state, payload) => state.commit('selected', payload),
loadImages(state, payload = {}) {
const uri = `https://pixabay.com/api/?key=APIKEY&q=${payload?.keyword || '%27%27=photo'}&image_type=photo&pretty=true&safesearch=true&page=${payload?.page || 1}&per_page=6`
return new Promise((respond, reject) => {
axios.get(uri)
.then((response) => {
state.commit("images", response.data.hits)
respond()
})
.catch(() => reject());
})
},
searchImages(state, payload = {}) {
const uri = `https://pixabay.com/api/?key=APIKEY&q=${payload?.keyword || '%27%27=photo'}&image_type=photo&pretty=true&safesearch=true&page=${payload?.page || 1}&per_page=6`
return new Promise((respond, reject) => {
axios.get(uri)
.then((response) => {
state.commit("search", response.data.hits)
respond()
})
.catch(() => reject());
})
},
},
getters: {
images: state => state.images,
image: state => state.image,
selected: state => state.selected,
keyword: state => state.keyword,
}
})
export default store
The result component retrieves the images from the Vuex store and renders them to the view.
<template>
<div class="result__container">
<div
class="result__card"
v-for="image in images"
:key="image.id"
>
<img
class="result__image"
:src="image.largeImageURL"
:alt="image.tags"
:class="{ result_selected: image == selected }"
@click="onSelected(image)"
/>
</div>
<div class="break"></div>
<button class="result__btn" @click="loadMore" :disabled="loading">{{ loading ? 'Loading...' : 'Load More' }}</button>
</div>
</template>
<script>
import { mapGetters, mapActions} from 'vuex'
export default {
name: "app-result",
data() {
return {
page: 1,
loading: false,
};
},
created() {
this.onLoadImages()
},
methods: {
...mapActions([
'setSelected',
'loadImages'
]),
onSelected(image) {
this.setSelected(image);
},
onLoadImages() {
this.loading = true
this.loadImages()
.catch(error => console.log(error))
.finally(() => this.loading = false)
},
loadMore() {
this.loading = true
this.loadImages({page: ++this.page, keyword: this.keyword})
.catch(error => console.log(error))
.finally(() => this.loading = false)
}
},
computed: {
...mapGetters([
'images',
'selected',
'keyword',
])
}
};
</script>
<style scoped>
.result__container {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
max-width: 80%;
margin: 50px auto;
}
.result__card {
width: 30%;
margin-top: 20px;
}
.result__image {
flex-grow: 1;
object-fit: cover;
width: 100%;
max-height: 250px;
min-height: 250px;
transition: all 0.2s ease-in-out;
box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
cursor: pointer;
overflow: hidden;
}
.result__image:hover {
transform: scale(1.11);
box-shadow: 0 8px 20px 0 rgba(0, 173, 95, 0.3);
border-radius: 9px;
}
.result_selected {
transform: scale(1.11);
box-shadow: 0 8px 20px 0 #ffc107;
border-radius: 9px;
}
.result_selected:hover {
box-shadow: 0 8px 20px 0 #ffc107;
}
.result__btn {
border: transparent;
cursor: pointer;
padding: 10px;
background: #00ad5f;
color: white;
border-radius: 9px;
box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
transition: all 0.2s ease-in-out;
margin-top: 40px;
}
.result__btn:hover {
background: transparent;
color: #00ad5f;
}
.break {
flex-basis: 100%;
height: 0;
}
@media (max-width: 992px) {
.result__card {
width: 40%;
}
}
@media (max-width: 768px) {
.result__card {
width: 90%;
}
}
</style>
And this is the output:
The Lab component utilizes a single image from the image array once a user selects an image and joins it with a supplied quote and also converts it to a base64 image string and saves it in the VueJs Store.
<template>
<div class="lab__container">
<div
ref="capture"
:style="{
background: `linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)),
url(${image}) no-repeat center center / cover`,
}"
class="lab__card"
>
<p>{{ quote }}</p>
<small>{{ name }}</small>
</div>
<div class="lab__tools">
<h4>Eith Quote</h4>
<textarea
class="lab__form"
placeholder="Type your quote here, be nice!"
cols="30"
rows="10"
v-model="quote"
maxlength="150"
></textarea>
<input
class="lab__form"
type="text"
placeholder="Your name"
v-model="name"
/>
<button class="lab__btn" @click="onCapture" :disabled="capturing">
{{ capturing ? "Capturing..." : "Capture" }}
</button>
</div>
</div>
</template>
<script>
import { mapGetters, mapMutations } from "vuex";
import domtoimage from "dom-to-image-more";
export default {
name: "app-lab",
data() {
return {
name: "",
quote: "",
image: "",
capturing: false,
};
},
methods: {
...mapMutations(["setImage"]),
onCapture() {
this.capturing = true;
const capture = this.$refs.capture;
domtoimage
.toPng(capture)
.then((dataUrl) => {
this.setImage(dataUrl);
this.capturing = false;
})
.catch((error) => {
this.capturing = false;
console.error("oops, something went wrong!", error);
});
},
},
computed: {
...mapGetters(["selected"]),
},
watch: {
selected() {
this.image = this.selected.largeImageURL;
},
},
};
</script>
<style scoped>
.lab__container {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
max-width: 100%;
min-height: 448px;
padding: 40px 50px;
background-color: #d9f1e3;
}
.lab__card {
width: 30%;
width: 300px;
height: 400px;
border-radius: 9px;
padding: 20px;
object-fit: contain;
transition: transform 0.45s;
color: white;
box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
}
.lab__card > p {
font-size: 30px;
font-style: italic;
margin-bottom: 10px;
}
.lab__card > small {
font-style: bold;
float: right;
}
.lab__tools {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
width: 50%;
background-color: #c0decd;
border-radius: 9px;
padding: 20px 50px;
}
.lab__tools > * {
margin: 10px 0;
width: 100%;
}
.lab__tools > textarea {
width: 100%;
}
.lab__form {
border: none;
border-radius: 9px;
padding: 10px;
box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
}
.lab__btn {
border: none;
cursor: pointer;
padding: 10px;
background: #ffc107;
color: white;
border-radius: 9px;
box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
transition: all 0.2s ease-in-out;
}
.lab__btn:hover {
background: transparent;
}
@media screen and (max-width: 992px) {
.lab__tools,
.lab__card {
width: 100%;
}
.lab__tools {
margin: 40px 0;
}
.lab__card > p {
padding: 0.5rem 0;
}
}
@media screen and (max-width: 410px) {
.lab__card > p {
font-size: 25px;
}
}
</style>
This is the output:
The imager component takes the saved image from the VueJs store and renders it to the view once the Lab component has done its job.
<template>
<div class="imager__container">
<img :src="image" alt="imager" class="imager__image" />
<div class="imager__btns">
<a class="imager__btn__download" :href="image" download="quote_result.png"
>Download</a
>
<button class="imager__btn__delete" @click="setImage('')">Delete</button>
</div>
</div>
</template>
<script>
import { mapGetters, mapMutations } from "vuex";
export default {
name: "imager",
methods: {
...mapMutations(["setImage"]),
},
computed: {
...mapGetters(["image"]),
},
};
</script>
<style scoped>
.imager__container {
display: grid;
justify-content: space-around;
max-width: 100%;
min-height: 448px;
padding: 40px 50px;
background-color: white;
}
.imager__image {
box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
}
.imager__btns {
text-align: center;
}
.imager__btn__download,
.imager__btn__delete {
border: transparent;
cursor: pointer;
padding: 10px;
border-radius: 9px;
box-shadow: 0 8px 20px 0 rgba(0, 0, 0, 0.15);
transition: all 0.2s ease-in-out;
margin: 40px 5px;
text-decoration: none;
}
.imager__btn__download {
background: #00ad5f;
color: white;
}
.imager__btn__delete {
background: white;
color: #00ad5f;
}
.imager__btn__download:hover {
background: white;
color: #00ad5f;
}
.imager__btn__delete:hover {
background: #00ad5f;
color: white;
}
</style>
This is the output of the imager component:
Lastly, we complemented the app with a footer which you can see below.
<template>
<div class="footer__container">
<div class="footer__copywrite">
<p>© 2021 All right reserved</p>
<small>Built by <a href="#">Gospel Darlington</a></small>
</div>
</div>
</template>
<script>
export default {
}
</script>
<style scoped>
.footer__container {
display: inline-block;
text-align: center;
width: 100%;
background: #4d5458;
padding: 10px;
}
.footer__copywrite {
color: white;
padding: 10px 0;
}
.footer__copywrite > small > a {
color: orange;
text-decoration: none;
transition: all .2s ease-in-out;
}
.footer__copywrite > small > a:hover {
color: green;
}
</style>
All these components were then brought together by the main component which is the App.vue file.
<template>
<div>
<Hero/>
<Result/>
<Lab/>
<Imager v-if="captured"/>
<Footer/>
</div>
</template>
<script>
import Hero from './components/Hero.vue'
import Result from './components/Result.vue'
import Lab from './components/Lab.vue'
import Imager from './components/Imager.vue'
import Footer from './components/Footer.vue'
import { mapGetters } from 'vuex'
export default {
name: 'App',
data() {
return {
captured: false
}
},
components: {
Hero,
Result,
Lab,
Imager,
Footer,
},
computed: {
...mapGetters([
'image'
])
},
watch: {
image() {
this.captured = !!this.image
}
}
}
</script>
<style>
* {
margin: 0;
box-sizing: border-box;
}
</style>
Top comments (3)
Great demo. I already wanted to do something like this to share an image of current Covid incidences within my widget: github.com/stritti/covid-ampel-widget
I will try to follow your article in the next days.
Thanks man, highly appreciated!
Thanks man, highly appreciated!