Beyond The Horizon

Pagination with Nuxt-Content

Website Monitoring Magazine

In the previous article, I showed you how to add a categorization to the magazine. This sets the course for an expansion of the content offer. However, this creates a problem, that of the loading time. You don't want to wait until hundreds of articles are collected, filtered and then output, smaller bites are better for the loading time.

Number of articles

For the pagination to work, the total number of all articles is needed.

Total number of articles / articles per page = number of paginated pages

This is not a big problem, because after the query, you only have to count the number of delivered items. But if you limit the number (to keep the resulting JSON small), because you want to display only nine items as in the case below, the total number consists of only nine or less items.

const articles = await this.$content(this.path, { deep: true })
  .only(['title', 'description', 'slug', 'type', 'image', 'dir', 'path', 'author'])
  .limit(9)
  .fetch()

You could remove the limit altogether, but that wouldn't make sense. So what remains? One sends an additional request, which collects all articles of the filter in question, independent of a limit. Since only([]) excludes all other information, you get an array with many empty arrays, from which you then take the total number.

const articleCount = (await this.$content(this.path, { deep: true })
  .only([])
  .fetch())
  .length

Problem solved? Almost...

Reduction of the requests

In the upper case, you now send two asynchronous calls that retrieve all articles (without further details) and then the last 9 (with more details). The purpose behind the query is to keep the resulting JSON as small as possible. This doesn't matter so much with 20 or 30 articles, but with 100 or more, the whole thing works more and more against the performance.

The whole thing adds up if you filter the article previews by categories, authors or tags. There is then not only a number of all articles, but also a number of articles that fall under the category 'plate edge'.

So there must be a kind of cache, which stores the number of articles of the selected filter. So the first time a category overview is called it will still send two queries (once for the count and then for the articles), but the second time the count query is omitted.

Vuex Store

To store the count of the articles, a store is created with Vuex.

export const state = () => ({
  polling: true,
  counts: {},
  paginatedCount: 12
})

export const mutations = {
  SET_POLLING (state, status) {
    state.polling = status
  },
  SET_COUNT (state, { type, count }) {
    state.counts[type] = count
  }
}

export const actions = {
  setCount ({ commit, state }, { type, count }) {
    commit('SET_COUNT', { type, count })
  },
  setPolling ({ commit }, status) {
    commit('SET_POLLING', status)
  }
}

export const getters = {
  getPolling: state => state.polling,
  getCount: state => state.counts,
  getPaginatedCount: state => state.paginatedCount
}

This is kept relatively simple. The article count is stored in an object counts and then returned via getCount. By association with the respective filter, one can query on the component side whether the corresponding count is available.

counts: {
    agenturleben: 8,
    tellerrand: 3,
    nils: 7
}

__What you might notice is that I don't split by author and category again, I'm good that we don't name categories like authors do.

I also stored the number of articles per page in the store, this will be needed later for the 'ArticleCollection' and the 'Pagination'.

On top of that there is the polling that will trigger the Computed Properties within the Paginations components....

Cache function and article selection

The ArticleCollection, is used to collect the article previews. The component is used on the home page, the category page and the author page, so it is guaranteed that it is in action for each of the possible filters. And is therefore predestined to initialize the cache.

import { mapGetters } from 'vuex'
import ArticlePreview from '@/components/notice/ArticlePreview/ArticlePreview'

