Blog

In this lesson, we will create a custom collection to store our blog posts. We will then create the post listing and single pages based on our design.


Posts collection

To begin, we will create the posts collection by utilizing the pageLikeCollection helper to set up a publicly accessible collection with the same fields as the default pages collection. The URL path prefix will be set to blog, resulting in post URLs that follow the format /blog/[post-slug].

# collections/posts.ts

import { defineCollection } from '#pruvious'
import { pageLikeCollection } from '#pruvious/standard'

// @see https://pruvious.com/docs/collections
export default defineCollection(
  pageLikeCollection({
    name: 'posts',
    pathPrefix: 'blog',
    icon: 'Pin',
    allowedLayouts: ['post'],
    additionalPublicPagesFields: ['headline', 'author'],
    additionalFields: {
      headline: {
        type: 'text',
        options: {
          label: 'Headline',
          description: 'If left empty, the page title will be used',
        },
      },
      author: {
        type: 'record',
        options: {
          collection: 'users',
          fields: ['firstName'],
          populate: true,
        },
      },
    },
  }),
)

Post layout

In the previously defined posts collection, we allowed only the layout post. Let's create it:

# layouts/post.vue

<template>
  <Header class="mt-12" />

  <div class="my-23 space-y-23 border-b pb-23 dark:border-white/10">
    <Container>
      <div class="max-w-content">
        <BackButton :to="blogLandingPage">Blog</BackButton>
        <h1 class="mt-3 text-post-title">{{ page?.fields.headline || page?.title }}</h1>
        <WrittenOn :author="page?.fields.author" :publishDate="page?.publishDate" class="mt-2 text-sm" />
      </div>
    </Container>

    <!-- Our post blocks will be rendered here -->
    <slot />
  </div>

  <Footer class="mb-23" />
</template>

<script lang="ts" setup>
import { defineLayout } from '#pruvious'
import { getCollectionData, usePage } from '#pruvious/client'

defineLayout({
  allowedBlocks: ['Image', 'Prose'],
})

const page = unref(usePage())
const { blogLandingPage } = await getCollectionData('settings')
</script>

<style lang="postcss" scoped>
:deep() .prose > h2 {
  @apply text-2xl;
}

:deep() .prose > h3 {
  @apply text-xl;
}
</style>

We used two new helper components in the layout that display the "back" button (BackButton) and the post metadata (WrittenOn), such as the publish date and author. The "back" button links to a landing page where all our posts will be shown. We can create this page in the default pages collection and extend our settings collection by adding a new field named blogLandingPage. This way, we can fetch it in our Vue app using the getCollectionData utility, as shown in the post layout component.

# collections/settings.ts

import { defineCollection } from '#pruvious'

export default defineCollection({
  ...
  },
  fields: {
    ...
    blogLandingPage: {
      type: 'link',
      options: {
        required: true,
        description: 'The page where all blog posts are listed',
      },
    },
  },
  dashboard: {
    fieldLayout: [
      {
        ...
        Blog: ['blogLandingPage'],
      },
    ],
  },
})

We should set the slug of our blog landing page to /blog for a correct URL structure on our website.

Let's create the helper components now. The BackButton will look like this:

# components/BackButton.vue

<template>
  <NuxtLink v-if="to" :to="to" class="group inline-flex items-center gap-2 font-heading text-sm">
    <IconChevronLeft class="h-3.5 w-3.5 transition-transform group-hocus:-translate-x-0.5" />
    <span>
      <slot />
    </span>
  </NuxtLink>
</template>

<script lang="ts" setup>
defineProps({
  to: String,
})
</script>

And here is the WrittenOn component:

# components/WrittenOn.vue

<template>
  <span v-if="writtenOn" class="block italic text-dim">
    Written on {{ writtenOn }}
    <span v-if="author">by {{ author?.firstName }}</span>
  </span>
</template>

<script lang="ts" setup>
import type { PopulatedFieldType } from '#pruvious'

const props = defineProps<{
  author?: PopulatedFieldType['posts']['author']
  publishDate?: PopulatedFieldType['posts']['publishDate']
}>()

const writtenOn = props.publishDate
  ? new Date(props.publishDate).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
  : null
</script>

Now, we can add posts through the dashboard, and they will work just like regular pages.

Posts block

The next step is to create a block that will display a paginated list of our posts on our blog landing page. Let's start by creating a custom API route that accepts a current page query parameter and returns three posts per page.

# server/api/posts.get.ts

import { isPositiveInteger } from '#pruvious'
import { query, resolvePagePath } from '#pruvious/server'

