NuxtJS-Frontend für ein Prismic.io Blog

Im letzten Teil dieser Serie haben wir einen Artikeltyp bei Prismic angelegt und die ersten Artikel geschrieben. Nun sollen die Inhalte auch für andere abrufbar sein.

Hierzu verwenden wir NuxtJS. NuxtJs baut auf VueJS auf, bietet aber noch einige mehr Funktionen, die uns das Leben etwas einfacher machen.

Ich gehe in diesem Tutorial schon mal davon aus, dass alles notwendig für NuxtJS installiert ist und NuxtJS schon initiiert worden ist. Dieses Tutorial soll keine komplette Schritt für Schritt Anleitung sein, sondern ehr auf die wichtigen Punkte eingehen. Den ganzen Code gibt es kostenlos auf Github.

Prismic.io Nuxt Modul

Zuerst sollte das Prismic.io Nuxt Modul installiert werden. Zwar könnte man auch Axios verwenden, aber das Modul erleichtert uns die Interaktion mit der API deutlich.

Installiert wird es mit folgendem Befehl:
yarn add @nuxtjs/prismic
oder
npm install @nuxtjs/prismic

Nun muss das Modul in der nuxt.config.js noch registriert werden. Außerdem muss der API-Point angegeben werden. Dieser ist in den Einstellungen auf Prismic.io zu finden.

  /*
   ** Nuxt.js modules
   */
  modules: ['@nuxtjs/prismic'],
  /*
   ** Prismic module configuration
   ** Change the API endpoint!
   */
  prismic: {
    endpoint: 'https://tamaki.cdn.prismic.io/api/v2',
    ……
  },

Artikel

Nun können wir sehr einfach auf unsere Inhalte in Prismic.io zugreifen. Ein guten Einstieg und Übersicht, was möglich ist, bietet die API-Dokumentation.

Das Erste was wir machen werden ist die Einzelansicht eines Artikels. Hierzu legen wir die Datei “_uid.vue” in “/pages” an. Was wir in dieser Datei einfügen, ist nun unter /XXX erreichbar. Genauer gesagt, wird dieser Parameter, dem Slug unserer Artikel in Prismic entsprechen. Geben wir also für einen Artikel in unserem CMS “erster-artikel” als Slug an, wird dieser Artikel unter “example.com/erster-artikel” abrufbar sein.

In der asyncData Method von NuxtJS sprechen wir mit der API von Prismic und lassen uns das Ergebnis geben.

 async asyncData({ $prismic, params, error }) {
    try {
      // Query post
      const post = await $prismic.api.getByUID('post', params.uid)

      // Returns data to be used in template
      return {
        title: post.data.title,
        date: Intl.DateTimeFormat('en-US').format(
          new Date(post.first_publication_date)
        ),
        mainimage: post.data.main_image,
        slices: post.data.body
      }
    } catch (e) {
      // Returns error page
      error({ statusCode: 404, message: 'Page not found' })
    }
  },

Zuerst stellen wir eine Anfrage an die API. Gib uns den Artikel mit dem Artikeltyp “Post” und dem Slug aus der URL. Das Ergebnis enthält einiges an Daten, viel davon brauchen wir gar nicht. Der Übersicht halber lassen wir uns daher nur den Titel, das Erstellungsdatum (direkt als internationales Datum), das Main Image und die Slices geben.

Diese Variablen können wir nun in unserer Template-Sektion verwenden.

<template>
  <main class="col-span-12">
    <article>
      <div class="grid items-center grid-cols-1 md:grid-cols-5 post-header">
        <div class="post-image" v-if="mainimage.url !== undefined">
          <ResponsiveImg :imgobject="mainimage" :sizes="'(min-width: 768px) 60vw, 100vw'" />
        </div>
        <div class="w-10/12 mx-auto mt-4 post-data md:mt-0 md:w-full md:pl-8">
          <h1
            class="text-3xl font-bold tracking-wider uppercase break-words lg:text-5xl"
          >{{ $prismic.asText(title) }}</h1>

          <div class="w-16 mt-2 mb-4 border-b-2 border-accent-dark"></div>

          <ul class="flex justify-start list-none post-meta">
            <li class="italic date">{{date}}</li>
          </ul>
        </div>
      </div>

      <div class="content">
        <Slices :slicedata="slices" />
      </div>
    </article>
  </main>
