✒️

簡易ブログを作ってみた!パート2(with Next.js & contentful)

2021/04/28に公開

はじめに

今回の記事は、前回の続きとなります。前回記事も是非、ご覧ください!!
https://zenn.dev/tsuboi/articles/99458811a003c7


本記事の目標

  • Tailwind CSS 導入
  • 記事の種類によって、表示を変える
  • ダークモード追加
  • vercelにデプロイ

この4点の達成を目指します。

完成品
https://next-blog-lime.vercel.app/


Tailwind CSS 導入

スタイルに Tailwind CSS を導入します。Next.js をベースとする Tailwind CSSの導入方法は公式で述べられています。
https://tailwindcss.com/docs/guides/nextjs

また Tailwind CSS はカスタマイズも簡単です。

tailwind.config.js
​module.exports = {
  purge: ['./pages/**/*.{tsx}', './components/**/*.{tsx}'],
  darkMode: false, // or 'media' or 'class'
  theme: {
+   colors: {
+     black: '#000',
+     white: '#fff',
+     blue: '#1985A1',
+     purple: '#8257e6',
+     orange: '#fa923f',
+     subtext: '#617482',
+     darkblue: '#06202A'
+   },
+   animation: {
+     none: 'none',
+     spin: 'spin 1s linear infinite',
+     ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite',
+     pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
+     bounce: 'bounce 1s infinite'
+   }
  },
  variants: {
    extend: {}
  },
  plugins: []
}

また vscode の拡張機能で Color Highlight をインストールしておけば、色が視覚化できます。

一例ですが、簡潔にスタイリングできます。

import React from 'react'

const index = () => {
  return (
    <div className="flex w-full mt-5 justify-evenly">
      <p className="w-5 h-5 rounded-full animate-bounce bg-orange" />
      <p className="w-5 h-5 rounded-t-sm animate-spin bg-blue" />
      <p className="w-5 h-5 rounded-full animate-ping bg-purple" />
      <p className="w-5 h-5 rounded-full animate-pulse bg-subtext" />
      <p className="w-5 h-5 rounded-full animate-none bg-darkblue" />
    </div>
  )
}

export default index


コードを書く

先程掲げた残り3つの目標を成すために、下準備を行います。

まずは Tailwind CSS によるスタイリングです。

_document.tsx
import React from 'react'
import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head>
	  <title>Next Blog</title>
          <link rel="preconnect" href="https://fonts.gstatic.com" />
          <link
            href="https://fonts.googleapis.com/css2?family=Kaushan+Script&display=swap"
            rel="stylesheet"
          />
        </Head>
        <body className="bg-fixed bg-gradient-to-r from-orange to-purple">
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument
index.tsx
import React, { useState } from 'react'
import Head from 'next/head'
import { createClient, EntryCollection } from 'contentful'
import { InferGetStaticPropsType, NextPage } from 'next'
import BlogCard from '../components/BlogCard'
import Sidebar from '../components/BlogNavbar'
import { Category, IFields } from '../../types'

export const getStaticProps = async () => {
  const client = createClient({
    space: process.env.CONTENTFUL_SPACE_ID,
    accessToken: process.env.CONTENTFUL_ACCESS_KEY
  })

  const response: EntryCollection<IFields> = await client.getEntries({
    content_type: 'article'
  })

  return {
    props: {
      article: response.items
    }
  }
}

type Props = InferGetStaticPropsType<typeof getStaticProps>

const Home: NextPage<Props> = ({ article }) => {
  return (
    <div className="px-5 py-2 overflow-scroll">
      <div className="grid grid-cols-12 gap-6 px-5 my-14 lg:mb-0 md:mb-16 sm:px-20 md:px-32 lg:px-36 xl:px-48 ">
        <div className="h-full col-span-12 p-4 text-base text-center bg-white  lg:col-span-3 rounded-2xl shadow-custom-light">
          <Sidebar/> 
        </div>
        <div className="flex flex-col col-span-12 overflow-hidden bg-white shadow-custom-light rounded-2xl lg:col-span-9">
          <div className="relative grid grid-cols-12 gap-4 my-3">
            {article.map(item => (
              <div
                key={item.sys.id}
                className="col-span-12 p-2 bg-gray-200 rounded-lg sm:col-span-6 lg:col-span-4"
              >
                <BlogCard item={item} />
              </div>
            ))} 
          </div>
        </div>
      </div>
    </div>
  )
}

