💬

Next.jsとTailwindを利用したエンジニア名刺アプリ

20 min read

この記事はSLP KBIT Advent Calendar 2020の21日目の記事です.

はじめに

こんにちは!今年ももうあと数日で終わるもので、早いですね。
今回は実装する内容は、10月にオンラインで開催されたnextjs conf 2020で実装されていた、チケット発行するやつを元ネタにしています。GitHub認証を行うことで、自分オリジナルのチケット画像を生成します。
今回実装するアプリでは、github認証とgoogle認証を用いてプロフィールデータを取得、表示していきます。

今回実装する内容

思った以上に長くなりそうなので、先に実装したものを紹介したいと思います。
主に、ヘッダー、ボタンレイアウト、名刺レイアウト、フッターみたいな構成で分けています。
名刺に表示しているプロフィールデータは、認証したものから取得しています。
また、名刺の外枠と内枠で、ランダムに色を変更できるようにしています。
自分好みの色をカスタマイズしてみてください。

Next.jsとTailwindについて

まずは、Next.jsとTailwindについて、簡単に紹介したいと思います。

Next.jsとは

Next.jsは、React製フレームワークであり、以下のような特徴を持っています。

  • Server side Rendering(SSR) / Static Site Generation(SSG)
  • Incremental Static Regeneration
  • 画像最適化の提供 (next/image)
  • zero config
  • /page下のディレクトリは全てルート対象となる

詳しくは公式まで nextjs org

Tailwindとは

Tailwindとは、UtilifyーFirstを掲げたCSSフレームワークです。
Bootstrapのように出来上がったコンポーネントを組み合わせて構築していく、というよりは自身でcssを組み合わせて構築していくイメージだと思います。
例えば、ボタンにcssを加える場合は、btnみたいなclassを設定せず、inline styleで記述していきます。

<button class="bg-indigo-700 text-white py-2 px-6">
  ボタン
</button>

はい。ここで気づく人はいるかもしれませんが、詳細にcssを設定した場合、すごくコードの見た目が汚くなるやん!と思いました。
実際、公式docにも取り上げられており、とりあえず使ってみろ。絶対いいから。とのこと。
docにこんなことが書かれているのは、なかなかユーモアがあって面白いなと思いました。

いざ,実装

では、実装をしていきます。
今回使用する主なライブラリのversionは以下です。

"react": "17.0.1",
"next": "10.0.1",
"next-auth": "^3.1.0",
"tailwindcss": "^2.0.1"

ディレクトリ構成

ディレクトリ
.
├── jest.config.js
├── next-env.d.ts
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── setupTest.js
├── src
│   ├── __tests__
│   │   ├── __snapshots__
│   │   ├── grass.test.tsx
│   │   ├── icon.test.tsx
│   │   └── signin.test.tsx
│   ├── pages
│   │   ├── _app.tsx
│   │   ├── _document.tsx
│   │   ├── api
│   │   │   └── auth
│   │   │       └── [...nextauth].ts
│   │   └── index.tsx
│   ├── ui
│   │   ├── ColorButtonLayout.tsx
│   │   ├── FooterLayout.tsx
│   │   ├── SaveButton.tsx
│   │   ├── SignIn
│   │   │   ├── SignInLayout.tsx
│   │   │   └── signIn.tsx
│   │   ├── Title.tsx
│   │   ├── card
│   │   │   ├── cardlayout.tsx
│   │   │   ├── emptyCardProfile.tsx
│   │   │   ├── grass.tsx
│   │   │   └── profile
│   │   │       ├── Icon.tsx
│   │   │       └── Profile.tsx
│   │   └── navBar.tsx
│   └── utils
│       ├── getProfile.ts
│       ├── useChangeColor.ts
│       └── useGetSession.ts
├── tailwind.config.js
├── tree.txt
└── tsconfig.json

環境構築

プロジェクト作成&起動

まずは、nextjsプロジェクトを作成します。
nextjsはzero-configを目指しており、以下のコマンドでプロジェクトを構築することができます。
構築後、試しにサーバを起動して動作を確認しておきましょう。

npx create-next-app cardcreator
# or 
yarn create next-app cardcreator

cd cardcreator
npm run dev

使用するライブラリのインストール

今回使用するライブラリをインストールしていきます
ライブラリとしては、tailwind関連、oauth認証用の3rdライブラリ、html2canvas、ts関連です

npm install autoprefixer postcss tailwindcss next-auth html2canvas
npm install -D @types/node @types/react @types/react-dom serve  typescript

設定ファイルの編集