</template>

Wie man sieht, wird das Datum und der Titel direkt ausgeben. Beim Titel verwenden wir eine weitere Funktion des NuxtJS Moduls von Prismic. Diese Funktion gibt den Titel direkt als Text aus. Später verwenden wir noch eine Funktion, die Text als Richtext ausgibt.

Das Bildobjekt wird direkt an eine Komponente weitergereicht. Man könnte dies zwar auch integrieren, aber wir verwenden diese Komponente später noch öfters.

Die Komponente sieht so aus:

<template>
  <img :srcset="GetImageSrcset()" :src="imgobject.url" :sizes="sizes" :alt="imgobject.alt" />
</template>

<script>
export default {
  props: {
    imgobject: {
      type: Object,
      default: null
    },
    sizes: {
      type: String,
      default: null
    }
  },
  methods: {
    GetImageSrcset() {
      let srcset =
        `${this.imgobject['320'].url} ${this.imgobject['320'].dimensions.width}w, ` +
        `${this.imgobject['640'].url} ${this.imgobject['640'].dimensions.width}w, ` +
        `${this.imgobject['768'].url} ${this.imgobject['768'].dimensions.width}w, ` +
        `${this.imgobject['1024'].url} ${this.imgobject['1024'].dimensions.width}w, ` +
        `${this.imgobject['1536'].url} ${this.imgobject['1536'].dimensions.width}w, ` +
        `${this.imgobject.url} ${this.imgobject.dimensions.width}w, `

      return srcset
    }
  }
}
</script>

Je nach verwendeten Bildgrößen, müssen die entsprechenden Parameter ausgetauscht werden.

article-header-blog-prismic-nuxtjs
Der Header des Artikels

Slices

Eine weitere Komponente kommt für die Slices, in denen sich der eigentliche Inhalt des Blogartikels befindet, zum Einsatz. In Slices.vue wird jedes Element durchgegangen und je nach Typ an eine weitere Komponente übergeben. Dort wird der Inhalt dann dementsprechend verarbeitet und dargestellt.

<template>
  <div>
    <section v-for="(slice, index) in slicedata" :key="'slice-' + index">
      <div v-if="slice.slice_type === 'text'">
        <TextSlice :slice="slice" />
      </div>

      <div v-else-if="slice.slice_type === 'image'">
        <ImageSlice :slice="slice" />
      </div>

      <div v-else-if="slice.slice_type === 'gallery'">
        <Gallery :slice="slice" />
      </div>
    </section>
  </div>
</template>

<script>
import Gallery from '~/components/slice/Gallery'
import ImageSlice from '~/components/slice/ImageSlice'
import TextSlice from '~/components/slice/TextSlice'

export default {
  components: {
    Gallery,
    ImageSlice,
    TextSlice
  },
  props: {
    slicedata: {
      type: Array,
      default: null
    }
  }
}
</script>

<template>
  <div class="my-16">
    <div
      class="pl-4 mb-8 text-3xl text-center title dark-mode:text-white md:pl-0"
      v-if="slice.primary.title.length > 0"
    >
      <h2>{{ $prismic.asText(slice.primary.title) }}</h2>
      <div class="w-1/12 mx-auto mt-4 border-b-2 border-accent-dark"></div>
    </div>

    <div v-if="slice.primary.column_number === '2'" class="two">
      <div v-for="item in slice.items" :key="item.id" class="mb-8">
        <ResponsiveImg :imgobject="item.image" :sizes="'(min-width: 768px) 50vw, 98vw'" />
      </div>
    </div>

    <div v-else-if="slice.primary.column_number === '3'" class="three">
      <div v-for="item in slice.items" :key="item.id" class="mb-8">
        <ResponsiveImg :imgobject="item.image" :sizes="'(min-width: 768px) 33vw, 98vw'" />
      </div>
    </div>

    <div v-else-if="slice.primary.column_number === '4'" class="four">
      <div v-for="item in slice.items" :key="item.id" class="mb-8">
        <ResponsiveImg :imgobject="item.image" :sizes="'(min-width: 768px) 25vw, 98vw'" />
      </div>
    </div>
  </div>
