🔥

Next.js+Fireastoreでリアルタイムチャットを作る

2022/12/28に公開約22,800字

この記事は合同会社DMM 23卒内定者 Advent Calendar 2022の14日目の記事です

はじめに

こんにちは!
DMM23卒内定者の0yuといいます。

気がつけば師走も終わりかけ、アドカレとしては大遅刻ですが、せっかくなので何か書ければと思い、登録していた枠に差し込み投稿させていただきました。
https://qiita.com/advent-calendar/2022/dmm2023

今回はちょっとした小ネタとして、Firebase Realtime Databaseの練習のため簡単なリアルタイムチャットアプリを作成してみました。
https://firebase.google.com/products/realtime-database/?hl=ja&gclid=CjwKCAiA76-dBhByEiwAA0_s9fKG1G07T4ME1gXQFAm8R3f-tG2hJWu3s1SN5kF3itgWUnG-E5LbdxoClhAQAvD_BwE&gclsrc=aw.ds

つくったもの

完成形

https://github.com/yud0uhu/firebase-realtime-chat

Firebase Realtime Databaseについて

Firebaseには、マルチプラットフォームでユーザー認証機能を実装できるFirebase Authentication や、静的なWebページをデプロイできるFirebase Hosting などのサービスがあります。
今回は、NoSQLデータベースによりリアルタイムでデータを保存し、すべてのクライアント間で同期できるサービスRealtime Database を利用します。

手順

Next.js+TypeScript+ESLint+Prettier+husky+Firebaseの環境構築を行います。
下記のテンプレートリポジトリをcloneすれば、3.リアルタイムチャットを実装する から同様の手順を行うことができます。
https://github.com/yud0uhu/next-ts-eslint-prettier-husky-template

1.Next.jsプロジェクトのセットアップ

Create Next Appする
$ npx create-next-app@latest --ts
Need to install the following packages:
  create-next-app@13.1.1
Ok to proceed? (y) y
✔ What is your project named? … realtime-chat
✔ Would you like to use TypeScript with this project? … No / Yes
✔ Would you like to use ESLint with this project? … No / Yes
Creating a new Next.js app in /Users/denham/Documents/realtime-chat.

Using npm.
prettierの導入
$ yarn add -D prettier eslint-config-prettier

eslint-config-prettierの構成を利用して、Prettierと競合する可能性のあるESLint ルールをオフにする
https://prettier.io/docs/en/install.html#eslint-and-other-linters

huskyとlint-stagedのインストール
$ yarn add --dev husky lint-staged
$ npx husky install
$ npm pkg set scripts.prepare="husky install"
$ npx husky add .husky/pre-commit "npx lint-staged"

.hasky/pre-commitの修正

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

- npx lint-staged
+ yarn lint-staged

package.jsonに追記

package.json
{
  "lint-staged": {
    "**/*": "prettier --write --ignore-unknown"
  }
}

動いていることを確認

$ git commit -m"add: huskyとlint-stagedのインストール"
✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...
[main 98e8320] add: huskyとlint-stagedのインストール
 4 files changed, 8134 insertions(+), 5628 deletions(-)
 create mode 100755 .husky/pre-commit
eslintとprettierの構成のセットアップ

.eslintrc.jsonの修正

.eslintrc.json
{
  "ignorePatterns": [
    "**/.next/**",
    "**/_next/**",
    "**/dist/**",
    "**/storybook-static/**",
    "**/lib/**"
  ],
  "extends": ["next/core-web-vitals", "prettier"],
  "rules": {
    "react/jsx-sort-props": [
      "error",
      {
        "callbacksLast": true,
        "shorthandLast": true
      }
    ],
    "import/order": "error"
  }
}
.prettierrc
prettierの構成を追加
{
  "trailingComma": "all",
  "tabWidth": 2,
  "semi": false,
  "singleQuote": true,
  "jsxSingleQuote": true,
  "printWidth": 100
}

npm scriptの追加

