[번역] Next.js와 Tailwind을 이용한 엔지니어 명함 앱

·

12 min read

💡
본 기사는 Next.js와 Tailwind를 이용한 엔지니어 명함 앱을 번역한 글입니다.

소개

안녕하세요! 올해도 이제 며칠 만 지나면 끝나는 것으로, 빠르네요(역주: 이 글의 원본 포스팅은 2020년 12월 21일에 작성되었습니다).
이번에 구현하는 내용은, 10월에 온라인으로 개최된 nextjs conf 2020 에서 구현되고 있던, 티켓 발행하는 녀석을 원재료로 하고 있습니다. GitHub 인증을 통해 자신의 원래 티켓 이미지를 생성합니다. 예제
이번에 구현하는 앱에서는 github 인증과 google 인증을 사용하여 프로필 데이터를 가져오고 표시할 것입니다.

이번에 구현할 내용

생각보다 길어질 것 같아서 먼저 구현한 것을 소개하고 싶습니다.
주로 헤더, 버튼 레이아웃, 명함 레이아웃, 풋터(역주:footer) 같은 구성으로 나뉩니다.
명함에 표시하고 있는 프로필 데이터는, 인증한 것으로부터 취득하고 있습니다.
또한 명함의 바깥 테두리와 안 테두리로 무작위로 색상을 변경할 수 있습니다.
자신 취향의 색상으로 커스터마이즈 해 보세요.

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에 이런 일이 쓰여져 있는 것은, 꽤 유머러스하고 재미있다고 생각했습니다.

그럼, 구현

그럼, 구현을 해 갑니다.
이번에 사용하는 주요 라이브러리의 버전은 다음과 같습니다.

"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버그 또는 설정이 부족한지 아직 알 수 없지만 설정하면 purgeCSS 빌드가 깨져 정상적으로 반영되지 않습니다.
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는 계속해 사용하기로 했습니다.
덧붙여 와일드 카드의 이용에 관해서는, 서브 도메인이라면 사용을 검토해도 괜찮을 지도같은 일도 쓰여져 있으므로, 만약 실제로 사용한다면 대응해 나가고 싶다고 생각합니다.

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라는 라이브러리를 사용하여 지정된 요소를 캔버스합니다.

여기서 방금 준 next/image의 빌드인 api를 직접 두드리는 이유로 돌아갑니다.
next/image 컴포넌트 자체에서는 이미지 최적화가 이루어지지 않고 URL 생성과 반응 대응만이 이루어지고 있습니다. srcset내에 복수의 이미지 URL을 지정해 두고, 화면 사이즈마다 적절한 URL을 자동적으로 생성, 선택하고 있습니다.
html2canvas는 dom과 css를 읽고 그들을 바탕으로 canvas에 그립니다. 그 때문에, next/image 컴퍼넌트를 그대로 이용했을 때에는, 정보가 일의에 정해져 있지 않고, 화상 사이즈가 이상해지고 있는 것이 아닐까 생각했습니다.
또한 외부 url을 이미지로 표시했을 때 이미지가 다른 오리진에서 가져 왔기 때문에 가져올 수 없습니다.
이러한 문제는 next/image내의 api를 직접 두드리는 것으로 해결되었습니다.
덧붙여 next/image에 관한 설명은, 이하의 분들이 상세하게 설명하고 있었으므로, 흥미가 있으면 꼭.

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는 그 중 정비한다고 생각합니다.