</template>

<script>
import ResponsiveImg from '~/components/ResponsiveImg'

export default {
  components: {
    ResponsiveImg
  },
  props: {
    slice: {
      type: Object,
      default: null
    }
  }
}
</script>
<template>
  <div class="grid grid-cols-12 my-16">
    <div v-if="slice.primary.whitespace === 'None'" class="col-span-12">
      <ResponsiveImg :imgobject="slice.primary.image" :sizes="'100vw'" />
    </div>

    <div v-else-if="slice.primary.whitespace === 'Left'" class="col-start-3 col-end-13">
      <ResponsiveImg :imgobject="slice.primary.image" :sizes="'90vw'" />
    </div>

    <div v-else-if="slice.primary.whitespace === 'Right'" class="col-span-10">
      <ResponsiveImg :imgobject="slice.primary.image" :sizes="'90vw'" />
    </div>

    <div v-else-if="slice.primary.whitespace === 'Center'" class="col-start-2 col-end-12">
      <ResponsiveImg :imgobject="slice.primary.image" :sizes="'90vw'" />
    </div>
  </div>
</template>

<script>
import ResponsiveImg from '~/components/ResponsiveImg'

export default {
  components: {
    ResponsiveImg
  },
  props: {
    slice: {
      type: Object,
      default: null
    }
  }
}
</script>

<template>
  <div class="w-10/12 mx-auto my-16 dark-mode:text-white text-slice">
    <prismic-rich-text :field="slice.primary.text" v-if="slice.primary.text.length > 0" />
  </div>
</template>

<script>
export default {
  props: {
    slice: {
      type: Object,
      default: null
    }
  }
}
</script>

Damit sind wir auch schon fertig mit der Einzelansicht für die Artikel. Das Ergebnis sieht dann ungefähr so aus:

blog-prismic-nuxtjs-javascript-cms
Die Artikelansicht mit den Slices

Startseite

Weiter geht es mit der Startseite für unseren Blog. Dafür verwenden wir die “index.vue” in unserem “pages”-Ordner.

Auch sprechen wir mit der API in asyncData. In der Anfrage lassen wir uns Artikel mit dem Artikeltyp “post” geben. Sortiert werden soll das ganze absteigend nach dem ersten Veröffentlichungsdatum. Um die Startseite nicht zu sehr zu überfüllen, limitieren wir die Anfrage auf maximal 9 Ergebnisse. Das Ergebnis soll als posts zurückgegeben werden.

 async asyncData({ $prismic, error }) {
    try {
      // Query last posts
      const posts = await $prismic.api.query(
        $prismic.predicates.at('document.type', 'post'),
        {
          orderings: '[document.first_publication_date desc]',
          pageSize: 9
        }
      )
      // Returns data to be used in template
      return {
        posts: posts.results
      }
    } catch (e) {
      // Returns error page
      error({ statusCode: 404, message: 'Page not found' })
    }
  }

Dargestellt wird der Array von Ergebnissen dann so:


<template>
  <main class="col-start-2 col-end-12">
    <div class="grid grid-cols-12 row-gap-16 md:col-gap-16" v-if="posts.length > 0">
      <div class="col-span-12">
        <h2 class="text-2xl tracking-wider text-center uppercase text-bold">Latest posts</h2>
        <div class="w-1/12 mx-auto mt-2 border-b-4 border-gray-400"></div>
      </div>
      <div v-for="post in posts" :key="post.id" class="col-span-12 md:col-span-4">
        <GridPost :postdata="post" :imgsize="'(min-width: 768px) 33vw, 90vw'" />
      </div>
    </div>

  </main>