export default Home

SSG によって Next.jscontentful との接続を行います。こちらは前回記事を御覧ください。ただ前回記事は、props の型推論がうまくいっておらず、 any 型となっていたため修正を加えています。(前回記事も修正済みです。)

見た目は以下のとおりです。

以降、左側を Sidebar コンポーネント、右側を BlogCard コンポーネントとします。


BlogCard コンポーネント

component/BlogCard.tsx
import React from 'react'
import { IFields } from '../../types'
import Image from 'next/image'
import { NextPage } from 'next'
import { Entry } from 'contentful'

const BlogCard: NextPage<{ item: Entry<IFields> }> = ({ item }) => {
  return (
    <>
      <Image
        src={'https:' + item.fields.thumbnail.fields.file.url}
        alt={item.fields.title}
        className="object-contain cursor-pointer"
        layout="responsive"
        height="300"
        width="400"
      />
      <p className="my-2 text-center">{item.fields.title}</p>
    </>
  )
}

export default BlogCard

画像には Next.js の Image タグを使います。今回のように外部(contentful)ファイルを読み込む場合、next.config.js ファイルを作成し以下のように追記する必要があります。

next.config.js
​module.exports = {
  images: {
    domains: ['images.ctfassets.net'] // ←外部ファイルによって記述が変わります
  }
}

また各記事の画像をクリックすると、画像が拡大し、さらに detail(Linkタグ) をクリックすると各記事のリンクに飛ぶ機能を追加します。

component/BlogCard.tsx
​import React, { useState } from 'react'
import { IFields } from '../../types'
+import { XIcon } from '@heroicons/react/solid'
import Image from 'next/image'
import Link from 'next/link'
import { NextPage } from 'next'
import { Entry } from 'contentful'

const BlogCard: NextPage<{ item: Entry<IFields> }> = ({ item }) => {
+ const [showDetail, setShowDetail] = useState(false)

  return (
    <>
      <Image
        src={'https:' + item.fields.thumbnail.fields.file.url}
        alt={item.fields.title}
        className="object-contain cursor-pointer"
        onClick={() => setShowDetail(true)}
        layout="responsive"
        height="300"
        width="400"
      />
      <p className="my-2 text-center">{item.fields.title}</p>

+     {showDetail && (
+       <div className="absolute top-0 left-0 z-10 grid w-full h-full p-2 text-black bg-white md:grid-cols-2 gap-x-12">
+         <Image
+           className="object-contain"
+           src={'https:' + item.fields.thumbnail.fields.file.url}
+           alt={item.fields.title}
+           layout="responsive"
+           height="150"
+           width="300"
+           />
+           <div className="flex justify-center my-4 space-x-3">
+             <Link href={'/article/' + item.fields.slug}>
+               <a className="flex items-center px-4 space-x-3 text-2xl +font-bold bg-gray-200 hover:text-orange">
+                 detail
+               </a>
+             </Link>
+             <div className="flex items-center text-sm">
+               {item.fields.type.map(tech => (
+                 <span
+                   key={tech}
+                   className="px-2 py-1 my-1 text-subtext"
+                 >
+                   [{tech}]
+                 </span>
+               ))}
+             </div>
+           </div>

+           <button
+             onClick={() => setShowDetail(false)}
+             className="absolute p-1 bg-gray-200 rounded-full top-3 right-3 focus:outline-none"
+           >
+             <XIcon className="h-8" />
+           </button>
+         </div>
+       )}
    </>
  )
}

export default BlogCard


アイコンには heroicons を使っています。
https://github.com/tailwindlabs/heroicons

因みに、 item.fields.type(画像では[dog],[husky])というのは、contentful で作成した Content model の一つです。次のチャプターでも用います。


記事の種類によって、表示する記事を変える。

下準備は以上で、目標の2つ目に入ります。

まず、Sidebar コンポーネントと BlogCard コンポーネントとの連携を図るために index コンポーネントに変更を加えていきます。