package.json
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint --dir src",
    "lint:fix": "yarn lint --fix",
    "format": "prettier --write --ignore-path .gitignore './**/*.{js,jsx,ts,tsx,json}'",
    "prepare": "husky install"
  },
}
⚠️yarn v3の設定

⚠️注意事項として、Vercelはyarn3+pnpがサポート外となっています。
そのため、ホスティング先にVercelを用いる場合はyarn 1を使うことを推奨します。
https://vercel.com/guides/does-vercel-support-yarn-2

リポジトリにyarn v3を設定する

$ yarn init -y
$ yarn set version berry # v2(Berry)を経由してアップグレード
$ touch yarn.lock
$ yarn set version 3.0.0

yarn v2以降 はデフォルトではyarn installからnode_moduleを生成しないため、node_moduleを使う設定を追加する
.yarnrc.yml ファイルを開き、以下の1行を追加する

yarnrc.yml
+ nodeLinker: node-modules

yarnPath: .yarn/releases/yarn-3.0.0.cjs

.gitignore に以下の設定を追加する

.gitignore
# yarn (using Zero-Installs)
.yarn/*
!.yarn/cache
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

yarn v2以降では、node_modulesの代わりに、.pnp.js(Plug'n'Play)という単一のファイルを内部に作成し、パッケージやバージョンの依存関係を解決するZero-Installs という手法がとられています。
これらのキャッシュは、合計1.2GBあるnode_moduleに比べ、合計しても139MBほどのサイズしかありません。そのため、コミットしてもほとんど問題ではありませんが(公式の見解)、後々の肥大化を避けるため.gitignore に追加します。

参考記事

https://zenn.dev/mizchi/articles/yarn-v1-to-v3
https://zenn.dev/kkoudev/articles/5b440e1e341458
https://www.wantedly.com/companies/wantedly/post_articles/325643

2.Firebaseの設定

firebase コンソールから任意のプロジェクトを作成する

https://console.firebase.google.com/u/0/
プロジェクトの作成(手順 1/3)

FirebaseCLIの追加

https://firebase.google.com/docs/cli#install-cli-mac-linux
に従い、curlからインストールする

$ curl -sL https://firebase.tools | bash

firebase loginで以下のような画面が出たら成功

firebase login
i  Firebase optionally collects CLI and Emulator Suite usage and error reporting information to help improve our products. Data is collected in accordance with Google's privacy policy (https://policies.google.com/privacy) and is not used to identify you.
? Allow Firebase to collect CLI and Emulator Suite usage and error reporting information? Yes
...

Firebase CLI Login Successful

リポジトリにfirebaseを追加する

$ yarn add firebase

src/lib/firebase/firebase.tsを作成し、Firebaseの初期設定を行う

$ mkdir -p src/lib/firebase
$ touch src/lib/firebase/firebase.ts

環境変数は画面左の歯車マークからプロジェクトの設定を選択してスクロール後、マイアプリから「アプリの追加」を行うことで確認できます。
アプリの追加1
アプリの追加2
Firebase init

src/lib/firebase/firebase.ts
import { getApps, getApp, FirebaseOptions, FirebaseApp, initializeApp } from 'firebase/app'

export const config: FirebaseOptions = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGE_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
  measurementId: process.env.NEXT_PUBLIC_AMESUREMENT_ID,
}

export const getFirebaseApp = (): FirebaseApp => {
  return !getApps().length ? initializeApp(config) : getApp()
}

.local.envに環境変数を追加する

NEXT_PUBLIC_FIREBASE_API_KEY=XXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=XXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_FIREBASE_PROJECT_ID=XXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=XXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_FIREBASE_MESSAGE_SENDER_ID=XXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_FIREBASE_APP_ID=XXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_FIREBASE_AMESUREMENT_ID=XXXXXXXXXXXXXXXXXXXX
Firebaseの初期化と疎通確認
src/pages/_app.tsx
import '../styles/globals.css'
import type { AppProps } from 'next/app'
+ import { getFirebaseApp } from '../lib/firebase/firebase'

getFirebaseApp()

export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}
src/pages/index.tsx
import { getApp } from 'firebase/app'

export default function Home() {
+  console.log(getApp())
  return (
    <>
      <h1>Hello world!</h1>
    </>
  )
}

ここで、ブラウザ上でFirebaseAppImplの情報が取得できていれば疎通確認はOKです。

Firebaseの疎通確認ログ

3.Firebase Realtime Databaseの作成

構築 > Realtime Databaseを選択
手順1
手順2
ロケーションは米国を選択する
手順3
後から変更できるため、テストモードで作成する
手順4
手順5

3.リアルタイムチャットを実装する

チャットの送信機能の実装

チャットの送信機能に必要な、データベースへのデータの保存は下記のリファレンスを参考に行います。
https://firebase.google.com/docs/database/admin/save-data?hl=ja#node.js

$ touch src/pages/chat/index.tsx
src/pages/chat/index.tsx
import { FormEvent, useState } from 'react'
// Import Admin SDK
import { getDatabase, push, ref } from '@firebase/database'
import { FirebaseError } from '@firebase/util'
import { NextPage } from 'next'

const ChatPage: NextPage = () => {
  const [message, setMessage] = useState<string>('')

  const handleSendMessage = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    try {
      // databaseを参照して取得する
      const db = getDatabase()
      // 取得したdatabaseを紐付けるref(db, 'path')
      const dbRef = ref(db, 'chat')
      // pushはデータを書き込む際にユニークキーを自動で生成する
      // 今回はpush() で生成されたユニークキーを取得する
      // messageというキーに値(message)を保存する
      await push(dbRef, {
        message,
      })
      // 書き込みが成功した際はformの値をリセットする
      setMessage('')
    } catch (e) {
      if (e instanceof FirebaseError) {
        console.log(e)
      }
    }
  }

  return (
    <>
      <h1>チャット</h1>
      <form onSubmit={handleSendMessage}>
        <input value={message} onChange={(e) => setMessage(e.target.value)} />
        <button type={'submit'}>送信</button>
      </form>
    </>
  )
}
export default ChatPage

動作確認
Firebase Console

チャットの受信機能の実装

チャットの受信機能に必要な、データベースへのデータの取得は下記のリファレンスを参考に行います。
データベースのチャットログを監視し、変更があれば画面上に表示させるようにします。
https://firebase.google.com/docs/database/admin/retrieve-data?hl=ja#node.js

src/pages/chat/indext.tsx
import { FormEvent, useEffect, useState } from 'react'
// Import Admin SDK
import { getDatabase, onChildAdded, push, ref } from '@firebase/database'
import { FirebaseError } from '@firebase/util'
import { NextPage } from 'next'
import Message from '@/components/chat/message'

const ChatPage: NextPage = () => {
  const [message, setMessage] = useState<string>('')
  const [chatLogs, setChatLogs] = useState<{ message: string; createdAt: string }[]>([])
  
  ...

  useEffect(() => {
    try {
      // Get a database reference to our posts
      const db = getDatabase()
      const dbRef = ref(db, 'chat')
      // onChildAddedでデータの取得、監視を行う
      // onChildAddedはqueryとcallbackを引数に取り、Unsubscribeを返して、変更状態をsubscribeする関数
      return onChildAdded(dbRef, (snapshot) => {
        // Attach an asynchronous callback to read the data at our posts reference
        // データベースからのデータはsnapshotで取得する
        // snapshot.val()でany型の値を返す
        const message = String(snapshot.val()['message'] ?? '')
        setChatLogs((prev) => [...prev, { message }])
      })
    } catch (e) {
      if (e instanceof FirebaseError) {
        console.error(e)
      }
      // unsubscribeする
      return
    }
  }, [])

  return (
    <>
      <h1>チャット</h1>
      {chatLogs.map((chat, index) => (
        <Message key={`ChatMessage_${index}`} message={chat.message} />
      ))}
      <form onSubmit={handleSendMessage}>
        <input value={message} onChange={(e) => setMessage(e.target.value)} />
        <button type={'submit'}>送信</button>
      </form>
    </>
  )
}
export default ChatPage

# 受信部分のコンポーネントを作成
$ touch src/components/chat/message/indext.tsx
type MessageProps = {
  message: string
}

const Message = ({ message }: MessageProps) => {
  return (
    <ul>
      <li>
        <text>{message}</text>
      </li>
    </ul>
  )
}

export default Message

動作確認

送信したメッセージが画面上に追加されていくようになっています
動作確認

Tailwindの導入で見た目を綺麗にする

これだけでは少々殺風景なので、CSSフレームワークのTailwind CSSを利用してUIを整えます。
https://tailwindcss.com/docs/guides/nextjs
に従ってセットアップを行います。

$ yarn add -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
$ touch tailwind.config.js
tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx}", // リポジトリ構成に応じたpathを設定する
    "./src/components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

src/styles/globals.css を以下のように書き換える

src/styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

src/pages/index.tsx を以下のように書き換える

src/pages/index.tsx
import { getApp } from 'firebase/app'

export default function Home() {
  console.log(getApp())
  return (
    <>
+       <h1 className='text-3xl font-bold underline'>Hello world!</h1>
    </>
  )
}

ここまでのセットアップ後、tailwindが画面上に反映されないときは、一度.nextを削除してからyarn installしましょう。

VSCodeで開発を行う際は、下記の拡張機能が便利です。
クラス名の自動補完や、ホバーでCSS全体のプレビューをおこなってくれます。
https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss

tailwind

デフォルトだと補完のタイミングが遅いため、ユーザースペースかワークスペース内のsetting.jsonに下記の設定を記述すれば、常に候補を出してくれます。

.vscode/setting.json
"editor.quickSuggestions": {
  "strings": true
}

eslintは以下のpluginを導入します。
https://github.com/francoismassart/eslint-plugin-tailwindcss

$ yarn add -D eslint-plugin-tailwindcss

下記のように設定することで、自動ソートなどをいい感じにおこなってくれます。

.eslintrc.json
{
...
+  "plugins": ["tailwindcss"],
+  "extends": ["next/core-web-vitals", "prettier", "plugin:tailwindcss/recommended"],
...
}

tailwindで画面を作っていきます。

src/components/chat/message/index.tsx
import Image from 'next/image'
import { FC } from 'react'
import LogoImage from '../../../assets/logo.jpg'
type MessageProps = {
  message: string
}

const Message: FC<MessageProps> = ({ message }: MessageProps) => {
  return (
+     <div className='flex flex-wrap gap-2'>
      <div>
+         <Image alt='LogoImage' className='h-12 w-12' src={LogoImage} />
      </div>
+       <p className='m-4 rounded bg-sky-200 p-2 text-white'>{message}</p>
    </div>
  )
}

export default Message
src/components/commpon/header/index.tsx
import { FC } from 'react'
type HeaderProps = {
  title: string
}
const Header: FC<HeaderProps> = ({ title }: HeaderProps) => {
  return (
    <header>
+       <nav className='relative flex w-full items-center justify-between bg-sky-200 py-2 shadow-md'>
+         <div className='mx-auto flex max-w-sm items-center space-x-4 p-6'>
          <div>
+             <div className='text-xl font-medium text-white'>{title}</div>
          </div>
        </div>
      </nav>
    </header>
  )
}
export default Header

スクロールバーのカスタマイズ

スクロールバーのスタイルは共通なので、global.cssで設定しています。

@tailwind base;
@tailwind components;
@tailwind utilities;

+ ::-webkit-scrollbar {
+   width: 10px;
+ }
+ ::-webkit-scrollbar-track {
+   background-color: transparent;
+   border-radius: 5px;
+ }
+ ::-webkit-scrollbar-thumb {
+   background-color: rgb(186 230 253);
+   border-radius: 5px;
+ }

送信時刻の表示

次に、date-fns というNode.jsのライブラリを使って送信時刻を取得・表示させてみます。

$ yarn add date-fns
    "@types/node": "^18.11.17",
    "@types/react": "18.0.26",
    "@types/react-dom": "18.0.10",
+    "date-fns": "^2.29.3",
    "eslint": "8.30.0",
    "eslint-config-next": "13.1.1",
    "firebase": "^9.15.0",
src/components/chat/message/index.tsx
import LogoImage from '../../../assets/logo.jpg'
type MessageProps = {
  message: string
  createdAt: string
}
-const Message: FC<MessageProps> = ({ message }: MessageProps) => {
+const Message: FC<MessageProps> = ({ message, createdAt }: MessageProps) => +{
  return (
-    <div className='flex flex-wrap gap-2'>
+     <div className='grid auto-cols-max grid-flow-col'>
      <div>
        <Image alt='LogoImage' className='h-12 w-12' src={LogoImage} />
      </div>
      <p className='m-4 rounded bg-sky-200 p-2 text-white'>{message}</p>
      <p className='my-4 pt-4 text-sm'>{createdAt}</p>
    </div>
  )
}
src/components/chat/message/index.tsx

import { FormEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
// Import Admin SDK
import { getDatabase, onChildAdded, push, ref } from '@firebase/database'
import { FirebaseError } from '@firebase/util'
import { NextPage } from 'next'
import { format } from 'date-fns'
import { ja } from 'date-fns/locale'
import Message from '@/components/chat/message'
import Header from '@/components/common/header'

const ChatPage: NextPage = () => {
  const [message, setMessage] = useState<string>('')
-  const [chatLogs, setChatLogs] = useState<{ message: string }[]>([])
+  const [chatLogs, setChatLogs] = useState<{ message: string; createdAt: string }[]>([])
  const scrollBottomRef = useRef<HTMLDivElement>(null)

+  const createdAt = format(new Date(), 'HH:mm', {
+    locale: ja,
+  })
  const handleSendMessage = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    try {
      // Get a database reference to our blog
      const db = getDatabase()
      // 取得したdatabaseを紐付けるref(db, 'path')
      const dbRef = ref(db, 'chat')
      // pushはデータを書き込む際にユニークキーを自動で生成する
      // 今回はpush() で生成されたユニークキーを取得する
      // messageというキーに値(message)を保存する
      await push(dbRef, {
        message,
+        createdAt,
      })
      // 成功した際はformの値をリセットする
      setMessage('')
      scrollBottomRef?.current?.scrollIntoView!({
        behavior: 'smooth',
        block: 'end',
      })
    } catch (e) {
      if (e instanceof FirebaseError) {
        console.log(e)
      }
    }
  }

  useEffect(() => {
    try {
      // Get a database reference to our posts
      const db = getDatabase()
      const dbRef = ref(db, 'chat')
      /** onChildAddedでデータの取得、監視を行う
       * onChildAddedはqueryとcallbackを引数に取り、Unsubscribeを返して、変更状態をsubscribeする関数
       * export declare function onChildAdded(query: Query, callback: (snapshot: DataSnapshot, previousChildName?: string | null) => unknown, cancelCallback?: (error: Error) => unknown): Unsubscribe;
       **/
      return onChildAdded(dbRef, (snapshot) => {
        // Attach an asynchronous callback to read the data at our posts reference
        // Firebaseデータベースからのデータはsnapshotで取得する
        // snapshot.val()でany型の値が返ってくる
        const message = String(snapshot.val()['message'] ?? '')
        const createdAt = String(snapshot.val()['createdAt'] ?? '')
+        setChatLogs((prev) => [...prev, { message, createdAt }])
      })
    } catch (e) {
      if (e instanceof FirebaseError) {
        console.error(e)
      }
      // unsubscribeする
      return
    }
  }, [])

  return (
    <div className='h-screen overflow-hidden'>
      <Header title={'あざらしちゃっと'} />
      <div className='container mx-auto bg-white dark:bg-slate-800'>
        <div className='relative m-2 h-screen items-center rounded-xl'>
+          <div className='absolute inset-x-0 top-4 bottom-32 flex flex-col space-y-2 px-16'>
            <div className='overflow-y-auto'>
              {chatLogs.map((chat, index) => (
+                <Message
+                  createdAt={chat.createdAt}
+                  key={`ChatMessage_${index}`}
+                  message={chat.message}
+                />
              ))}
              <div ref={scrollBottomRef} />
            </div>
            <div className='position-fixed'>
              <form onSubmit={handleSendMessage}>
                <div className='grid grid-flow-row-dense grid-cols-5 gap-4'>
                  <input
+                    className='col-span-3 block w-full overflow-hidden text-ellipsis rounded border py-2 px-4 pl-2 focus:ring-sky-500 sm:text-sm md:col-span-4 md:rounded-lg'
                    placeholder='メッセージを入力してください'
                    value={message}
                    onChange={(e) => setMessage(e.target.value)}
                  />
+                  <button
                    className='col-span-2 rounded bg-sky-200 py-2 px-4 font-bold text-white hover:bg-sky-300 md:col-span-1'
                    type={'submit'}
                  >
                    送信
                  </button>
                </div>
              </form>
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}
export default ChatPage