export default {
  name: 'ArticleCollection',
  components: { ArticlePreview },
  props: {...},
  async fetch () {
    const { query } = this.$route
    const pageNumber = query.p ? query.p : 1

    this.error = false

    const baseQuery = () => ({ type: { $ne: 'category' } })
    const publishQuery = () => process.env.NODE_ENV === 'production' ? ({ published: true }) : null
    const authorQuery = () => this.authorArticles ? ({ author: { $eq: this.$route.params.author } }) : null

    const limiter = this.asident
      ? 1
      : this.paginated
        ? this.paginatedCount
        : this.limit

    const skipper = this.paginated
      ? this.paginatedCount * (pageNumber - 1)
      : null

    try {
      if (this.paginated) {
        this.$store.dispatch('magazine/setPolling', true)
        const path = this.authorArticles ? this.$route.params.author : this.path.split('/')[2]
        if (!this.getCount[path]) {
          const articleCount = (await this.$content(this.path, { deep: true })
            .only([])
            .where({ ...baseQuery(), ...publishQuery(), ...authorQuery() })
            .fetch())
            .length
          this.$store.dispatch('magazine/setCount', { type: path, count: articleCount })
        }
        this.$store.dispatch('magazine/setPolling', false)
      }

      const articles = await this.$content(this.path, { deep: true })
        .only(['title', 'description', 'slug', 'type', 'image', 'dir', 'path', 'author'])
        .where({ ...baseQuery(), ...publishQuery(), ...authorQuery() })
        .sortBy('createdAt', this.sortDirection)
        .skip(skipper)
        .limit(limiter)
        .fetch()

      this.articles = articles
        .filter(val => this.activeArticle.length ? val.title !== this.activeArticle : val)
    } catch (error) {
      console.error(error)
      this.error = true
    }
  },

  data () {
    return {
      articles: null,
      error: false
    }
  },

  computed: {
    ...mapGetters({
      getCount: 'magazine/getCount',
      paginatedCount: 'magazine/getPaginatedCount'
    })
  },

  watch: {
    '$route.query': '$fetch'
  }
}

If you have looked at the last version, you will have noticed the extension, I will now go into the details. Since you set the page number by query parameters, it is of course useful to save the query, if necessary to set a default value of 1.

async fetch() {
    const { query } = this.$route
    const pageNumber = query.p ? query.p : 1
...
}

Whether the entries are paginated depends on whether the Prop paginated was set. If this is the case, the limit stored in the store paginatedCount is used.

const limiter = this.asident
  ? 1
  : this.paginated
    ? this.paginatedCount
    : this.limit

But now it gets really interesting, because with the value for skip, we determine how many articles are skipped in the query. Again, it is only set when pagination is enabled. For example, if 12 articles are set per page and we are on page 3, 24 articles will be skipped.

const skipper = this.paginated
  ? this.paginatedCount * (pageNumber - 1)
  : null

Now we come to the triggering of the cache, which is in the try-catch block. Not much exciting happens there, it's just that we can store the number of items. If we already have the number, not much more happens here.

if (this.paginated) {
  const path = this.authorArticles 
    ? this.$route.params.author 
    : this.path.split('/')[2]

  if (!this.getCount[path]) {
    this.$store.dispatch('magazine/setPolling', true)
    const articleCount = (await this.$content(this.path, { deep: true })
      .only([])
      .where({ ...baseQuery(), ...publishQuery(), ...authorQuery() })
      .fetch())
      .length

    this.$store.dispatch('magazine/setCount', { type: path, count: articleCount })
    this.$store.dispatch('magazine/setPolling', false)
  }
}

Last but not least, in addition to the obligatory mapGetters, there is a watcher that monitors the route. If something changes in the queries, it will trigger a new fetch() and thus re-render the view.

watch: {
  '$route.query': '$fetch'
}

Pagination

The store is used to get the data necessary to build the pagination. In the case of Koality there is only a pagination if a category is used or you want to display the articles of an author.

The computed property pages() is updated whenever something changes in the path. This saves several watchers. Otherwise, although the component is the core, it is kept relatively simple.

<template>
  <div v-if="pages > 1">
    <nav class="flex items-center justify-center -mx-1">
      <div
        v-for="page in pages"
        :key="page"
        class="px-1"
      >
        <a
          class="h-8 w-8 border border-gray-200 block flex-center cursor-pointer "
          :class="[page === parseInt(current) ? 'bg-gray-600 text-white' : 'text-gray-600']"
          @click.prevent="$router.push({ query: { p: page }})"
        >
          {{ page }}
        </a>
      </div>
    </nav>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  name: 'MagazinPagination',
  props: {
    path: {
      type: String,
      default: '/articles'
    },
    authorArticles: {
      type: Boolean,
      default: false
    }
  },

  computed: {
    ...mapGetters({
      getCount: 'magazine/getCount',
      paginatedCount: 'magazine/getPaginatedCount',
      polling: 'magazine/getPolling'
    }),
    current () {
      const { query } = this.$route
      return query.p ? query.p : 1
    },
    pages () {
      if (this.polling) { return }
      const path = this.authorArticles
        ? this.$route.params.author
        : this.path.split('/')[2]
      const pages = Math.round(this.getCount[path] / this.paginatedCount)
      return pages > 0 ? pages : 1
    }
  }
}
</script>