I wrote my own website in Laravel and VueJS. It’s nothing special, just a page about me and some of my skills, and a blog page. Eventually I intend to have a page for some projects to showcase some of the things I've created in my spare time.
A few days ago, the blog index page was, well… shite, just a paginated list of the posts, nothing exciting and a pretty crap layout. I wanted to spruce it up with a masonry feel, but didn’t want to import any packages, so had a go at building it myself.
This is resulting outcome. Check it out here johnhalsey.co.uk/blog
So how did I achieve this?
Firstly, I make an api call to get the posts from the database. I send back a JSON response in a Laravel resource (not shown here).
export default {
name: 'Posts',
props: {
category: {
type: String
}
},
data () {
return {
posts: []
}
},
mounted () {
this.getPosts()
},
methods: {
getPosts () {
let params = {}
if(this.category != '') {
params.category = this.category
}
window.axios.get('/api/posts', {params})
.then(response => {
this.posts = response.data.data
this.calculateImageCount()
})
}
}
}
The masonry layout is achieved with CSS Grid and a javascript calculation for each post that works out how many grid rows it needs to take up, but it can't start to work that out, until all the images have loaded and rendered on the browser, and since not all posts have images, I calculate how many of the posts have images.
data () {
return {
imageCounter: 0
}
},
methods: {
calculateImageCount () {
for (let i = 0; i < this.posts.length; i++) {
if (this.posts[i].media.featured.medium != '') {
this.imageCounter++
}
}
}
}
Then in the template, when looping through the posts, I mark when each image is loaded, and increment a counter.
<template>
<div class="masonry">
<div v-for="(post, key) in posts"
:key="key"
class="card"
>
<div class="card-content">
<div v-if="post.media.featured.medium != ''">
<img :src="post.media.featured.medium" :alt="post.title" class="img-responsive" @load="rendered">
</div>
<div class="p-20">
<a :href="post.link">
<h3>{{ post.title }}</h3>
</a>
<a v-for="category in post.categories" :key="category.id" :href="'/blog?category=' + category.slug">{{ category.title }} <br></a>
<p class="font-12">Posted on {{ post.published_at }}</p>
</div>
</div>
</div>
</div>
</template>
export default {
data () {
return {
imagesCount: 0
}
},
methods: {
rendered () {
this.imagesCount++
}
}
}
I watch the image counter and when it gets to the same number of images I know I’m expecting, I can calculate the height of the post cards.
watch: {
imagesCount: function () {
if(this.imagesCount == this.imageCounter){
this.resizeAllMasonryItems()
}
}
},
Then the magic happens. the resizeAllMasonryItems()
method loops through all the posts now that all images have loaded, and calls another method to actually resize the item, and it does that by applying a grid-row-end: span [dynamic number]
style to each post card.
resizeAllMasonryItems () {
// Get all item class objects in one list
let allItems = document.getElementsByClassName('card');
/*
* Loop through the above list and execute the spanning function to
* each list-item (i.e. each masonry item)
*/
for (let i = 0; i < allItems.length; i++) {
this.resizeMasonryItem(allItems[i]);
}
}
Each item gets passed into the resizeMasonryItem()
method.
resizeMasonryItem (item) {
/* Get the grid object, its row-gap, and the size of its implicit rows */
let grid = document.getElementsByClassName('masonry')[0],
rowGap = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-row-gap')),
rowHeight = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-auto-rows'));
/*
* Spanning for any brick = S
* Grid's row-gap = G
* Size of grid's implicitly create row-track = R
* Height of item content = H
* Net height of the item = H1 = H + G
* Net height of the implicit row-track = T = G + R
* S = H1 / T
*/
let rowSpan = Math.ceil((item.querySelector('.card-content').getBoundingClientRect().height + rowGap) / (rowHeight + rowGap));
/* Set the spanning as calculated above (S) */
item.style.gridRowEnd = 'span ' + rowSpan;
},
I style the parent element with some basic CSS, using CSS Grid.
<style type="text/css">
.masonry {
display: grid;
grid-gap: 15px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-auto-rows: 0;
}
</style>
Finally, when the component is created I put event listeners on load and resize of the page to keep calculating the card heights. Technically I don’t even need the load event listener, but seems nice to have it there.
created () {
let masonryEvents = ['load', 'resize'];
let vm = this
masonryEvents.forEach(function (event) {
window.addEventListener(event, vm.resizeAllMasonryItems);
});
}
That’s it, now I have a pretty cool masonry layout for my blog posts. The whole thing looks like this. And it's responsive too.
<template>
<div class="masonry">
<div v-for="(post, key) in posts"
:key="key"
class="card"
>
<div class="card-content">
<div v-if="post.media.featured.medium != ''">
<img :src="post.media.featured.medium" :alt="post.title" class="img-responsive" @load="rendered">
</div>
<div class="p-20">
<a :href="post.link">
<h3>{{ post.title }}</h3>
</a>
<a v-for="category in post.categories" :key="category.id" :href="'/blog?category=' + category.slug">{{ category.title }} <br></a>
<p class="font-12">Posted on {{ post.published_at }}</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Posts',
props: {
category: {
type: String
}
},
data () {
return {
posts: [],
imageCounter: 0,
imagesCount: 0
}
},
mounted () {
this.getPosts()
},
created () {
let masonryEvents = ['load', 'resize'];
let vm = this
masonryEvents.forEach(function (event) {
window.addEventListener(event, vm.resizeAllMasonryItems);
});
},
watch: {
imagesCount: function () {
if(this.imagesCount == this.imageCounter){
this.resizeAllMasonryItems()
}
}
},
methods: {
rendered () {
this.imagesCount++
},
getPosts () {
let params = {}
if(this.category != '') {
params.category = this.category
}
window.axios.get('/api/posts', {params})
.then(response => {
this.posts = response.data.data
this.calculateImageCount()
})
},
calculateImageCount () {
for (let i = 0; i < this.posts.length; i++) {
if (this.posts[i].media.featured.medium != '') {
this.imageCounter++
}
}
},
resizeMasonryItem (item) {
/* Get the grid object, its row-gap, and the size of its implicit rows */
let grid = document.getElementsByClassName('masonry')[0],
rowGap = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-row-gap')),
rowHeight = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-auto-rows'));
/*
* Spanning for any brick = S
* Grid's row-gap = G
* Size of grid's implicitly create row-track = R
* Height of item content = H
* Net height of the item = H1 = H + G
* Net height of the implicit row-track = T = G + R
* S = H1 / T
*/
let rowSpan = Math.ceil((item.querySelector('.card-content').getBoundingClientRect().height + rowGap) / (rowHeight + rowGap));
/* Set the spanning as calculated above (S) */
item.style.gridRowEnd = 'span ' + rowSpan;
},
resizeAllMasonryItems () {
// Get all item class objects in one list
let allItems = document.getElementsByClassName('card');
/*
* Loop through the above list and execute the spanning function to
* each list-item (i.e. each masonry item)
*/
for (let i = 0; i < allItems.length; i++) {
this.resizeMasonryItem(allItems[i]);
}
}
}
}
</script>
<style lang="scss" type="text/css">
.masonry {
display: grid;
grid-gap: 15px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-auto-rows: 0;
}
</style>
Top comments (2)
Really enjoyed that. Thanks! If you feel like it, and have time Id love to get a source of the masonry. Its really good and its easier to work with a git repo (especially for a noob like me. Just a humble wish.
Take care and keep being awesome!
Thanks for sharing chief...I opened it to read it in vuejs only to realise you did it with laravel too..Thanks for sharing