まずは、tailwindを使用する際の設定ファイルを編集していきます。
基本的には、公式ガイド通りですが、widthの値を一部、カスタマイズして設定しています。
一般的には、purgeオプションは不必要なスタイルを除去し、ビルドサイズを小さくしてくれるので設定した方が好ましいです。
ただし、現在のtailwind:2.0.1では、バグか設定が足りていないのか、まだ不明ですが、purgeを設定することでcssのビルドがこけ、正常に反映されません。
CSS file fails to get built when purge.enabled is set to true #3080
そのため、今回はpurgeを設定せずにいきたいと思います。(進展があり次第、修正していきます)

tailwind.config.js
module.exports = {
  purge: [],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {
      width: {
        'crd': '40rem',  //w-crd を40remに設定
      }
    },
  },
  variants: {
    extend: {},
  },
  plugins: [],
}
postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

次に、環境変数とnext/imageを使用するための設定を記述します

追記 2020-12-23
現状、設定ファイルにドメインを記述する際、ワイルドカード等を記述できないため、列挙という形で対応しました。
下記にも記述していますが、外部から取得した画像はoriginが異なり、canvas上に保存することができないため、next/imageは継続して使うことにしました。
なお、ワイルドカードの利用に関しては、サブドメインなら実装を検討してもいいかもね的なことも書かれているので、もし実装されたら対応していきたいと思います。

https://github.com/vercel/next.js/discussions/18429
next.config.js
module.exports = {
  env: {
    GITHUB_CLIENT: process.env.GITHUB_CLIENT,
    GITHUB_SECRET: process.env.GITHUB_SECRET,
    GOOGLE_CLIENT: process.env.GOOGLE_CLIENT,
    GOOGLE_SECRET: process.env.GOOGLE_SECRET,
  },
  images: {
    domains: [
      'avatars.githubusercontent.com', 
      'avatars0.githubusercontent.com', 
      'avatars1.githubusercontent.com', 
      'avatars2.githubusercontent.com', 
      'avatars3.githubusercontent.com', 
      'avatars4.githubusercontent.com', 
      'grass-graph.moshimo.works',
      'lh3.googleusercontent.com',
      'lh4.googleusercontent.com',
      'lh5.googleusercontent.com',
      'lh6.googleusercontent.com',
      'github.com'
    ],
  }
};

tsの設定ファイルを記述していきます

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": false,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve"
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

最後に、oauth認証に使う情報をenvに記載していきます。

.env
GITHUB_CLIENT=
GITHUB_SECRET=
GOOGLE_CLIENT
GOOGLE_SECRET=

実装!!

今回実装していく上で、レイアウト単位に分割したものが以下の図になります。
なお、長くなりそうなので、本アプリのメイン部分となるCardのレイアウトとボタンのロジック部分のみを紹介していきます。全体のコード等はgithub等でお願いいたします。cardcreator

ページのレイアウト構成

認証&データ取得

データ取得のために、oauth認証を実装していきます。
nextjsでは、NextAuthと呼ばれる、ossの認証ライブラリが存在しており、oauth認証に対応しているproviderも豊富に用意されていて簡単に利用することができます。
以下のように、対象にするproviderの設定や取得する情報などを設定していきます。

src/pages/api/auth/[...nextauth].ts
import NextAuth from 'next-auth';
import Providers from 'next-auth/providers';

const options = {
  providers: [
    Providers.GitHub({
      clientId: process.env.GITHUB_CLIENT,
      clientSecret: process.env.GITHUB_SECRET
    }),
    Providers.Google({
      clientId: process.env.GOOGLE_CLIENT,
      clientSecret: process.env.GOOGLE_SECRET
    }),
  ], 
  session: {
    jwt: true,
  },
  callbacks: { 
    session: async (session, user) => { 
      return Promise.resolve(user) 
    },
    jwt: async (token, user, account, profile) => {
      if(account) {token.provider = account.provider}
      if(profile) {token.profile = profile}
      return Promise.resolve(token) 
    }, 
  },
}
export default (req, res) => NextAuth(req, res, options);

認証後の情報は、next-auth/clientのgetSession/useSession等で取得することができます。
この辺りの処理は後ほど記述します。

名刺部分のレイアウト作成

まずは名刺に表示する情報のレイアウトを考えていきます。
基本的に名刺には、自身を表現する画像(アイコン)、名前やsnsのアカウント、メールアドレス、所属組織などを記載することが多いと思います。
今回は、githubとgoogle認証から取得できる情報を元に、名刺を作成していきます。
googleから取得できる情報としてはプロフィールアイコンと名前、メールアドレス等しか検討できなかったため、今回の説明ではgithub認証時のレイアウトを紹介していきます。