</template>

Auch hier wird der einzelne Artikel über eine Komponente dargestellt. Diese sieht so aus:

<template>
  <section class="bg-gray-200 shadow dark-mode:bg-gray-800">
    <nuxt-link :to="LinkGetter(postdata)">
      <div class="main_image" v-if="postdata.data.main_image.url !== undefined">
        <ResponsiveImg :imgobject="postdata.data.main_image" :sizes="imgsize" />
      </div>

      <div class="px-8 py-4 text">
        <div class="text-sm italic date">
          {{
          Intl.DateTimeFormat('en-US').format(
          new Date(postdata.first_publication_date)
          )
          }}
        </div>

        <div class="mt-1 mb-4 text-xl font-bold title">
          {{ $prismic.asText(postdata.data.title) }}
          <div class="w-8 border-b-2 border-accent-dark"></div>
        </div>

        <div class="text-xs summary" v-if="postdata.data.summary.length > 0">
          <prismic-rich-text :field="postdata.data.summary" />
        </div>
      </div>
    </nuxt-link>
  </section>
</template>

<script>
import ResponsiveImg from '~/components/ResponsiveImg'
import LinkResolver from '~/plugins/link-resolver.js'

export default {
  name: 'GridPost',
  components: {
    ResponsiveImg
  },
  props: {
    postdata: {
      type: Object,
      default: null
    },
    imgsize: {
      type: String,
      default: '100vw'
    }
  },
  methods: {
    LinkGetter(post) {
      return LinkResolver(post)
    }
  }
}
</script>

Pagination

Sind unsere Leser an mehr Artikeln interessiert, können sie über einen “Load More” Button mehr Artikel sich anzeigen lassen. Dazu fügen wir folgenden Code hinzu:

<template>
  <main class="col-start-2 col-end-12">

    <div class="flex col-span-12 mt-16 loadmore">
      <button
        class="px-8 py-2 mx-auto text-lg text-center border-2 border-black border-solid cursor-pointer dark-mode:border-white hover:border-accent-dark"
        @click="loadMorePosts()"
        v-if="posts.length % 9 === 0 && !nonewposts"
      >Load more</button>
      <button
        v-else
        class="px-8 py-2 mx-auto text-lg text-center border-2 border-black border-solid cursor-pointer dark-mode:border-white"
      >You reached the bottom!</button>
    </div>
  </main>
</template>

<script>

export default {
  data() {
    return {
      currentpage: 1,
      nonewposts: false
    }
  },
  methods: {
    async loadMorePosts() {
      try {
        // Query Posts
        const posts = await this.$prismic.api.query(
          this.$prismic.predicates.at('document.type', 'post'),
          {
            orderings: '[document.first_publication_date desc]',
            pageSize: 9,
            page: this.currentpage + 1
          }
        )

        if (posts.results.length > 0) {
          // Merge with the other posts
          this.posts = this.posts.concat(posts.results)
        } else {
          // No more new posts
          this.nonewposts = true
        }

        // Save current page
        this.currentpage++
      } catch (e) {
        console.error(e)
      }
    }
  }
}
</script>

Wird der Button betätigt, löst er die loadMorePosts() Funktion aus. Die Prismic.io Query sieht dabei fast gleich zu der Start-Query aus, dazu kommt aber der “Page”-Parameter. Wir lassen uns also die nächste Seite unseres Suchergebnisses geben. Ist das Ergebnis nicht 0, wird das Ergebnis zu den anderen Posts hinzugefügt und entsprechend dargestellt. Anschließend wird der Zähler für die aktuelle Seite auf +1 gestellt.

Sollte das Ergebnis 0 sein, wird der Datenpunkt “nonewposts” auf true gesetzt. Ist dieser auf “true” oder ist das Ergebnis beim initialen Laden der Seite auf unter 9, wird der “Load more” Button ausgeblendet.

Fertig, die Startseite steht und auch die Artikelansicht für die Artikel ist abrufbar. Im nächsten Teil geht es weiter mit einem Tag-System für den Blog.