アルパカログ

開発しているNotion Blogやプログラミングの話題が中心のブログ

❤️astro-notion-blogにいいねボタンを追加する

astro-notion-blogはサーバーサイドレンダリング(SSR)を使用していないため、ホスティングサービスを選ばず様々なクラウドやレンタルサーバーで運用することができます。

一方、astro-notion-blogは一般的なブログでよくある「いいねボタン」や「コメント」にはデフォルトでは対応しておらず、それらの機能が欲しいと思う人もいらっしゃるでしょう。

そこでこの記事では、astro-notion-blogにいいねボタンを実装する方法を説明します。

システムと仕様の検討

今回はいいね数、いいねボタンを記事個別ページに設置するとして話を進めていきます。

まずはいいねボタンから考えていきましょう。

いいねボタンをどのように実装するか?

いいねボタンは、ボタンが押されるとNotionデータベースのいいね数を 現在のいいね数+1 の値に更新し、ページに表示しているいいね数を最新の値で更新するという仕様です。

いいね数を更新するにはNotion APIをコールする必要があります。しかし、デフォルトのastro-notion-blogにはこのような処理を実行するサーバーがありません。

しかし幸いなことに、astro-notion-blogがベースとしているAstroはオプション1つでSSRを有効にでき、このようなサーバー処理が可能になります。

また、ホスティングサービス側もAstroのSSRに対応しているものがいくつかあります。詳細は下記の公式ドキュメントをご覧ください。

この記事ではVercelの例を説明します。

⚠️
Cloudflare PagesもAstroのSSRに対応していますが、Node.jsランタイムでないためSSRを有効にしたastro-notion-blogをCloudflare Pagesで動かすことはできません

SSRを用いてNotion APIをリクエストするためのAPIエンドポイントの実装を考えます。

APIエンドポイントは POST /api/likes.json とし、どの記事に対する「いいね」かがわかるように記事のSlugを付けて POST /api/likes.json?slug=aaa のようにリクエストすることにしましょう。

レスポンスでは更新後の最新の「いいね数」を返すことにします。そうすることで「いいねボタン」を押した後に「いいね数」の更新がスムーズに行えます。

レスポンスの形式はJSONで下記のようにします。

{"likes": 1}

いいねボタンが押されたとき、クライアントサイドのJavaScriptからこのAPIをリクエストします。

レスポンスを受け取ったらステータスを調べ、ステータス200(成功)だったらレスポンスボディから最新のいいね数を取得し、ページ内のいいね数を書き換えます。

これで「いいねボタン」は実装できそうです。次に「いいね数」の表示を見ていきましょう。

いいね数の表示をどうするか?

いいねボタンの実装のためにSSRを有効にすることにしましたが、記事個別ページを動的生成にすべきかどうかは検討の余地があります。

すなわち、下記の2パターンを比較、検討する必要があるでしょう。

  1. 記事個別ページ自体を動的生成にして最新のいいね数を表示する
  2. 記事個別ページは静的生成のままにして、最新のいいね数の表示はAPIを利用する

ページを動的生成するということは、ページ表示時の速度を犠牲にするということです。

一方、後者はページを静的生成のままに保ちます。APIレスポンスが返ってくるまでは「いいね数」が表示されないことになりますが、ブログというアプリケーションの特性上そこまで気にはならないでしょう。

そういうわけで今回は2の方法で実装していきます。「いいねボタン」のAPIがほぼ流用できるので実装の手間も少なくて済みます。

「いいね数」を取得するAPIエンドポイントは GET /api/likes.json とし、「いいねボタン」と同様、どの記事かわかるように GET /api/likes.json?slug=aaa のようにSlugを合わせて受け取ることにします。

レスポンスは「いいねボタン」と同様に、現在のいいね数をJSONで返します。

これで、システムと仕様が決まったので実装に入りましょう。

実装

Likeの追加

最初にNotionデータベースにいいね数を表す Like プロパティを追加します。プロパティの種類は数値(Number)です。

追加した Like プロパティをastro-notion-blog全体で扱えるように src/lib/interfaces.ts に差分を追加します。

  export interface Post {
    PageId: string
    Title: string
    Icon: FileObject | Emoji | null
    Cover: FileObject | null
    Slug: string
    Date: string
    Tags: SelectProperty[]
    Excerpt: string
    FeaturedImage: FileObject | null
    Rank: number
+   Like: number
  }

Like を更新する際のリクエストパラメータの型も定義しておきます。

src/lib/notion/request-params.ts に下記の差分を追加します。

+ export interface UpdatePage {
+   page_id: string
+   properties: PageProperties
+ }
+
+ interface PageProperties {
+   [key: string]: PageProperty
+ }
+
+ interface PageProperty {
+   number?: number
+ }

src/lib/notion/responses.ts にレスポンスも定義しておきます。

+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
+ export interface UpdatePageResponse extends PageObject {}

src/lib/notion/client.tsLikePost に設定するために下記の差分を追加します。

  function _buildPost(pageObject: responses.PageObject): Post {
    ...
      FeaturedImage: featuredImage,
      Rank: prop.Rank.number ? prop.Rank.number : 0,
+     Like: prop.Like.number ? prop.Like.number : 0,
    }