レイアウトは以下の図のようになりました。
まず、上段と下段に別れており、上段では当アプリのロゴとgithubの草を、下段ではプロフィール画像と名前、githubに公開しているプロフィールデータを取得しています。

名刺のレイアウト構成

まずは右下の、sns等のアイコンやデータを表示する部分を作成していきます。
表示するアイコン等は、アイコン]とテキストを受け取って、componentを使いまわせるように設計します。
なお、取得するデータが必ずしも存在するとは限らないため、早期returnを実施します。
画像を表示するimgのsrcは、next/imageが提供しているコンポーネントではなく、画像最適化のビルドインapiを直接利用します。理由は後ほど。

src/ui/card/profile/Icon.tsx
interface Props {
  name: string | null;
  icon: string;
}

export const Icon = ({name, icon}: Props) => {
  if(!name) return null;
  return (
    <div className="mr-0 ml-24 text-gray-100 flex">
      <div className="h-8 w-8 mt-1 mr-2">
        <img
          src={"/_next/image?url=%2F"+icon+".svg&w=64&q=75"} 
          alt={name} 
          width="25" height="25"
        />
      </div>
      <p>{name}</p>
    </div>
  );
};

再利用できるアイコンのコンポーネントが実装できたので、下段ののレイアウト全体を実装していきます。

src/ui/card/profile/Profile.tsx
import { Icon } from "./Icon";
import { ProfileType } from '../../../utils/getProfile';

export const Profile = ({
  name, picture, twitter, provider, email, blog
}: ProfileType): JSX.Element => {
  const src = "/_next/image?url="+encodeURIComponent(picture)+"&w=384&q=75"
  return (
    <div className="py-2 pl-12">
      <div className="flex flex-row">
        <div>
          <img src={src} alt={name} className="rounded-full" width="120" height="120"/>
        </div>
        <div className="flex flex-col">
          <p className="text-3xl ml-24 mb-3 -mt-4 font-bold text-white">{name}</p>
          <Icon name={name} icon={provider}/>
          <Icon name={twitter} icon="twitter" />
          <Icon name={email} icon="mail"/>
          <Icon name={blog} icon="blog" />
        </div>
      </div>
    </div>
  );
};

次に上段のレイアウトも作成し、名刺全体のレイアウトを完成させていきます。

src/ui/card/grass.tsx
interface Props {
  provider: string;
  name: string;
}

export const Grass = ({provider, name}: Props): JSX.Element => {
  if(provider !== 'github') return null;

  return (
    <div className="ml-28">
      <img
        src={"/_next/image?url=https%3A%2F%2Fgrass-graph.moshimo.works%2Fimages%2F"+name+".png%3Fbackground%3Dnone&w=1200&q=75"}
        width={380} height={70}
      />
    </div>
  )
}

useGetSessionは、認証サービスの情報を取得するカスタムフックです。
取得する情報はprofileDataに格納しますが、認証前など、空になる場合があります。
空の場合には、EmptyCardLayoutのように、別途用意します。
現状、EmptyCardLayoutはロゴのみを出力しているだけですが、他にも認証前の時に表示切り分けをしたい場合が出てきたように切り分けています。

src/ui/card/cardLayout.tsx
import { Profile } from './profile/Profile';
import { useGetSession } from '../../utils/useGetSession';
import { OuterColorType } from '../../utils/useChangeColor';
import { EmptyCardLayout } from './emptyCardProfile';
import { Grass } from './grass';
import { isNullOrUndefined } from 'util';

interface Props {
  innerColor: string;
  outerColor: OuterColorType;
}

export const CardLayout = ({
  innerColor, outerColor
}: Props ): JSX.Element => {
  const {profileData} = useGetSession();
  if(isNullOrUndefined(profileData)) return <EmptyCardLayout innerColor={innerColor} outerColor={outerColor}/>
  return (
   <div id="cardScreen" className={"w-crd h-80 my-10 mx-auto relative border-4 border-solid border-white p-1.5 rounded-3xl bg-gradient-to-r from-"+outerColor.from+" via-"+outerColor.via+" to-"+outerColor.to}>
      <div className={"w-full h-full rounded-3xl bg-" + innerColor}>
        <div className="flex">
          <img src={"/_next/image?url=%2Flogo.svg&w=384&q=75"} className="rounded-full" width='100' height='100' />
          <Grass provider={profileData.provider} name={profileData.name} />
        </div>
        <Profile 
          name={profileData.name}
          picture={profileData.picture}
          provider={profileData.provider} 
          twitter={profileData.twitter}
          email={profileData.email}
          blog={profileData.blog}
        />
      </div>
   </div>
  );
};