export default defineEventHandler(async (event) => {
  const qs = getQuery(event)
  const page = qs.page ? Number(qs.page) : 1

  if (!isPositiveInteger(page)) {
    setResponseStatus(event, 400)
    return "The 'page' query parameter must be a positive integer"
  }

  const result = await query('posts')
    .select(['author', 'description', 'headline', 'path', 'publishDate', 'sharingImage'])
    .where('public', true)
    .order('publishDate', 'desc')
    .populate()
    .paginate(page, 3)

  return {
    ...result,
    records: await Promise.all(
      result.records.map(async (post) => ({
        ...post,
        path: await resolvePagePath(post.path, 'posts'),
      })),
    ),
  }
})

We'll fetch this route directly from our block component, like this:

# blocks/Posts.vue

<template>
  <Container class="space-y-23">
    <div v-for="{ author, description, headline, path, publishDate, sharingImage } of data?.records" :key="path">
      <div class="flex items-center gap-8 tp:flex-col">
        <PruviousPicture :image="sharingImage" :imgAttrs="{ class: 'w-95 h-auto shrink-0 rounded-md tp:w-full' }" />
        <div class="flex-1">
          <h2>
            <NuxtLink :to="path">{{ headline }}</NuxtLink>
          </h2>
          <WrittenOn :author="author" :publishDate="publishDate" class="mt-2 text-vs" />
          <div v-if="description" class="prose mt-4">
            <p>{{ description }}</p>
          </div>
          <Button :to="path" class="mt-6">Read more</Button>
        </div>
      </div>
    </div>

    <div v-if="data && data.total > data.perPage" class="flex gap-8">
      <Button v-if="data.currentPage > 1" :to="`${route.path}?page=${data.currentPage - 1}`">Previous page</Button>
      <Button
        v-if="data.lastPage > data.currentPage"
        :to="`${route.path}?page=${data.currentPage + 1}`"
        class="ml-auto"
      >
        Next page
      </Button>
    </div>
  </Container>
</template>

<script lang="ts" setup>
import { defineBlock, type PaginateResult, type PopulatedFieldType } from '#pruvious'

defineBlock({
  icon: 'List',
})

const route = useRoute()
const page = computed(() => (route.query.page ? Number(route.query.page) : 1))

const { data } = await useFetch<
  PaginateResult<
    Pick<PopulatedFieldType['posts'], 'author' | 'description' | 'headline' | 'path' | 'publishDate' | 'sharingImage'>
  >
>('/api/posts', { query: { page } })

if (!data.value?.records.length && (page.value !== 1 || data.value?.total)) {
  if (process.server) {
    throw createError({ statusCode: 404 })
  } else {
    showError({ statusCode: 404 })
  }
}
</script>

In our new block, we included a third helper component named Button. Below is the code:

# components/Button.vue

<template>
  <NuxtLink
    v-if="to"
    :to="to"
    class="group relative inline-flex h-4 items-center pl-6 font-heading text-sm text-heading transition dark:text-white"
  >
    <span
      class="absolute left-0 top-0 h-4 w-4 rounded-full bg-heading transition group-hocus:scale-75 dark:bg-white"
    ></span>
    <span class="mt-px">
      <slot />
    </span>
  </NuxtLink>
</template>

<script lang="ts" setup>
defineProps({
  to: String,
})
</script>

Fine-tuning

Finally, we want to highlight the "Blog" menu item not just on the landing page, but also when viewing a single post. To achieve this, we can modify the Menu component as follows:

# components/Menu.vue

<template>
  <nav
    v-if="menu.length"
    class="relative tp:fixed tp:inset-0 tp:z-20 tp:flex tp:overflow-y-auto tp:bg-white/75 tp:backdrop-blur tp:backdrop-filter tp:transition-all tp:duration-300 tp:dark:bg-heading/80"
    :class="{ 'tp:invisible tp:opacity-0': !mobileMenuVisible }"
  >
    <ul @mouseleave="updateDotPosition()" class="flex gap-15 tp:m-auto tp:flex-col tp:p-6 tp:text-center">
      <li v-for="({ label, link }, i) of menu" ref="menuItemEls">
        <NuxtLink
          :to="link"
          @click="mobileMenuVisible = false"
          @mouseenter="updateDotPosition(i)"
          class="text-sm font-bold text-heading transition dark:text-white"
          :class="{ 'tp:underline tp:decoration-2 tp:underline-offset-4': i === activeMenuIndex }"
        >
          {{ label }}
        </NuxtLink>
      </li>
    </ul>

    <span
      v-if="dotPosition !== null"
      class="absolute top-1/2 -ml-3.5 mt-px h-1.5 w-1.5 rounded-full bg-heading transition duration-300 dark:bg-white tp:hidden"
      :style="{ transform: `translate3d(${dotPosition}px, -50%, 0)` }"
    ></span>
  </nav>
</template>

<script lang="ts" setup>
...
onMounted(() => {
  activeMenuIndex = menu.findIndex(({ link }) => link === route.path || route.path.startsWith(`${link}/`))
  updateDotPosition()
})
...
</script>

The blog is ready!

Playground

You can access the current project files at the following link:

Explore the GitHub repository.

Last updated on April 21, 2024 at 15:51