同じく src/lib/notion/client.tsLike を更新するためのメソッドを実装します。

+ export async function incrementLikes(post:Post): Promise<Post|null> {
+   let result: responses.PageObject
+
+   const params: requestParams.UpdatePage = {
+     page_id: post.PageId,
+     properties: {
+       Like: {
+         number: (post.Like || 0) + 1,
+       },
+     },
+   }
+
+   result = (await client.pages.update(
+     params as any // eslint-disable-line @typescript-eslint/no-explicit-any
+   )) as responses.UpdatePageResponse
+
+   if (!result) {
+     return null
+   }
+
+   return _buildPost(result)
+ }

次にSSRを有効にします。

SSRの有効化

SSRを有効にするにはクラウドサービスに応じたアダプターをインストールし astro.config.mjs に設定を追加する必要があります。

SSRに対応するためのコマンドが用意されています。下記コマンドを実行して差分をコミットします。

yarn astro add vercel

もしくは、次のように手動で対応することもできます。

@astrojs/vercel をインストールし package.jsonyarn.lock をコミットします。

yarn add @astrojs/vercel

astro.config.mjs に下記の差分を追加します。

+ import vercel from '@astrojs/vercel/serverless';


  export default defineConfig({
+   output: 'server',
+   adapter: vercel(),
    ...
  });

⚠️
SSRを有効にするとアプリケーション全体がSSRになってしまうため、既存のページで静的生成を維持するためにはページ全部に設定を追加しなければなりません。

SSRはアプリケーション全体に渡って有効になってしまうため、既存のページまで動的生成になってしまいます。

APIエンドポイント以外は静的生成を維持したいので、既存のページ全てに export const prerender = true を追加します。

例えば記事個別ページは下記のように追加します。

  ---
  ...
  import styles from '../../styles/blog.module.css'

+ export const prerender = true
+
  export async function getStaticPaths() {

次にAPIエンドポイントを実装します。

APIエンドポイントの実装

src/pages/api/likes.json.ts を次のような内容で作成します。

import type { APIRoute, APIContext } from 'astro'
import { getPostBySlug, incrementLikes } from '../../lib/notion/client'

export const get: APIRoute = async ({ request }: APIContext) => {
  const url = new URL(request.url)
  const params = new URLSearchParams(url.search)
  const slug = params.get('slug')

  if (!slug) {
    return new Response(null, { status: 400 })
  }

  const post = await getPostBySlug(slug)

  if (!post) {
    return new Response(null, { status: 404 })
  }

  return new Response(JSON.stringify({ likes: post.Like }), {
    status: 200,
    headers: {
      'Content-Type': 'application/json',
    },
  })
}

export const post: APIRoute = async ({ request }: APIContext) => {
  const url = new URL(request.url)
  const params = new URLSearchParams(url.search)
  const slug = params.get('slug')

  if (!slug) {
    return new Response(null, { status: 400 })
  }

  const post = await getPostBySlug(slug)

  if (!post) {
    return new Response(null, { status: 404 })
  }

  const updatedPost = await incrementLikes(post)

  if (!updatedPost) {
    return new Response(null, { status: 404 })
  }

  return new Response(JSON.stringify({ likes: updatedPost.Like }), {
    status: 200,
    headers: {
      'Content-Type': 'application/json',
    },
  })
}

これでいいね数といいねボタンの準備が整いました。最後に記事個別ページにいいねボタンを実装します。

いいねボタンの実装

いいねボタンは ❤️ 5 のようにいいね数と一緒に表示することにし LikeButton コンポーネントとして実装します。

src/components/LikeButton.astro を下記のように実装します。スタイルはお好みで調整してください。

---
export interface Props {
  post: Post
}

const { post } = Astro.props
---

<button class="like-button" data-slug={post.Slug}>❤️ 0</button>

<style>
  .like-button {
    margin-top: 1rem;
  }
</style>

<script is:inline>
  document.addEventListener('DOMContentLoaded', async () => {
    const button = document.querySelector('.like-button')
    const slug = button.dataset.slug
    const url = `/api/likes.json?slug=${slug}`

    const res = await fetch(url)
    if (res.status !== 200) {
      throw new Error('Failed to like get')
    }

    const { likes } = await res.json()
    button.textContent = `❤️ ${likes}`

    button.addEventListener('click', async () => {
      const res = await fetch(url, { method: 'POST' })
      if (res.status !== 200) {
        throw new Error('Failed to like post')
      }

      const { likes } = await res.json()
      button.textContent = `❤️ ${likes}`
    })
  })
</script>

記事個別ページ src/pages/posts/[slug].astroLikeButton コンポーネントを追加します。

  import BlogTagsLink from '../../components/BlogTagsLink.astro'
+ import LikeButton from '../../components/LikeButton.astro'
  import styles from '../../styles/blog.module.css'
        <PostTags post={post} />
+       <LikeButton post={post} />

        <footer>

push する前に yarn dev で動作確認しておきましょう。

記事のフッターに「いいねボタン」が表示されています。

image block
記事のフッターにいいねボタンが表示されている

最終的な全体差分は下記をご覧ください。

以上です。この記事では、astro-notion-blogにいいねボタンを実装する方法を説明しました。