名刺の表示部分はあらかた完成したので、次はoauth認証の発火処理、oauthから情報を取得する処理、名刺を画像として保存する処理と名刺の外枠と内側の色をランダムに変更する処理を実装していきます。

oauth認証を発火する処理はSignInLayoutとSignIn内で実装していきます。
今回は認証providerとしてgithubとgoogleのみを採用していますが、今後の拡張性を持たせるために、providerの名前を引き渡すことで別のproviderに認証を通せるようにしておきます。
認証自体は、next-authが提供しているsignInメソッドを利用します。事前に[...nextauth]に定義したproviderであれば、メソッドの引数にproviderの文字列を渡すだけで、認証を実行することができます。

src/ui/SignIn/SignInLayout.tsx
import { SignIn } from './signIn';
export const SignInLayout = (): JSX.Element => {
  return (
    <div className="flex">
      <SignIn provider="github"/>
      <SignIn provider="google" />
    </div>
  )
}
src/ui/SignIn/signin.tsx
import { signIn } from 'next-auth/client'
import { MouseEvent } from 'react';

interface Props {
  provider: string;
}

export const SignIn = ({provider}: Props) => {
  const signInButton = (event: MouseEvent<HTMLElement>) => {
    signIn(event.currentTarget.dataset.name)
  }
  return (
    <div className="mx-3">
      <button data-name={provider} onClick={signInButton}>Sign In with {provider}</button>
    </div>
  )
}

oauthからデータを取得する処理は、カスタムフックでカスタマイズして実装を行いました。
signin後にホームにリダイレクトするため、ページのマウント時にセッションを確認をしてデータの取得を行います。
データをそのまま利用すると、情報過多であったり、扱いにくい部分があるので先に整形を行った後に格納を行います。

src/utils/useGetSession.ts
import { useEffect, useState } from "react";
import { getSession } from "next-auth/client";
import { isNullOrUndefined } from "util";

import { getProfile, ProfileType } from "./getProfile";

interface SessionAPIResponse {
  provider: string;
  email: string | null;
  exp: number;
  iat: number;
  name: string;
  picture: string;
  profile: {};
}

export const useGetSession = () => {
  const [profileData, setProfile] = useState<ProfileType | undefined>();
  useEffect(() => {
    const getFunction = async () => {
      await getSession().then<SessionAPIResponse>((session) => {
        if (!isNullOrUndefined(session)) {
          const profile = getProfile(session);
          setProfile(profile);
        }
      });
    };
    getFunction();
  }, []);
  return { profileData };
};

データの整形処理

src/utils/getProfile.ts
export interface ProfileType {
  provider: string;
  email: string | null;
  name: string;
  picture: string;
  twitter: string | null;
  blog: string | null;
}

export function getProfile(response): ProfileType {
  const twitter = response.provider === 'github' ? response.profile.twitter_username : null;
  const blog = response.provider === 'github' ? response.profile.blog : null;

  return {
    provider: response.provider,
    email: response.email,
    name: response.name,
    picture: response.picture,
    twitter: twitter,
    blog: blog
  }
}

次に、名刺部分を画像として保存する処理を実装していきます。
web上に表示されているものを画像として保存する際には、canvas上に置き換えた上でそれをimage/jpegなどに変換して保存します。
ここでは、html2canvasというライブラリを使用して、指定要素内をcanvas化します。

ここで、先ほどあげたnext/imageのビルドインapiを直接叩く理由に戻ります。
next/imageコンポーネント自体では、画像の最適化は行われておらず、URLの生成とレスポンシブ対応だけが行われています。srcset内に複数の画像URLを指定しておき、画面サイズごとに適したURLを自動的に生成、選択しています。
html2canvasはdomやcssを読み込み、それらを元にcanvasに描画しています。そのため、next/imageコンポーネントをそのまま利用した時には、情報が一意に決まっておらず、画像サイズがおかしくなっているのではないかと思いました。
また、外部urlを画像として表示した際、画像は別オリジンから取得したため、取得することができません。
これらの問題は、next/image内のapiを直接叩くことで解決しました。
なお、next/imageに関する説明は、以下の方々の方が詳細に説明していましたので、興味があればぜひ。

https://zenn.dev/saitoeku3/articles/read-next-image
https://zenn.dev/catnose99/articles/883f7dbbe21632a5254e
src/ui/SaveButton.tsx
import html2canvas from 'html2canvas';