自動スクロールの作成

メッセージの送信後、最新のメッセージが表示されるように一番下までスクロールさせたいと思います。
下記のように作成したRefを、スクロールさせたい場所にある Element にセットします。

src/pages/chat/index.tsx
  const [message, setMessage] = useState<string>('')
  const [chatLogs, setChatLogs] = useState<{ message: string; createdAt: string }[]>([])

+    const scrollBottomRef = useRef<HTMLDivElement>(null)

scrollIntoView()でスクロールを設定します。

scrollBottomRef?.current?.scrollIntoView!({
        behavior: 'smooth',
        block: 'end',
      })

スクロールさせたい場所のElementにRefを設定します。
ここでは、メッセージ一覧の末尾に

src/pages/chat/index.tsx
<div ref={scrollBottomRef} />

をセットします。

src/pages/chat/index.tsx
...
  return (
    <div className='h-screen overflow-hidden'>
      <Header title={'あざらしちゃっと'} />
      <div className='container mx-auto bg-white dark:bg-slate-800'>
        <div className='relative m-2 h-screen items-center rounded-xl'>
          <div className='absolute inset-x-0 top-4 bottom-32 flex flex-col space-y-2 px-16'>
            <div className='overflow-y-auto'>
              {chatLogs.map((chat, index) => (
                <Message
                  createdAt={chat.createdAt}
                  key={`ChatMessage_${index}`}
                  message={chat.message}
                />
              ))}
+              <div ref={scrollBottomRef} />
...

4.デプロイする

せっかくなのでデプロイしてみましょう。
Vercelでのホスティング方法についてはここでは割愛します。
(事前にVercelの管理画面からenv.localの値をセットするのを忘れないようにしましょう)
https://vercel.com/

完成形は以下のようになります。
完成形
https://firebase-realtime-chat-one.vercel.app/

おわりに

今回はFirebaseのRealtime Databaseを利用し、簡単なリアルチャットアプリを作成しました。
Realtime Databaseは使いこなせれば色々と応用が効かせられそうなので、何かの形で発展させてアウトプットしてみたいと思います。

参考にさせていただいた記事

https://zenn.dev/hisho/books/617d8f9d6bd78b

追記 12/29

OGPを実装しました
https://zenn.dev/denham/articles/b2378462d54823

追記 2023/1/1

サインイン機能を実装しました
https://zenn.dev/denham/articles/27ff0c61878644

Discussion

ログインするとコメントできます