index.tsx(SSGは除く)
​const Home: NextPage<Props> = ({ article }) => {
+ const [articles, setArticles] = useState(article)
+ const [active, setActive] = useState('dog')

+  const handlerFilterCategory = (category: Category) => {
+    if (category === 'dog') {
+      setArticles(article)
+      setActive(category)
+      return
+    }
+    const newArray = article.filter(item => item.fields.type.includes(category))

+    setArticles(newArray)
+    setActive(category)
+  }

  return (
    <div className="px-5 py-2 overflow-scroll">
      <div className="grid grid-cols-12 gap-6 px-5 my-14 lg:mb-0 md:mb-16 sm:px-20 md:px-32 lg:px-36 xl:px-48 ">
        {/* // do this div style later (after putting the content) */}
        <div className="h-full col-span-12 p-4 text-base text-center bg-white lg:col-span-3 rounded-2xl shadow-custom-light">
          <Sidebar
+           handlerFilterCategory={handlerFilterCategory}
+           active={active}
          />
        </div>
        <div className="flex flex-col col-span-12 overflow-hidden bg-white shadow-custom-light rounded-2xl lg:col-span-9">
          <div className="relative grid grid-cols-12 gap-4 my-3">
-	     {article.map(item => (
+            {articles.map(item => (
              <div
                key={item.sys.id}
                className="col-span-12 p-2 bg-gray-200 rounded-lg sm:col-span-6 lg:col-span-4 "
              >
                <BlogCard item={item} />
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  )
}

export default Home

ポイント

まず useState 2つ用意します。

1つ目の useState は contentful から取得したデータ(article)を引数に取り、条件によって article の内容を更新します。それを担うのが handlerFilterCategory 関数で、引数(category)によって article の全てを必要とするのか、一部を必要とするのかを分けます。
一部を必要とする場合、filter メソッドによって配列処理を行います。contentful で作った各記事の type に handlerFilterCategory 関数の引数(category)が含まれている場合 article の内容を更新しています。それを newArray と定め、 setState(setArticles) によって状態管理を行います。
後は、最新の state の値(articles)を BlogCard コンポーネントに props 経由で渡すといった具合です。

2つ目の useState はTailwind CSSによるスタイリングのために用意しました。次の Sidebar コンポーネントのコードで紹介致します。

components/BlogSidebar.tsx
import React from 'react'
import { Category } from '../../types'

export const NavItem: React.FC<{
  value: Category
  handlerFilterCategory: (category: Category) => void
  active: string
}> = ({ value, handlerFilterCategory, active }) => {
  let className = 'capitalize cursor-pointer hover:text-orange'
  if (active === value) className += ' text-orange'

  return (
    <li className={className} onClick={() => handlerFilterCategory(value)}>
      {value}
    </li>
  )
}

const BlogNavbar: React.FC<{
  handlerFilterCategory: (category: Category) => void
  active: string
}> = props => {
  return (
    <div className="min-h-full ">
      <h3 className="font-mono text-3xl font-medium tracking-wider text-purple">
        Blog
      </h3>
      <div className="flex flex-col mt-10 space-x-3 space-y-6 overflow-x-auto list-none">
        <NavItem value="dog" {...props} />
        <NavItem value="Border Collie" {...props} />
        <NavItem value="bulldog" {...props} />
        <NavItem value="husky" {...props} />
      </div>
    </div>
  )
}

export default BlogNavbar

ポイント

BlogNavbar コンポーネントの引数には、先程 index コンポーネントで props として渡した handlerFilterCategory と active を持ちます。さらに各 NavItem コンポーネントにスプレッド構文で渡します。
NavItem コンポーネントでは、バケツリレーで handlerFilterCategory と active と value を受け取ります。 handlerFilterCategory の引数には value が渡り、 active と value との文字列が一致した場合、テキストに色付けます。

以上の変更で、 記事の種類によって、表示する記事を変える を可能にしました。


ダークモード追加

ダークモードの導入手順は Tailwind CSS 公式で詳しく述べられています。
https://tailwindcss.com/docs/dark-mode
公式を参考に、
Tailwind CSSでタークモードを有効化するためには、 tailwind.config.js ファイルを修正します。

tailwind.config.js
​module.exports = {
  purge: ['./pages/**/*.{tsx}', './components/**/*.{tsx}'],
- darkMode: 'false',
+ darkMode: 'class', 

......

}

また一つライブラリをインストールする必要があります。
https://github.com/pacocoursey/next-themes

​$ npm install next-themes
# or
$ yarn add next-themes

ライブラリをインストールしたら、_app.js に ThemeProvider を import します。

_app.tsx
​import React from 'react'
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import Head from 'next/head'

+import { ThemeProvider } from 'next-themes'

function MyApp({ Component, pageProps }: AppProps): React.ReactNode {
  return (
    <>
      <Head>
        <title>Next Blog</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
+     <ThemeProvider attribute="class">
        <Component {...pageProps} />
+     </ThemeProvider>
    </>
  )
}

export default MyApp

あとはダークモードを適用したい場所に dark: に続きスタイルを付与するだけです。

_document.tsx
•import React from 'react'
import React from 'react'
import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head>
          <link rel="preconnect" href="https://fonts.gstatic.com" />
          <link
            href="https://fonts.googleapis.com/css2?family=Kaushan+Script&display=swap"
            rel="stylesheet"
          />
        </Head>
+       <body className="bg-fixed bg-gradient-to-r from-orange to-purple dark:from-subtext dark:to-darkblue dark:text-white">
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

またボタンをクリックすると、ダークモード、ライトモードと入れ替わる機能を Sidebar コンポーネントに追加します。

components/BlogNavbar.tsx
​import React from 'react'
+import { useTheme } from 'next-themes'
import { Category } from '../../types'
+import { SunIcon } from '@heroicons/react/outline'
+import { MoonIcon } from '@heroicons/react/solid'

export const NavItem: React.FC<{
  value: Category
  handlerFilterCategory: (category: Category) => void
  active: string
}> = ({ value, handlerFilterCategory, active }) => {
  let className = 'capitalize cursor-pointer hover:text-orange'
  if (active === value) className += ' text-orange'

  return (
    <li className={className} onClick={() => handlerFilterCategory(value)}>
      {value}
    </li>
  )
}

const BlogNavbar: React.FC<{
  handlerFilterCategory: (category: Category) => void
  active: string
}> = props => {
+ const { theme, setTheme } = useTheme()
+ const changeTheme = () => {
+   setTheme(theme === 'light' ? 'dark' : 'light')
+ }

  return (
    <div className="flex flex-col justify-between min-h-full">
      <div className="flex justify-between">
        <h3 className="text-3xl font-medium tracking-wider text-purple font-kaushan dark:text-subtext">
          Blog
        </h3>
+       {theme === 'light' ? (
+         <SunIcon className="h-8" />
+       ) : (
+         <MoonIcon className="h-8" />
+       )}
      </div>
      <div className="flex flex-col py-6 space-x-3 space-y-3 overflow-x-auto list-none">
        <NavItem value="dog" {...props} />
        <NavItem value="Border Collie" {...props} />
        <NavItem value="bulldog" {...props} />
        <NavItem value="husky" {...props} />
      </div>
+     <button
+       onClick={changeTheme}
+       className="py-2 my-4 text-white bg-black rounded-full cursor-pointer bg-gradient-to-r from-orange to-purple dark:from-subtext dark:to-darkblue focus:outline-none hover:scale-105"
+     >
+       {theme === 'light' ? 'Light Mode' : 'Dark Mode'}
+     </button>
    </div>
  )
}

export default BlogNavbar

以上の変更で、ダークモードを導入できました。


vercel にデプロイ

vercel にデプロイするには、最低限必要なことがあります。

vercel にログインするには以下をクリック
https://vercel.com/

ログイン後、デプロイするまでの手順

  1. vercel にログイン
  2. Nex Project クリック
  3. Import Git Repository(デプロイしたいレポジトリをセレクト)
  4. Select Vercel Scope(github アカウントをセレクト)

最後に、Import Project
今回のように、環境変数が必要な場合、env ファイルよりコピペしてきます。

後は deploy をクリックすると、 Congratulations! と称賛してくれます。
(スクショを撮るの忘れました...)
これにより、 vercel にデプロイできました。

以上になります。
ここまで読んでいただきありがとうございました!!

Discussion