export const SaveButton = () => {
  const getElement = () => {
    html2canvas(document.querySelector("#cardScreen"), {
      width: 640,
      height: 320
    })
    .then(canvas => {
      let a = document.createElement('a')
      a.href = canvas.toDataURL('image/jpeg', 1.0);
      a.download = 'mycard.jpg';
      a.click();
    })
  }
  return (
    <div>
      <button onClick={getElement}>save</button>
    </div>
  )
}

ここまで、必要な機能の実装は終了です。
github/google認証からデータの表示、画像として保存する機能ができたと思います。

ここで、お遊び機能として、名刺の縁と中の色を変更できるようにしたいと思います。
tailwindでは、決められた色に対して値を設定することで色を決定しています。red-500
そこで、色と値をそれぞれ配列に初期値として入れておき、乱数を用いてミックスさせることで異なる色を取得できるようにします。(ここの設計は、現在進行形で検討中です)
なお、blackとwhiteに関しては、値が存在していないため、色のみを返す処理を加える必要があります。

src/ui/ColorButtonLayout.tsx
interface Props {
  changeInnerColor: () => void;
  changeOuterColor: () => void;
}

export const ColorButtonLayout = ({
  changeInnerColor, changeOuterColor
}: Props ): JSX.Element => {
  return (
    <div>
      <button onClick={changeInnerColor}>change inner color</button>
      <button onClick={changeOuterColor}>change outer color</button>
    </div>
  )
}
src/utils/useChangeColor.ts
import { useState } from "react";

const COLOR = ["black","white","gray","red","yellow","green","blue","indigo","purple","pink"];
const GRADIENT = ["50","100","200","300","400","500","600","700","800","900"];

export interface OuterColorType {
  from: string;
  via: string;
  to: string;
}

export const useChangeColor = (): [
  () => void,
  () => void,
  {
    innerColor: string;
    outerColor: OuterColorType;
  }
] => {
  const [innerColor, setInnerColor] = useState("black");
  const [outerColor, setOuterColor] = useState({
    from: "purple-400",
    via: "pink-500",
    to: "red-500",
  });

  const randomNumber = (): number => Math.floor(Math.random() * 10);
  const getColorData = (data: Array<string>): string => data[randomNumber()];

  const joinColorData = (): string => {
    const gColor = getColorData(COLOR);
    if (gColor === "black" || gColor === "white") {
      return gColor;
    } else {
      return gColor + "-" + getColorData(GRADIENT);
    }
  };
  const changeOuterColor = () => {
    setOuterColor({
      from: joinColorData(),
      via: joinColorData(),
      to: joinColorData(),
    });
  };
  const changeInnerColor = () => {
    setInnerColor(joinColorData());
  };

  return [changeInnerColor, changeOuterColor, { innerColor, outerColor }];
};

最後に、今まで実装したものを組み合わせていきます。

src/pages/index.tsx
import * as React from 'react';
import { CardLayout } from '../ui/card/cardlayout';
import { FooterLayout } from '../ui/FooterLayout';
import { NavBar } from '../ui/navBar';
import { Title } from '../ui/Title';
import { SignInLayout } from '../ui/SignIn/SignInLayout';
import { ColorButtonLayout } from '../ui/ColorButtonLayout';
import { useChangeColor } from '../utils/useChangeColor';
import { SaveButton } from '../ui/SaveButton';

export default function Home() {
  const [changeInnerColor, changeOuterColor, {innerColor, outerColor}] = useChangeColor(); 

  return (
    <div className="min-h-screen flex flex-col justify-items-center">
      <NavBar />
      <div className="flex flex-col mb-10 items-center">
        <Title text="Create your own card!!" />
        <SignInLayout />
        
        <ColorButtonLayout 
          changeInnerColor={changeInnerColor}
          changeOuterColor={changeOuterColor}
        />
        <SaveButton />
        <CardLayout innerColor={innerColor} outerColor={outerColor}/>
      </div>
      <FooterLayout />
    </div>
  )
}

まとめ

思ったより非常に長い記事となってしまいました。
ここまで読んでくださった方、ありがとうございました。
当初はここに、興味・関心のあるタグの作成/登録、さらには名刺の検索機能等をrecoilとfirebaseを用いてやっていこうと思っていましたが、流石に多すぎだなと反省しました。(記事外で実装していく予定です)
今回の記事で、認証やdom、設計など、いろいろ考えさせられることが多く、良い体験だったなと思いました。
readmeはそのうち整備すると思います。