LoginSignup
3
1

More than 1 year has passed since last update.

【デモ】学習記録アプリ📖

Last updated at Posted at 2023-05-15

1. 本稿の目的

React を勉強する一環として学習記録アプリを作成してみました。
作る過程で得た知識の定着を目的として、以下に作成方法をまとめていきます。

また、本稿では React や TypeScript の文法については細かく説明しませんので、適宜公式ドキュメントを参照してください。
以下が使用する主な技術のドキュメントです。

2. 環境

ツール バージョン
macOS Ventura 13.2.1
Node.js 18.16.0
React 18.2.0
TypeScript 4.9.5
Firebase 9

3. 環境構築

3.1 Visual Studio Code

3.1.1 インストール

フロントエンド開発では、Visual Studio Code(VSCode)が一般的に使用されています。
他の IDE(統合開発環境)と比べても非常に軽量です。TypeScript の開発元である Microsoft の製品でもあるため、特別なこだわりがない限り VSCode をお勧めします。
(ちなみに個人的には WebStorm が好きです。)

VSCode は以下の公式サイトからダウンロードできます。

3.1.2 拡張機能の追加

VSCode を起動すると左のサイドバーに四角が積み重なったようなマークがありますのでクリックして開きます。
image.png

検索窓にeslintと入力して以下のプラグインをインストールします。
(私の場合は既にインストールしているので表記が異なっていますが、初めての場合はインストールというボタンが表示されます。)
image.png
同じように以下のプラグインをインストールします。
プラグインの詳細については、インストール画面の説明やプラグインのホームページ、GitHub などを読んでみてください。
image.png
image.png
また、以下のプラグインは必須ではありませんが、おすすめのプラグインです。
image.png
image.png
image.png
image.png

3.2 Node.js

3.2.1 インストール

React プロジェクトを作成するために、Node.js をインストールする必要があります。
Node.js とはサーバサイドで動く JavaScript のことです。
Node.js はプロジェクトごとに使用するバージョンが変化するため、公式の Web サイトから直接インストールするよりもバージョン管理ツールを用いるのが一般的かと思います。
個人的に Volta が使いやすくてお勧めです。

また、バージョン管理ツールはいいからとりあえず動かしたいという方であれば、以下の Progate の解説がわかりやすいので読んでみてください。

以下の記事内では 16 系を使用していますが、本稿で使用する Node のバージョンは 18.16.0 です。

3.2.2  バージョンの確認

Node.js のインストールが完了したらターミナルでバージョンを確認します。
node -vというコマンドを実行して以下のようにバージョンが表示されたらインストール成功です。

ターミナル
~ % node -v
v18.16.0

3.3 Create React App

3.3.1 プロジェクトの作成

今回利用するのはCreate React Appという React で Single Page Application(SPA)を簡単に作成することができるコマンドです。
お使いのターミナルでプロジェクトを作成したいディレクトリに移動し、以下のコマンドを実行します。
--template typesriptと末尾につけることによって TypeSciprt をインストールした状態の React プロジェクトを作成してくれます。

ターミナル
npx create-react-app just-do-it --template typescript

just-do-itの部分は任意のプロジェクト名(お好きな名前)でオッケーです。
本稿のデモアプリでは学習を記録するアプリのため、メンタル強めの名前にさせていただきました。
実行後のメッセージの最終行にHappy hacking!出てきたら成功です。

3.3.2 起動確認

現在いるディレクトリの直下に 3.3.1 のコマンドで React プロジェクトが作成されました 🎉
まずは起動確認するために、プロジェクトディレクトリに移動します。

ターミナル
cd just-do-it

続けて、起動コマンドを叩きます。

ターミナル
npm start

自動でブラウザを立ち上げてhttp://localhost:3000にアクセスしてくれるはずです。
自動でブラウザが立ち上がらなかった方は、手動でアクセスしてみてください。
image.png
終了させたい時はctrl + Cで終了できます。

3.3.3 不要なファイルの削除

VSCode で作成したプロジェクトを開きます。
image.png
src 直下の以下のファイルは今回使用しないため、削除しておきます。

  • App.css
  • logo.svg
  • react-app-env.d.ts
  • reportWebVitals.ts
  • setupTests.ts
  • App.test.tsx
    image.png

3.3.4 不要な記述の削除

src/index.css
- body {
-   margin: 0;
-   font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
-     'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
-     sans-serif;
-   -webkit-font-smoothing: antialiased;
-   -moz-osx-font-smoothing: grayscale;
- }
-
- code {
-   font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
-     monospace;
- }
src/App.tsx
import React from 'react';
- import logo from './logo.svg';
- import './App.css';

function App() {
  return (
-    <div className="App">
-      <header className="App-header">
-        <img src={logo} className="App-logo" alt="logo" />
-        <p>
-          Edit <code>src/App.tsx</code> and save to reload.
-        </p>
-        <a
-          className="App-link"
-          href="https://reactjs.org"
-          target="_blank"
-          rel="noopener noreferrer"
-        >
-          Learn React
-        </a>
-      </header>
-    </div>
+   <div>Hello, world!</div>
  );
}

export default App;

※後の動作確認用に 1 行だけ追加しています。

index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
- import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

- // If you want to start measuring performance in your app, pass a function
- // to log results (for example: reportWebVitals(console.log))
- // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
- reportWebVitals();

3.3.5 動作確認

ターミナル
npm start

以下のようにHello, world!と表示されていれば成功です。
image.png
画面に表示されている文字列は、先ほど App.tsx に記述した<div>Hello, world!</div>の部分です。
index.tsx が読み込まれ、return 文の中で App コンポーネント(App.tsx から export されているもの)が呼び出されています。
さらに呼び出された App コンポーネントから div 要素を返却することで画面へ描画しています。

3.4   settings.json

プロジェクトの作成が完了したところで、一旦 VSCode の設定にもどりたいと思います。
設定はそれぞれ好みがあるかと思いますので、本項ではプロジェクトごとに設定する方法を案内します。

  1. VSCode の左したにある歯車アイコンの設定マークを開きます。
    image.png

  2. 出てきた設定画面のタブをワークスペースに切り替えます。
    image.png
    ユーザータブはグローバルな設定ができます。つまり VSCode 全体に設定するのでどのプロジェクトを開いても適用されます。
    一方、ワークスペースはプロジェクトごとに設定が作成されます。
    それぞれお好みに合わせて使ってみてください。

  3. VSCode の右上に下記のようなアイコンがありますので、クリックして`settings.jsonファイルを開いてください。
    image.png

ワークスペースタブからsettings.jsonを開くと、プロジェクトに.vscode/settings.jsonが自動生成されます。
Git にあげればチームで共有することも可能です。

.vscode/settings.json
{
  "editor.bracketPairColorization.enabled": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "editor.minimap.enabled": false,
  "editor.renderWhitespace": "all",
  "editor.tabSize": 2,
  "explorer.sortOrder": "type",
  "files.autoSave": "onFocusChange",
  "typescript.referencesCodeLens.enabled": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}

それぞれの設定の内容は以下の通りです。

 設定  内容
"editor.bracketPairColorization.enabled": true, 括弧の対応を示すために色付けを有効にします。
"editor.defaultFormatter": "esbenp.prettier-vscode", コードを自動フォーマットするフォーマッターに Prettier を指定
"editor.formatOnSave": true, ファイル保存時にコードフォーマットを実施
"editor.minimap.enabled": false, ミニマップ(右側に出てくるエディタファイルの全体像)を無効化
"editor.renderWhitespace": "all", 空白文字を表示
"editor.tabSize": 2, タブサイズをスペース 2 個分に設定
"explorer.sortOrder": "type", エクスプローラーの並べ替えを拡張子ごとに分ける
"files.autoSave": "onFocusChange", エディタがフォーカスを失うと自動保存
"typescript.referencesCodeLens.enabled": true, 関数や変数の被 import 数を表示できる
"editor.codeActionsOnSave": { "source.fixAll.eslint": true } 保存時に ESLint をもとに自動修正

3.5 ESLint ・ Prettier

3.5.1 ignore ファイルの作成

ESLint と Prettier を入れる前に先に ignore ファイルを作ってしまいます。
後でプラグインを入れた時に予期しないコードの整形とかが走るとバグの原因になるので...
プロジェクトの直下に以下のファイルを新規作成します。

ESLint が解析をしないファイルおよびディレクトリを指定

.eslintignore
**/node_modules/*
**/build/*
/.eslintrc.js
/tailwind.config.js

続いて Prettier がチェックしないファイルおよびディレクトリを指定

.prettierignore
node_modules
build
package-lock.json
public

3.5.2 インストール

ESLint とは JavaScript のコード解析ツールです。

https://eslint.org/docs/latest/use/getting-started

バグの原因の早期発見やコードの可動性を上げることができます。
次のコマンドを使用して、ESLint をインストールおよび構成していきます。

ターミナル
npm init @eslint/config

実行すると以下のように質問がされるので答えていきます。
各質問はお好みに合わせて設定を進めてください。
複数選択肢が場合はお使いのキーボードの矢印キーでカーソルを移動できます。
Enterキーで決定です。
本稿で選択した設定はコードブロック左上のラベルに記載しておきます。
また質問で聞かれている内容をざっくりとコードブロックの上に書いておきます。

@eslint/create-configというパッケージをインストールする必要があるが進めても良いか?

y
Need to install the following packages:
  @eslint/create-config@0.4.3
Ok to proceed? (y)

ESLint をどのように使いたいか

To check syntax only
? How would you like to use ESLint? …
❯ To check syntax only
  To check syntax and find problems
  To check syntax, find problems, and enforce code style

プロジェクトで使用したいモジュールシステムの種類

JavaScript modules (import/export)
? What type of modules does your project use? …
❯ JavaScript modules (import/export)
  CommonJS (require/exports)
  None of these

どのフレームワークで使用するか

React
? Which framework does your project use? …
❯ React
  Vue.js
  None of these

プロジェクトで TypeScript を使うか

Yes
? Does your project use TypeScript? › No / Yes

どこでコードを動かすか

Browser
? Where does your code run? …  (Press <space> to select, <a> to toggle all, <i> to invert selection)
✔ Browser
✔ Node

構成ファイルはどの形式が良いか

JavaScript
? What format do you want your config file to be in? …
❯ JavaScript
  YAML
  JSON

次のパッケージをインストールする必要があるのでインストールしてもいいか

Yes
Local ESLint installation not found.
The config that you've selected requires the following dependencies:

eslint-plugin-react@latest @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest eslint@latest
? Would you like to install them now? › No / Yes

パッケージマネージャーはどれを使用するか

npm
? Which package manager do you want to use? …
❯ npm
  yarn
  pnpm

全ての質問に答えて実行したあとに
Successfully created .eslintrc.js file in /[プロジェクトを保存しているディレクトリ]/just-do-it
というメッセージが表示されたら成功です。

3.5.3 ESLint のプラグインと Prettier をインストール

下記のコマンドで、今回 ESLint に設定するプラグインをインストールします。

ターミナル
npm install --save-dev eslint-config-prettier eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-simple-import-sort eslint-plugin-sort-keys-custom-order eslint-plugin-unused-imports npm-run-all prettier

上記でインストールしているものの概要はこちらです。

パッケージ名 概要
eslint-config-prettier 不要な、または Prettier と競合する可能性のあるすべてのルールをオフにする。
eslint-plugin-import ES6 モジュールを使用する際に、モジュールの読み込みに関する問題を検出するための ESLint プラグイン。
eslint-plugin-jsx-a11y React アプリケーションで、アクセシビリティに関する問題を検出するための ESLint プラグイン。
eslint-plugin-react React アプリケーションで、React の最新のベストプラクティスに従っていることを確認するための ESLint プラグイン。
eslint-plugin-react-hooks React フックに関する問題を検出するための ESLint プラグイン。
eslint-plugin-simple-import-sort モジュールの読み込みの並び順に関する問題を検出するための ESLint プラグイン。
eslint-plugin-sort-keys-custom-order オブジェクトプロパティの並び順に関する問題を検出するための ESLint プラグイン。
eslint-plugin-unused-imports 未使用の変数や関数を検出するための ESLint プラグイン。
npm-run-all 複数の npm スクリプトを同時に実行するためのツール。
prettier   コードフォーマッター。コードを一貫したスタイルで整形することができる。

3.5.4 .eslintrc.js の編集

.eslintrc.js にて ESLint の設定を行うことができます。
以下の通り eslintrc.js を編集します。
それぞれの概要についてはコメントに書いてあります。
さらに詳しく知りたい方は、ESLinkt の公式サイトや上記でインストールしたプラグインの GitHub にある ReadMe などを読んでみてください。

.eslintrc.js
module.exports = {
  root: true, // ルートディレクトリを指定
  env: {
    browser: true, // ブラウザのグローバル変数を有効化
    es2022: true, // ES2022のグローバル変数を有効化
    node: true, // Node.jsのグローバル変数を有効化
  },
  extends: [
    "eslint:recommended", // ESLintの推奨ルールを有効化
    "plugin:react/recommended", // Reactの推奨ルールを有効化
    "plugin:@typescript-eslint/recommended", // TypeScriptの推奨ルールを有効化
    "plugin:react-hooks/recommended", // React Hooksのルールを有効化
    "plugin:jsx-a11y/recommended", // アクセシビリティ
    "prettier", // Prettierとの競合を解消
  ],
  overrides: [],
  parser: "@typescript-eslint/parser", // TypeScriptを解析
  parserOptions: {
    ecmaVersion: "latest", // ESのバージョンを自動検出
    sourceType: "module", // importを有効化
  },
  plugins: [
    "sort-keys-custom-order", // オブジェクトのキーをソート
    "react", // Reactのルールを有効化
    "@typescript-eslint", // TypeScriptのルールを有効化
    "simple-import-sort", // importをソート
    "import", // importのルールを有効化
    "unused-imports", // 未使用のimportをエラー
  ],
  rules: {
    "react/jsx-uses-react": "off", // Reactを使用していることを明示
    "react/react-in-jsx-scope": "off", // Reactを使用していることを明示
    "sort-keys-custom-order/object-keys": [
      // オブジェクトのキーをソート
      "error",
      { orderedKeys: ["id", "name", "title"] },
    ],
    "sort-keys-custom-order/type-keys": [
      // オブジェクトのキーをソート
      "error",
      { orderedKeys: ["id", "name", "title"] },
    ],
    "simple-import-sort/imports": "error", // importをソート
    "simple-import-sort/exports": "error", // exportをソート
    "import/first": "error", // importはファイルの先頭
    "import/newline-after-import": "error", // import後に改行
    "import/no-duplicates": "error", // 同じファイルのimportをマージ
    "unused-imports/no-unused-imports": "error", // 未使用のimportをエラー
    "react/prop-types": "off", // prop-typesを無効化
    "no-undef": "error", // 未定義の変数をエラー
    "no-var": "error", // varをエラー
  },
  settings: {
    react: {
      // Reactのバージョンを自動検出
      version: "detect",
    },
  },
};

3.5.5 ESLint と Prettier の実行用スクリプトを用意

ESLint と Prettier を走らせるコマンドを用意しておきます。
package.json の script オブジェクト内に"入力コマンド":"実際に走るコマンド"とすることで登録できます。

pakage.json
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
+   "lint": "run-p --continue-on-error lint:*",
+   "lint:eslint": "eslint --ext .tsx,.ts .",
+   "lint:prettier": "prettier --check .",
+   "fix": "run-s --continue-on-error fix:eslint fix:prettier",
+   "fix:eslint": "eslint --ext .tsx,.ts . --fix",
+   "fix:prettier": "prettier --write ."
  },

内容は以下の通りとなります。
npm run lintなどのコマンドでターミナルから実行できます。

script 実行する内容
lint "lint:eslint"と"lint:prettier"を並列に実行する。その際、スクリプト内でエラースローされても後続のスクリプトを実行する。
lint:eslint 拡張子が.tsx.tsのファイルに対して ESLint のチェックを実行する。
lint:prettier Prettier のチェックを実行する
fix "fix:eslint"と"fix:prettier"を順番に実行する。その際、スクリプト内でエラーをスローされても後続のスクリプトを実行する。
fix:eslint 拡張子が.tsx.tsのファイルに対して ESLint を基準にコードを修正する。
fix:prettier Prettier を基準にコードを整形する。

例えば、npm run fixというようにターミナルから実行すると、自動で修正可能な ESLint のエラーを修正した後に Prettier によるコードの生計を行うことができます。

3.6 補足

この時点でのpackage.jsonを以下に記載しておきます。
よければ参考にしてください。

package.json
package.json
{
  "name": "just-do-it",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "@types/jest": "^27.5.2",
    "@types/node": "^16.18.24",
    "@types/react": "^18.0.37",
    "@types/react-dom": "^18.0.11",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "typescript": "^4.9.5",
    "web-vitals": "^2.1.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "lint": "run-p --continue-on-error lint:*",
    "lint:eslint": "eslint --ext .tsx,.ts .",
    "lint:prettier": "prettier --check .",
    "fix": "run-s --continue-on-error fix:eslint fix:prettier",
    "fix:eslint": "eslint --ext .tsx,.ts . --fix",
    "fix:prettier": "prettier --write ."
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^5.59.0",
    "@typescript-eslint/parser": "^5.59.0",
    "eslint": "^8.39.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-jsx-a11y": "^6.7.1",
    "eslint-plugin-react": "^7.32.2",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-simple-import-sort": "^10.0.0",
    "eslint-plugin-sort-keys-custom-order": "^1.0.5",
    "eslint-plugin-unused-imports": "^2.0.0",
    "npm-run-all": "^4.1.5",
    "prettier": "^2.8.7"
  }
}

3.6 Tailwind CSS

3.6.1 Tailwind CSS とは

Tailwind CSS とは、便利な CSS のクラスがパックされた CSS フレームワークです。
BootStrap みたいなイメージを持っていただくとわかりやすいかもしれません。
Tailwind CSS は JavaScript に依存せず、使用していないクラスは本番用にビルドする時に削除されます。
そのため最終的なバンドルサイズを小さく保つことができます。
以下の公式のトップページがわかりやすく Tailwind CSS の特徴を述べていますのでぜひ見てみてください。

https://tailwindcss.com

3.6.2 インストール

ターミナル
npm install -D tailwindcss

-D--save-devの省略記法です。
上記のコマンドで言えば、
npm install --save-dev tailwindcss
と同じ意味です。

3.6.3  tailwind.config.jsファイルを生成

ターミナル
npx tailwindcss init

プロジェクトの直下にtailwind.config.jsという設定ファイルが生成されていますので、以下の通り追記します。

tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};

3.6.4 index.css に Tailwind ディレクティブを追加

Tailwind CSS を使用可能にするために、index.css に追記します。

src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

3.6.5 prettier-plugin-tailwindcss をインストール

prettier-plugin-tailwindcss をインストールすることによってタグ内に記述した Tailswind CSS のクラス名を自動で並べ替えてくれます。
慣れてくるとどのクラス名がどこにあるのかすぐに探せるようになるため開発効率がグッと上がります。

ターミナル
npm install -D prettier-plugin-tailwindcss

3.6.6 動作確認

試しに、Tailwind CSS がうまく効いているか確認します。

src/App.tsx
function App() {
+  return <div className="text-sky-500">Hello, world!{}</div>;
}

export default App;

className属性にtext-sky-500と指定しています。
これは Tailwind CSS で用意されているクラスで、このクラスが指定された要素には以下のようなプロパティが当たっています。

実際に動作しているCSS
.text-sky-500 {
  --tw-text-opacity: 1;
  color: rgb(14 165 233 / var(--tw-text-opacity));
}

Tailwind で文字色を指定するクラスはtext-色名-数値みたいな感じで用意されています。
具体的には以下のような色が用意されています。
Tailwind CSS のおかげで色について悩みずぎることが減りました。(20 代 男性 会社員)

https://tailwindcss.com/docs/text-color

本題に戻ります。
プロジェクトをnpm startで起動してlocalhost:3000にアクセスしましょう。
以下のような表示になっていれば Tailwind が適用されています。
image.png

3.7 Firebase

Firebaseは Google が提供するモバイルおよび Web アプリケーションのためのクラウドベースのプラットフォームです。
データベースや認証、ストレージその他いろいろな機能が提供されています。
今回はこの Firebase のCloud Firestoreという NoSQL 型のデータベースとAuthenticationという認証サービス、そしてHostingというデプロイに必要なサービスを使用します。

3.7.1 プロジェクトの作成

まずFirebase のトップ画面にアクセスします。
右上のコンソールへ移動をクリックします。
image.png
プロジェクトを作成ボタンをクリックします。
image.png
プロジェクト名(好きなように変えて問題ありません)を入力して続行をクリックします。
image.png
Google アナリティクスはどちらでも良いですが、今回は特に使用する予定がないのでオフにしてプロジェクトを作成をクリックします。
image.png
ローディングアイコンが現れます。
image.png
新しいプロジェクトの準備ができましたとなったら続行をクリックします。
image.png

無事にプロジェクトの作に成功すると下記のような画面に遷移します。
プロジェクト名の横に書いてあるSparkとは今回使用する無料プランの名前です。
それなりに制限がありますが、今回のデモアプリであれば Spark プランで問題無いのでこのまま進めます。
ご自身のアプリの必要に応じて従量課金制の B l aze プランへの切り替えなど検討してください。
image.png

3.7.2 Authentication の設定

先ほどのプロジェクトのホーム画面からAuthenticationをクリックします。
Authentication の画面に遷移したら、始めるをクリックします。
image.png

様々な認証方法が用意されています。
その中にあるGoogleをクリックします。
image.png

プロジェクト公開名は自動で入力されています。
プロジェクトのサポートメールというところから Firebase に登録しているメールアドレスを選択します。
保存をクリックします。
image.png

ログインプロバイダに Google が追加されていれば成功です。
image.png

3.7.2 Cloud Firestore の設定

プロジェクトのトップ画面に戻り、Cloud Firestoreをクリックします。
Cloud Firestore の画面に遷移したら、データベースの作成をクリックします。
image.png

セットアップモーダルが表示されるので、本番環境モードで開始するが選択されていることを確認して次へをクリックします。
image.png

リソースロケーションを選択します。
asia-northeast1(Tokyo)を選択して有効にするをクリック。
image.png

3.7.3  リソースロケーションの設定

プロジェクトのホーム画面に戻り、サイドバーの歯車アイコンをクリックします。
プロジェクトの設定をクリックします。
image.png
デフォルトのGCPリソースロケーションの鉛筆マークをクリックします。
image.png
先ほど設定したロケーションがすでに入力されているので、そのまま保存を押すと下記のように表示されます。
image.png

3.7.4 プロジェクトにから Firebase を呼び出す。

プロジェクトのホーム画面に戻り、今度は画面中央あたりにある</>マークをクリックします。(ホバーすると"Web"と出てきます)
image.png
アプリのニックネームをつけます。
アプリを登録をクリックします。
image.png

:::note
本稿では Firebase Hosting は後で後で設定するので一旦飛ばします。

続いて FirebaseSDK の追加に書いてある説明の通りに進めていきます、
firebase をインストールします。

ターミナル
npm install firebase

VSCode を開いて以下のファイルを新規作成します。
ちなみに firebaseConfig のところには表示されていた情報をそのまま貼り付けて問題ありません。
「え、API キーなんてコードに直接貼り付けていいの?」と思われるかもしれません。
しかし、以下の構成オブジェクトはクライアントサイドに持たせて、どの Firebase プロジェクトにアクセスするか識別させるためのものです。
つまり公開前提の情報であり、この情報がなければ Firebase にアクセスすることができません。
しかしここで注意があります。

この構成要素はこの後に説明するセキュリティルールや Google Cloud Platform(GCP)コンソールでの API 制限などが適切に設定されていないと、悪意のある第 3 者から不正にプロジェクトを利用されたり、情報を読み取られる恐れがあります。
セキュリティルール等の設定を行うまでは Git などに上げないことを強くお勧めします。

src/firebase.ts
import { initializeApp } from "firebase/app";
import { getAuth, GoogleAuthProvider } from "firebase/auth";
import { getFirestore } from "firebase/firestore";

const firebaseConfig = {
  apiKey: "書いてあったapiKey",
  appId: "書いてあったappId",
  authDomain: "書いてあったauthDmain",
  messagingSenderId: "書いてあったmessagingSenderId",
  projectId: "書いてあったprojectId",
  storageBucket: "書いてあったstrageBucket",
};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const provider = new GoogleAuthProvider();
const db = getFirestore(app);

export { auth, db, provider };

3.8 その他に必要なパッケージをインストール

ターミナル
npm install react-router-dom react-icons @tremor/react react-hooks-use-modal
パッケージ名 説明
react-router-dom React Router というルーティングライブラリ
react-icons アイコン
@tremor/react Tailwind CSS ベースの UI コンポーネントライブラリ。今回はチャートをメインに使用
react-hooks-use-modal モーダルを超簡単に実装するための hooks

環境構築は以上です。お疲れ様でした 🎉
次のセクションからいよいよ実装に入っていきます。

4.ページヘッダーを実装

本アプリのページ遷移は主にページヘッダーから行なっていきます。
そのため、まずはページヘッダーを書かないことには始まりません。
まずは index.tsx にルーティングの大元を記述します。

src/index.tsx
import "./index.css";

import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";

import App from "./App";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

上記はルーティングを設定していく App コンポーネントをreact-router-domからインポートしたBrowserRouterコンポーネントでラップしています。

src/App.tsx
import React from "react";
import { Link, Route, Routes } from "react-router-dom";

import { Layout } from "./routes/Layout";

const App = () => {
  return (
    <Routes>
      <Route path="/" element={<Layout />}></Route>
    </Routes>
  );
};

const NoMatch = () => {
  return (
    <div>
      <h2>ページが見つかりませんでした。</h2>
      <p>
        <Link to="/">ホームに戻る</Link>
      </p>
    </div>
  );
}

export default App;

Routesコンポーネントの中に、Routeコンポーネントを記述していくことで、ルーティングを実現していきます。
Routeコンポーネントにはpathelementという 2 つのプロパティを渡す必要があります。
pathには対応させたいパスを記述し、elementには path で指定した URL にアクセスした時に呼び出したいコンポーネントを指定します。
上記では、https://xxx.com/みたいなアプリケーションのルート URL にアクセスした際に Layout コンポーネントが表示されることを示しています。
開発環境では、http://localhost:3000/が対応する URL となります。
続いて Layout コンポーネントを作成していきます。

src/routes/Layout.tsx
import { Outlet } from "react-router-dom";

import { Header } from "../components/Header";

export const Layout = () => {
  return (
    <div>
      <Header />
      <div className="fixed h-full w-full overflow-auto bg-gray-200 pb-52 pt-5  ">
        <Outlet />
      </div>
    </div>
  );
};

今回のアプリではLayoutコンポーネントを常に呼び出し、Layout コンポーネントから他のコンポーネントを呼び出すことでページの切り替えを実現していきます。
ページヘッダーであるHeaderコンポーネントは常に呼び出すため、固定で配置しています。
では、どのようにページを切り替えるかというと、<Outlet/>という記述がポイントです。
先ほどAppコンポーネントで記述したRouterコンポーネントはネストすることが可能です。
その際、複数のルートをネストさせることで、親ルートから子ルートを<Outlet />で呼び出すことができます。リクエストされた URL に応じて子ルートのみ表示が切り替わります。
後ほど具体的に記載していくのでその時に改めて解説します。
続けて Header コンポーネントを作成していきます。

src/components/Header.tsx
import { AiOutlineAreaChart, AiOutlineHome } from "react-icons/ai";
import { BsPencil } from "react-icons/bs";
import { GoSignIn } from "react-icons/go";
import { Link, NavLink } from "react-router-dom";

export const Header = () => {
  return (
    <header className="h-20 select-none">
      <div className="container mx-auto flex h-20 flex-wrap items-center overflow-hidden font-medium lg:justify-center">
        <div className="flex h-full w-full items-center justify-around">
          <nav className="flex w-full justify-around">
            <Link to="/" className="inline-block py-4 md:py-0">
              <span className="p-1 text-xl font-black leading-none text-gray-900">
                JustDoIt
              </span>
            </Link>
            <NavLink
              to={"/"}
              className={({ isActive, isPending }) =>
                isPending || isActive
                  ? "flex items-center text-indigo-600"
                  : "flex items-center hover:text-indigo-400"
              }
            >
              <AiOutlineHome className="inline-block" size={20} />
              <span className="hidden pl-2 md:inline-block">ホーム</span>
            </NavLink>

            <NavLink
              to="/record-study"
              className={({ isActive, isPending }) =>
                isPending || isActive
                  ? "flex items-center text-indigo-600"
                  : "flex items-center hover:text-indigo-400"
              }
            >
              <BsPencil className="inline-block" size={20} />
              <span className="hidden pl-2 md:inline-block">記録</span>
            </NavLink>
            <NavLink
              to="/my-page"
              className={({ isActive, isPending }) =>
                isPending || isActive
                  ? "flex items-center text-indigo-600"
                  : "flex items-center hover:text-indigo-400"
              }
            >
              <AiOutlineAreaChart className="inline-block" size={20} />
              <span className="hidden pl-2 md:inline-block">マイページ</span>
            </NavLink>
            <NavLink
              to="/login"
              className={({ isActive, isPending }) =>
                isPending || isActive
                  ? "flex items-center text-indigo-600"
                  : "flex items-center hover:text-indigo-400"
              }
            >
              <GoSignIn className="inline-block" size={20} />
              <span className="hidden pl-2 md:inline-block">ログイン</span>
            </NavLink>
          </nav>
        </div>
      </div>
    </header>
  );
};

navタグの中にいくつかリンクを作成しています。
React Router によるアプリケーション内のルーティングではaタグではなくLinkコンポーネントを使用します。aタグも使えないわけではありませんが、aタグによるページ遷移ではリクエストされたページを丸ごと再読み込みすることになります。(ブラウザのアドレスバーの横にある読み込みマークが一旦「×」になる状態です)
しかし、切り替えが不要なものは極力再描画させたくありません。そこでLinkコンポーネントによるページの切り替えを行います。
Linkコンポーネントを使用すれば、Route コンポーネントで設定したパスに対応するコンポーネントのみ切り替えを行ってくれるため、ページ全体を再読み込みしません。そのため、基本的にアプリケーション内のページ遷移はLinkコンポーネントを使用します。

Linkの基本構文
<Link to="遷移先のパス">リンク文字列<Link/>

また、ナビゲーションの中で Link と似ているNavLinkコンポーネントを呼び出しています。
このNavLinkコンポーネントはリンクがactive(そのリンクに対応するページに自分がいる)かpending(そのリンクに対して遷移中)かを認識します。
後ほど子ページを実装した際に分かりますが、上記の実装では自分が今いる URL が NavLink のtoに対応している、もしくは遷移中であれば該当のリンク文字列を indigo 色になるよう条件分岐をしています。
isAcriveおよびisPendingという値を NavLink から受け取り、className 属性に渡す文字列を分岐しています。これらの条件に満たないリンクは hover した時だけ薄めの indigo にするようにしています。

ローカルサーバを立ち上げてhttp://localhost:3000にアクセスしてみましょう。
ホームにはto="/"と指定しているため、現在地と認識されて文字色が indigo になっています。
image.png

さて、もう一つヘッダーを実装する上で重要なことを解説します。
ページ幅を小さくするとメニューから文字列が消えてアイコンだけになることにお気づきでしょうか。
このレスポンシブ対応は Tailwind のmd:という記述で実現しています。
実際に該当するコード例は以下の部分です。

レスポンシブ対応
<AiOutlineHome className="inline-block" size={20} />
<span className="hidden pl-2 md:inline-block">ホーム</span>

最初に補足として、AiOutlineHomeというのは React Icons からインポートしたコンポーネントです。このコンポーネントを置くことによりアイコンを表示させています。
本題ですが、span タグの className にhiddenと指定しています。このクラスにはdisplay: none;が割り当てられており、文字列を非表示にしています。
そこでmd:の登場です。このmd:の直後にクラス名を指定すると、画面幅が 768px 以上になった時にそのクラスが適用されます。つまり上記の実装では、デフォルトでは文字列は表示しない(display: none;)が、画面幅が 768px を超えた時に表示(display: inline-block)しています。
他にも画面幅のブレークポイントを貼る便利なクラスやあるので興味がある方は公式ドキュメントを参照してみてください。
image.png

5. ヘッダーのナビゲーションに対応するルーティングを設定

ルーティングの設定をしていくために、ページのコンポーネントを新規作成して仮実装します。

src/routes/Home.tsx
import React from "react";

export const Home = () => {
  return <div>Home</div>;
};
src/routes/Home.tsx
import React from "react";

export const Home = () => {
  return <div>Home</div>;
};
src/routes/RecordStudy.tsx
import React from "react";

export const RecordStudy = () => {
  return <div>RecordStudy</div>;
};

src/routes/MyPage.tsx
import React from "react";

export const MyPage = () => {
  return <div>Mypage</div>;
};
src/routes/SignIn.tsx
import React from "react";

export const SignIn = () => {
  return <div>SignIn</div>;
};

上記は特に何の変哲もない div タグたちです。
続けて App コンポーネントでルーティングを設定します。

App.tsx
import React from "react";
import { Link, Route, Routes } from "react-router-dom";

import { Home } from "./routes/Home";
import { Layout } from "./routes/Layout";
+ import { MyPage } from "./routes/MyPage";
+ import { RecordStudy } from "./routes/RecordStudy";
+ import { SignIn } from "./routes/SignIn";

const App = () => {
  return (
    <Routes>
      <Route path="/" element={<Layout />}>
+        <Route index element={<Home />} />
+        <Route path="record-study" element={<RecordStudy />} />
+        <Route path="my-page" element={<MyPage />} />
+        <Route path="login" element={<SignIn />} />
+        <Route path="*" element={<NoMatch />} />
      </Route>
    </Routes>
  );
};

const NoMatch = () => {
  return (
    <div>
      <h2>ページが見つかりませんでした。</h2>
      <p>
        <Link to="/">ホームに戻る</Link>
      </p>
    </div>
  );
}

export default App;

最初に記述した Layoute コンポーネントを呼び出すの Route にネストさせています。
こうすることによって Layout コンポーネントの中でOutletとしてそれぞれ子ルートのみ切り替えることができます。つまり、Layout は表示されたままなので、Layout の中で固定で呼び出されている Header も表示されたままということです。
基本的には前述で示した Router と同じ書き方なのですが、若干書き方が異なっている箇所について解説します。
1 つ目に、indexです。
親ルートが呼び出された時にデフォルトで呼び出したい子ルートにindexと指定してあげます。
そのため上記のコードでは、'localhost:3000/'にアクセスした時の Home コンポーネントが表示されることになります。

2 つ目にルーティングを設定していない URL を設定した場合の処理です。
例えばlocalhost:3000/sonzaishinaipathのように存在しないパスをリクエストされた場合、ページが存在しないことを示してあげるのが親切です。
そこで、path="*"とすることでルーティングに一致しない URL に対してコンポーネントを割り当てます。
割り当てるコンポーネントに、前述のNoMatchコンポーネントを割り当てることで ページがないことを伝えています。

動作確認をします。
ヘッダーのナビゲーションをクリックすると各ページに遷移します。
また URL が変化するたびにヘッダーの文字列色が変化していることにも着目してください。
image.png

6. 各コンポーネントをハードコーディング

続いて各ページの UI を固めていきましょう。

6.1 今回よく使う型を定義

各コンポーネントで毎回型を定義してもよいのですが、それだと少し面倒なのでよく使う型だけ型定義ファイルにまとめて使いまわせるようにします。

src/types/types.ts
export type StudyLog = {
  id: string;
  title: string;
  author: {
    id: string;
    photoUrl: string;
    username: string;
  };
  createdAt: string;
  detail: string;
  hour: string;
  minute: string;
  time: number;
};

上記で定義した型は主に Firebase からデータを取得する時に使います。

6.3 Tailwind CSS を上書き

index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
+ /* number型のinput要素のスピンボタンを非表示 */
+ @layer base {
+   input[type="number"]::-webkit-outer-spin-button,
+   input[type="number"]::-webkit-inner-spin-button,
+   input[type="number"] {
+     -webkit-appearance: none;
+     margin: 0;
+     -moz-appearance: textfield !important;
+   }
+ }
+
+ /* スクロールバーを非表示にする */
+ @layer utilities {
+   .hidden-scrollbar {
+     -ms-overflow-style: none; /* IE, Edge 対応 */
+     scrollbar-width: none; /* Firefox 対応 */
+   }
+   .hidden-scrollbar::-webkit-scrollbar {
+     /* Chrome, Safari 対応 */
+     display: none;
+   }
+ }

この後実装するフォームの input 要素でtype="number"を使用するのですが、その時のスピンボタンを非表示にする設定と、スクロールバーを非表示にするクラスを用意します。

上記は次の 2 つの記事を参考にさせていただきました。🙏
詳細は記事で説明されているので読んでみてください。

6.2 仮のデータで UI の構築

ホーム画面で表示する投稿一つ一つを表示させる Card コンポーネントを作成します。
具体的にはこんな形のカードを作っています。
image.png
※まだ Home コンポーネントで呼び出していないので表示されません。

src/components/Card.tsx
import React, { FC } from "react";
import { useModal } from "react-hooks-use-modal";
import { AiOutlineClose } from "react-icons/ai";
import { BsBook } from "react-icons/bs";

import { StudyLog } from "../types/types";

type CardProps = {
  cardInfo: StudyLog;
};

export const Card: FC<CardProps> = ({ cardInfo }) => {
  const [Modal, open, close] = useModal("root", {});
  return (
    <>
      <article className="md:w-100 h-52 w-96 p-3">
        <div className="flex h-full w-full rounded-lg bg-white p-3  shadow-md">
          <div className="flex w-1/5 flex-col items-center justify-between">
            <div className="flex h-2/5 items-center justify-center">
              <div className="items-between flex h-12 w-12 justify-center overflow-hidden">
                <img
                  className=" h-full  w-full rounded-full border-[1px]"
                  alt="アイコン"
                  src={cardInfo.author.photoUrl}
                />
              </div>
            </div>
            <button className="h-2/5" onClick={open}>
              <BsBook className="text-sky-500" size={"2rem"} />
            </button>
            <div className="h-1/5"></div>
          </div>
          <div className="w-full divide-y divide-sky-200 pl-2">
            <div className="flex h-2/5 items-center px-2">
              <h3 className="text-xl text-gray-600">{cardInfo.title}</h3>
            </div>
            <div className="flex h-2/5 items-center px-2">
              <p className=" text-3xl text-sky-500">
                {cardInfo.hour}
                <span className="px-1 text-xl">時間</span>
                {cardInfo.minute}
                <span className="px-1 text-xl"></span>
              </p>
            </div>
            <div className="h-1/5 px-2">
              <p className="flex h-full items-center  text-gray-400">
                {cardInfo.author.username}
              </p>
            </div>
          </div>
        </div>
      </article>
      <Modal>
        <div className=" h-[36rem] w-screen  rounded-2xl bg-white p-10 md:w-[50rem]">
          <div className="h-full w-full divide-y divide-sky-200 overflow-auto">
            <h2 className="py-2 text-center text-3xl">{cardInfo.title}</h2>
            <p className="py-2 text-center text-3xl text-sky-500">
              {cardInfo.hour}時間{cardInfo.minute}</p>
            {cardInfo.detail && <p className="py-2">{cardInfo.detail}</p>}
            <div className="relative flex items-center py-2">
              <p className="w-full py-2 text-center text-gray-600">
                {cardInfo.author.username}
              </p>
              <button
                className="absolute right-3 z-10 flex items-center rounded-lg border border-sky-800 p-1 text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white active:bg-sky-800"
                onClick={close}
              >
                <span>閉じる</span>
                <AiOutlineClose className="text-xl" />
              </button>
            </div>
          </div>
        </div>
      </Modal>
    </>
  );
};

はじめに先ほどtypes.tsで定義した StudyLog 型を import します。
CardProps という名前でこのコンポーネントので受け取る引数の型を定義してあげます。
今回は StudyLog 型だけあればいいので一旦 cardInfo という名前で StudyLog を受け取ります。
コンポーネント(Card)の型に FCを指定し、props で cardInfo を受け取ります。

この Card コンポーネントには大きく分けて 2 つの部品があります。
1 つ目がarticleタグ内に書いたカードそのものです。直前のキャプチャが article タグで書いたものです。
単純に渡された StudyLog 型のデータからプロパティを指定して読み込んでいます。

2 つ目がモーダルです。これは環境構築時にインストールしたreact-hooks-use-modalというモーダルを簡単に実装できる hooks ライブラリです。
この Modal コンポーネント内に記述した内容がモーダルの本体として表示されます。
使い方は、はじめにuseModalというフックを呼び出します。この時、第一引数に"root"を指定し、第二引数にオプションを記述するオブジェクトを渡します。今回は全てデフォルトのオプションにするので空オブジェクトを渡します。
useModalからはModalopencloseisOpenの 4 つ値が入った配列が返されます。
それぞれの概要は以下のとおりです。

要素 概要
Modal コンポーネント。モーダルとして表示させたい要素をラップする。
open モーダルを開く関数
close モーダルを閉じる関数
isOpen モーダルが開いているか閉じているかの論理値。今回はこの変数を直接使用しないため記述を省略しています。

オプションなど詳細については公式リポジトリをご参照ください。

続いてHomeコンポーネントの UI を構築します。

src/routes/Home.tsx
import React, { useState } from "react";

import { Card } from "../components/Card";
import { StudyLog } from "../types/types";

const dummyData: StudyLog[] = [];
for (let i = 0; i < 100; i++) {
  dummyData.push({
    id: i.toString(),
    title: `第${i}回React勉強会`,
    author: {
      id: i.toString(),
      photoUrl: "https://via.placeholder.com/150/92c952",
      username: "takumi",
    },
    createdAt: new Date().toString(),
    detail: "Reactの勉強をしました",
    hour: "1",
    minute: "30",

    time: 90,
  });
}

export const Home = () => {
  const [studyLog, setStudyLog] = useState<StudyLog[]>(dummyData);
  return (
    <div className="hidden-scrollbar flex w-screen justify-center overflow-hidden">
      <div className="hidden-scrollbar container flex h-full flex-col items-center overflow-scroll pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
        <div className="container flex flex-col items-center pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
          {studyLog.map((card) => (
            <Card key={card.id} cardInfo={card} />
          ))}
        </div>
      </div>
    </div>
  );
};

image.png

image.png
はじめに dummyData という変数名で StudyLog 配列型のダミーを用意しています。
後に Firebase から値を取得する際も同様のデータ形式に変換します。

ダミーデータは所詮一時的なものなのでこの辺にして、Home コンポーネントの中身について解説します。
コンポーネント一番上でuseState()を使用してステート(状態)を定義しています。
useState は React の Hooks(複雑な処理を裏側に任せることができる関数のようなものだと思ってください)の 1 つです。構文は以下のとおりです。

useStateの構文
const [状態変数, 更新関数] = useState(初期状態);

useState は 0 番目に状態変数、1 番目に状態変数を更新するための関数を戻します。その値を配列に分割代入して定義します。
useState の引数には初期値を渡します。上記では一旦初期値をダミーデータにしています。
また useState で管理する状態変数の型ですが、型推論(useState("")と初期値に文字列を渡すとその状態変数は string 型とみなされる)が使える時は型推論に任せてしまっても構いませんし、Home コンポーネントのようにuseState<型>()とジェネリクスに型を渡しても宣言可能です。
注意点として useState はコンポーネントの最上部で宣言する必要があります。

続いて MyPage のを UI を作成していきます。

src/routes/MyPage.tsx
import { AreaChart, Metric } from "@tremor/react";
import { useState } from "react";

type Log = {
  date: string;
  "": number;
};

const timeFormatter = (time: number) => {
  return `${Math.floor(time / 60)}時間${time % 60}分`;
};

const dateFormatter = (date: Date) => {
  return `${date.getMonth() + 1}/${date.getDate()}`;
};

const dummyData: Log[] = [];
for (let i = 0; i < 30; i++) {
  dummyData.push({
    date: dateFormatter(new Date(2022, 8, i)),
    "": Math.floor(Math.random() * 100),
  });
}

export const MyPage = () => {
  const [chartData, setChartData] = useState<Log[] | null>(dummyData);

  return (
    <div className="hidden-scrollbar  flex w-screen justify-center overflow-hidden">
      <div className="hidden-scrollbar container flex flex-col items-center overflow-scroll rounded-3xl bg-white pt-4 drop-shadow-md md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
        <div className=" container flex h-80 flex-col items-center pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
          <Metric className=" text-gray-600">学習チャート</Metric>
          {chartData && (
            <AreaChart
              className="h-72 px-5 py-10"
              data={chartData}
              index={"date"}
              categories={[""]}
              colors={["indigo"]}
              valueFormatter={timeFormatter}
              showLegend={false}
              showTooltip={true}
              yAxisWidth={80}
            ></AreaChart>
          )}
        </div>
      </div>
    </div>
  );
};

image.png
ここでもダミーデータを作成してから UI を構築しています。
同様にダミーデータは後で消すので深く考えなくて OK です。
ようやくtremorという Trailwind CSS 製の UI コンポーネントライブラリの登場です。
上記の画像のようにおしゃれないい感じのエリアチャートを描画してくれます。
ざっくりと使い方を解説します。上記でチャートに使用しているコンポーネントはAreaChartです。
プロパティをいくつか指定するできます。上記で設定しているプロパティは以下のとおりです。

使用したプロパティ やっていること
data={chartData} データを指定します。コンポーネントの最上部で定義した chartData というオブジェクトの配列を渡しています。それぞれのオブジェクトには x 軸と y 軸に対応させる値が必要です。
index={"date"} x 軸に使用するデータのキーを指定します。
categories={[""]} y 軸に使用するデータのキーを指定します。これはチャートをホバーするとでてくるツールチップにも文字列としてでてくるのですが、キーをtimeみたいな文字列にするとなぜか1時間30分timeと表記されてしまいうまくいかなかったので渋々から文字列をキーとして定義しています。本当はあまり良くないので他に方法があったら修正してください。
colors={["indigo"]} チャートの色を指定します。
valueFormatter={timeFormatter} データ文字列のフォーマッタを指定します。ただの数値として入ってきた学習時間の情報をh時間M分という形式の文字列に変換するように定義した関数を渡しています。
showLegend={false} 凡例を非表示にしています。
showTooltip={true} ツールチップを非表示にします
yAxisWidth={80} y 軸のラベルの幅を指定しています。

続いて投稿画面の UI を記述していきます。

src/routes/RecordStudy.tsx
import { Metric } from "@tremor/react";
import React, { ChangeEventHandler, useCallback, useState } from "react";

export const RecordStudy = () => {
  const [title, setTitle] = useState("");
  const [detail, setDetail] = useState("");
  const [hour, setHour] = useState("");
  const [minute, setMinute] = useState("");
  const [time, setTime] = useState(0);

  const handleHour: ChangeEventHandler<HTMLInputElement> = useCallback(
    (e) => {
      if (
        parseInt(e.target.value) > parseInt(e.target.max) ||
        e.target.value.length > 2
      ) {
        setHour("");
        setTime(parseInt(minute));
        return;
      }
      setHour(e.target.value.trim());
      setTime(
        parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim()) *
          60 +
          parseInt(minute === "" ? "0" : minute)
      );
    },
    [minute]
  );

  const handleMinute: ChangeEventHandler<HTMLInputElement> = useCallback(
    (e) => {
      if (
        parseInt(e.target.value) > parseInt(e.target.max) ||
        e.target.value.length > 2
      ) {
        setMinute("");
        setTime(parseInt(hour) * 60);
        return;
      }
      setMinute(e.target.value.trim());
      setTime(
        parseInt(hour === "" ? "0" : hour) * 60 +
          parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim())
      );
    },
    [hour]
  );

  return (
    <div className="flex w-full justify-center">
      <form className="w-full rounded-3xl bg-white p-5 drop-shadow-md md:w-[50rem]">
        <div className="mb-6">
          <Metric className=" text-center text-gray-600">学習を記録</Metric>
          <label
            htmlFor="title"
            className=" mb-2 block text-sm font-medium text-gray-900"
          >
            学習内容
          </label>
          <input
            id="title"
            type="text"
            className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
            placeholder="例)数学A 問題集"
            required
            onChange={(e) => setTitle(e.target.value)}
            value={title}
          />
        </div>

        <div className="mb-6">
          <label
            htmlFor="hour"
            className="mb-2 block text-sm font-medium text-gray-900"
          >
            学習時間
            <span className="px-1 text-gray-500">(時間:分)</span>
          </label>
          <div className="block h-10 w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 text-sm text-gray-900 shadow-sm">
            <div className="flex h-full w-full items-center">
              <input
                type="number"
                inputMode="numeric"
                id="hour"
                min="0"
                max="24"
                placeholder="00"
                className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
                onChange={handleHour}
                value={hour}
              />
              <span className="px-1">:</span>
              <input
                type="number"
                inputMode="numeric"
                id="minute"
                min="0"
                max="99"
                placeholder="00"
                className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
                onChange={handleMinute}
                value={minute}
              />
            </div>
          </div>
        </div>

        <div className="mb-6 ">
          <label
            htmlFor="detail"
            className="mb-2 block text-sm font-medium text-gray-900"
          >
            メモ
          </label>
          <textarea
            id="detail"
            className="block h-40 w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
            onChange={(e) => setDetail(e.target.value)}
            value={detail}
          ></textarea>
        </div>
        <div className="flex w-full justify-center py-5">
          <button
            type="submit"
            className="w-full rounded-lg border border-sky-800 px-10 py-2.5 text-center text-sm font-medium text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white active:bg-sky-800"
          >
            記録する
          </button>
        </div>
      </form>
    </div>
  );
};

image.png
input 要素に記述された情報は useState を使用して状態を管理します。そのため、各要素に対応するステートを定義します。
titledetailについては見ての通りただの文字列の状態を管理しているだけなので問題ないかと思うのですが、hour,minute,timeという 3 つのステートで記録時間を管理しているところを不思議に思われた方もいるかもしれません。
hour は時間を管理し、minut は分、time はその両方を合わせた分で管理するようにしています。
理由としては例えばユーザーによってはで学習時間を測っていて 90 分として記録したいとします。しかしホーム画面では時間の表記を統一したいため、90分と記録されてもホーム画面では1時間30分と表示させたいです。
そこで、別途timeという状態変数を用意し、hourminuteを合計した分単位の値として保持します。

続いて、hourminuteの状態を更新する関数handleHourhandleMinuteについて説明します。処理自体はほぼ一緒なので handleHour のみ解説します。
まず、handleHour のメインの処理についてです。
eという引数を受け取っていますが、これは input 要素に変更を加えた(つまり、文字を入力・削除した)ときのイベントを受け取っています。関数の代入先にはChangeEventHandler<HTMLInputElement>という型を指定してください。
以降の処理をざっくりとコメントで説明します。

handleHourの処理
    (e) => {
      if (
        // もしinputに入力された値が、inputタグに設定したmax以上の数値
        parseInt(e.target.value) > parseInt(e.target.max) ||
        // もしくは3桁以上入力されたら
        e.target.value.length > 2
      ) {
        // input欄をリセットして
        setHour("");
        // timeにminuteのみ代入
        setTime(parseInt(minute));
        // 以降の処理を行わずに関数を終了
        return;
      }
      // hourをinput欄の値で更新
      setHour(e.target.value.trim());
      // 時単位の値を分単位に修正して時分を合計してtimeに代入
      // その際、input欄が空の場合は0に変換する
      setTime(
        parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim()) * 60 +
        parseInt(minute === "" ? "0" : minute)
      );
    },

さて、上記の処理はuseCallbackという React のフックを使って記述しています。
hour の input 欄が変更された時にこの関数を実行するだけでなく、minute の input 欄が変更された時にもこの関数を実行します。第二引数の配列は依存配列と呼ばれ、この配列に指定した変数が変更された時にのみ関数を実行するようになります。
個人的に useCallback を活かしきれていない気がするのと、handleHour と handleMinute の処理に重複が多く冗長な記述になっているため時間がある時にリファクタしたい...。
useCallbak とは React のフックの一つで、関数をメモ化するためのものです。メモ化とは、関数の引数と戻り値をキャッシュしておき、同じ引数で呼び出された場合はキャッシュされた戻り値を返すことで、関数の実行を高速化する手法です。
公式ドキュメントで詳細を確認することをおすすめします。

続いてサインイン画面の UI を実装します。

src/routes/SignIn.tsx
import { FcGoogle } from "react-icons/fc";

export const SignIn = () => {
  return (
    <div className=" flex w-screen justify-center  p-5 ">
      <div className="h-96 w-full max-w-xl rounded-2xl bg-white p-5 px-10">
        <h2 className=" pb-4 text-center text-2xl text-gray-600">
          ログイン方法を選択
        </h2>
        <ul>
          <li className="flex justify-center">
            <button className="flex  items-center rounded-lg border border-sky-800 px-10 py-2.5 text-center text-lg  font-medium text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white  active:bg-sky-800">
              <FcGoogle className="mr-1" />
              Googleアカウント
            </button>
          </li>
        </ul>
      </div>
    </div>
  );
};

image.png

特別なことはしていないので一旦解説は飛ばします。
ちなみにソースコードでコンポーネント名はSignInとしているのに表示と URL はlogin及びログインとしているのは個人的な好みです。
新規登録ロジックを実装したくなった時、Registration, login, logout よりも SignUp, SignIn, SignOut の方がしっくりくるということ、そしてユーザー的にはログインという言葉の方が馴染みがありそうなのでこのような実装にしています。

7 ログインを実装

7.1 ログイン状態で表示切り替え

現状だとログインしてもしなくても常に同じ UI が返されます。
ログインしたのにログインボタンが表示されたり、ログアウトしているのに投稿画面が表示されては困りますから、
ログインの実装とログイン状態による UI の切り替えを実装しましょう。
まずは App コンポーネントで認証情報の取得とログイン状態を定義しましょう。

src/App.tsx
+ import type { User } from "@firebase/auth";
+ import { getAuth, onAuthStateChanged } from "firebase/auth";
+ import React, { useEffect, useState } from "react";
import { Link, Route, Routes } from "react-router-dom";

import { Home } from "./routes/Home";
import { Layout } from "./routes/Layout";
import { MyPage } from "./routes/MyPage";
import { RecordStudy } from "./routes/RecordStudy";
import { SignIn } from "./routes/SignIn";

const App = () => {
+ const [user, setUser] = useState<User | null>(null);

+ useEffect(() => {
+   return onAuthStateChanged(getAuth(), (user: User | null) => {
+     setUser(user);
+   });
+ }, []);
  return (
    <Routes>
+     <Route path="/" element={<Layout user={user} />}>
        <Route index element={<Home />} />
+       <Route path="my-page" element={<MyPage user={user} />} />
+       <Route path="record-study" element={<RecordStudy user={user} />}></Route>
+       <Route path="/login" element={<SignIn user={user} />}></Route>
        <Route path="*" element={<NoMatch />} />
      </Route>
    </Routes>
  );
};

const NoMatch = () => {
  return (
    <div>
      <h2>ページが見つかりませんでした。</h2>
      <p>
        <Link to="/">ホームに戻る</Link>
      </p>
    </div>
  );
}

export default App;

最初にuseEffectという React のフックで、コンポーネントのレンダリング後に実行される関数を第一引数として定義しています。また、第 2 引数は依存配列で、ここに指定した変数が変更された時に第一引数の関数が実行されます。ここでは、レンダリング後に 1 度だけ実行されれば良いため、空配列を指定しています。

onAuthStateChangedは Firebase の認証情報が変更された時に実行される関数です。第一引数にはgetAuth()で取得した認証情報を渡し、第二引数には認証情報が変更された時に実行される関数を渡します。ここでは、認証情報が変更された時にsetUser関数を実行しています。これだけで認証情報がリッスンされるのめちゃくちゃ便利ですよね。すご。

そして user として定義したステートを各コンポーネントに props として渡しています。これで各コンポーネントでログイン状態を判定できるようになりました。

続いて SignIn コンポーネントを仕上げていきます。

src/routes/SignIn.tsx
+ import type { User } from "@firebase/auth";
+ import { signInWithPopup } from "firebase/auth";
+ import React, { FC, useEffect } from "react";
  import { FcGoogle } from "react-icons/fc";
+ import { useNavigate } from "react-router-dom";
+
+ import { auth, provider } from "../firebase";
+
+ type SignInProps = {
+   user: User | null;
+ };
+
+ export const SignIn: FC<SignInProps> = ({ user }) => {
+   const navigate = useNavigate();
+   const loginWithGoogle = () => {
+     signInWithPopup(auth, provider).then(() => {
+       navigate("/");
+     });
+   };
+
+   useEffect(() => {
+     if (user) {
+       navigate("/");
+     }
+   }, [user, navigate]);

  return (
    <div className=" flex w-screen justify-center  p-5 ">
      <div className="h-96 w-full max-w-xl rounded-2xl bg-white p-5 px-10">
        <h2 className=" pb-4 text-center text-2xl text-gray-600">ログイン方法を選択</h2>
        <ul>
          <li className="flex justify-center">
            <button
              className="flex  items-center rounded-lg border border-sky-800 px-10 py-2.5 text-center text-lg  font-medium text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white  active:bg-sky-800"
+              onClick={loginWithGoogle}
            >
              <FcGoogle className="mr-1" />
              Googleアカウント
            </button>
          </li>
        </ul>
      </div>
    </div>
  );
};

はじめに、App コンポーネントから渡された user を受け取るために型を定義します。
user というプロパティに Firebase で定義されている User 型を割り当てます。ログアウトしている状態では null なので null を許容しています。
続いて、SignIn コンポーネントに FC 型を指定し、ジェネリクスに型情報を渡してあげます。そうすることでコンポーネントの引数で user を受け取れるようになります。

続いてコンポーネントのトップでuseNavigate()という React ReactRouter からインポートしたフックを navigate という関数に格納しています。

次にログイン処理を行う関数loginWidhGoogleを宣言しています。
関数ないでは Firebase で用意されている SignInWithPopup というポップアップにより Google アカウントを選択するポップアップを出力しします。
第 1 引数には firebase.ts で getAuth()関数によって取得した auth を渡します。
第 2 引数には firebase.ts で GoogleAuthProvider インスタンスが格納した provider を渡します。
認証完了後の処理として、then()メソッドを呼び出し、その中で navigate 関数を呼び出しています。これでログイン後にホーム画面にリダイレクトされるようになりました。

認証処理自体はこれで完了なのですが、ログイン済みなのにも関わらず、手打ちでログイン/loginをリクエストした際にログイン画面が表示されっぱなしなのは好ましくありません。
そこで、useEffect フックを使ってログイン状態をリッスンし、ログイン済みならばホーム画面にリダイレクトする処理を実装します。
依存配列には user と navigate を渡しています。これで user と navigate の状態が変更された時に useEffect が実行されるようになります。(そのようなケースは考えにくいですが念の為ぐらいな感じです。)

続いて同じように Layout コンポーネントを仕上げます。

src/routes/Layout.tsx
+ import type { User } from "@firebase/auth";
+ import React, { FC } from "react";
import { Outlet } from "react-router-dom";

import { Header } from "../components/Header";

+ type layoutProps = {
+   user: User | null;
+ };

+ export const Layout: FC<layoutProps> = ({ user }) => {
  return (
    <div>
+     <Header user={user} />
      <div className="fixed h-full w-full overflow-auto bg-gray-200 pb-52 pt-5  ">
        <Outlet />
      </div>
    </div>
  );
};

処理としては SignIn コンポーネントと同じですので解説はスキップします。

続いて Header コンポーネントです。ここは認証状態によって UI を変更するので条件分岐が必要です。

src/components/Header.tsx
+ import type { User } from "@firebase/auth";
+ import { signOut } from "firebase/auth";
+ import React, { FC } from "react";
import { AiOutlineAreaChart, AiOutlineHome } from "react-icons/ai";
import { BsPencil } from "react-icons/bs";
import { GoSignIn, GoSignOut } from "react-icons/go";
import { Link, NavLink, useNavigate } from "react-router-dom";

+ import { auth } from "../firebase";
+
+ type HeaderProps = {
+   user: User | null;
+ };
+
+ export const Header: FC<HeaderProps> = ({ user }) => {
+   const navigate = useNavigate();
+   const signOutGoogle = () => {
+     signOut(auth).then(() => {
+       navigate("/");
+     });
+   };

  return (
    <header className="h-20 select-none">
      <div className="container mx-auto flex h-20 flex-wrap items-center overflow-hidden font-medium lg:justify-center">
        <div className="flex h-full w-full items-center justify-around">
          <nav className="flex w-full justify-around">
            <Link to="/" className="inline-block py-4 md:py-0">
              <span className="p-1 text-xl font-black leading-none text-gray-900">JustDoIt</span>
            </Link>
            <NavLink
              to={"/"}
              className={({ isActive, isPending }) =>
                isPending || isActive
                  ? "flex items-center text-indigo-600"
                  : "flex items-center hover:text-indigo-400"
              }
            >
              <AiOutlineHome className="inline-block" size={20} />
              <span className="hidden pl-2 md:inline-block">ホーム</span>
            </NavLink>
+           {user ? (
+             <>
                <NavLink
                  to="/record-study"
                  className={({ isActive, isPending }) =>
                    isPending || isActive
                      ? "flex items-center text-indigo-600"
                      : "flex items-center hover:text-indigo-400"
                  }
                >
                  <BsPencil className="inline-block" size={20} />
                  <span className="hidden pl-2 md:inline-block">記録</span>
                </NavLink>
                <NavLink
                  to="/my-page"
                  className={({ isActive, isPending }) =>
                    isPending || isActive
                      ? "flex items-center text-indigo-600"
                      : "flex items-center hover:text-indigo-400"
                  }
                >
                  <AiOutlineAreaChart className="inline-block" size={20} />
                  <span className="hidden pl-2 md:inline-block">マイページ</span>
                </NavLink>
+             </>
+           ) : null}
+           {user ? (
+             <button onClick={signOutGoogle} className="flex items-center hover:text-indigo-400">
+               <GoSignOut className="inline-block" size={20} />
+               <span className="hidden pl-2 md:inline-block">ログアウト</span>
+             </button>
+           ) : (
              <NavLink
                to="/login"
                className={({ isActive, isPending }) =>
                  isPending || isActive
                    ? "flex items-center text-indigo-600"
                    : "flex items-center hover:text-indigo-400"
                }
              >
                <GoSignIn className="inline-block" size={20} />
                <span className="hidden pl-2 md:inline-block">ログイン</span>
              </NavLink>
+           )}
          </nav>
        </div>
      </div>
    </header>
  );
};

分岐は三項演算子で{{user ? (ログイン時の要素) : null}}のように記述しています。
つまり、ログイン状態の時のみ表示する要素は記録マイページログアウトの三つです。
またログインに関しては、ログアウト状態の時のみ表示するようにしています。
ちなみに<></>は React の Fragment というもので、これを使うことで不要な<div>タグを生成せずに複数の要素を返すことができます。

同様に登録画面とマイページも認証されていないユーザーには、ログインが必要である旨を条件分岐で表示させます。
処理自体は特に新しいことをしていないので解説は省きます。

src/routes/RecordStudy.tsx
+ import type { User } from "@firebase/auth";
import { Metric } from "@tremor/react";
import React, { ChangeEventHandler, FC, useCallback, useState } from "react";

+ type RecordStudyProps = {
+   user: User | null;
+ };

+ export const RecordStudy: FC<RecordStudyProps> = ({ user }) => {
  const [title, setTitle] = useState("");
  const [detail, setDetail] = useState("");
  const [hour, setHour] = useState("");
  const [minute, setMinute] = useState("");
  const [time, setTime] = useState(0);

  const handleHour: ChangeEventHandler<HTMLInputElement> = useCallback(
    (e) => {
      if (parseInt(e.target.value) > parseInt(e.target.max) || e.target.value.length > 2) {
        setHour("");
        setTime(parseInt("0") * 60 + parseInt(minute));
        return;
      }
      setHour(e.target.value.trim());
      setTime(
        parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim()) * 60 +
          parseInt(minute === "" ? "0" : minute)
      );
    },
    [minute]
  );

  const handleMinute: ChangeEventHandler<HTMLInputElement> = useCallback(
    (e) => {
      if (parseInt(e.target.value) > parseInt(e.target.max) || e.target.value.length > 2) {
        setMinute("");
        setTime(parseInt(hour) * 60 + parseInt("0"));
        return;
      }
      setMinute(e.target.value.trim());
      setTime(
        parseInt(hour === "" ? "0" : hour) * 60 +
          parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim())
      );
    },
    [hour]
  );
+ if (!user) {
+   return <div>学習を記録するにはログインが必要です。</div>;
+ }
  return (
    <div className="flex w-full justify-center">
      <form className="w-full rounded-3xl bg-white p-5 drop-shadow-md md:w-[50rem]">
        <div className="mb-6">
          <Metric className=" text-center text-gray-600">学習を記録</Metric>
          <label htmlFor="title" className=" mb-2 block text-sm font-medium text-gray-900">
            学習内容
          </label>
          <input
            id="title"
            type="text"
            className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
            placeholder="例)数学A 問題集"
            required
            onChange={(e) => setTitle(e.target.value)}
            value={title}
          />
        </div>

        <div className="mb-6">
          <label htmlFor="hour" className="mb-2 block text-sm font-medium text-gray-900">
            学習時間
            <span className="px-1 text-gray-500">(時間:分)</span>
          </label>
          <div className="block h-10 w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 text-sm text-gray-900 shadow-sm">
            <div className="flex h-full w-full items-center">
              <input
                type="number"
                inputMode="numeric"
                id="hour"
                min="0"
                max="24"
                placeholder="00"
                className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
                onChange={handleHour}
                value={hour}
              />
              <span className="px-1">:</span>
              <input
                type="number"
                inputMode="numeric"
                id="minute"
                min="0"
                max="99"
                placeholder="00"
                className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
                onChange={handleMinute}
                value={minute}
              />
            </div>
          </div>
        </div>

        <div className="mb-6 ">
          <label htmlFor="detail" className="mb-2 block text-sm font-medium text-gray-900">
            メモ
          </label>
          <textarea
            id="detail"
            className="block h-40 w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
            onChange={(e) => setDetail(e.target.value)}
            value={detail}
          ></textarea>
        </div>
        <div className="flex w-full justify-center py-5">
          <button
            type="submit"
            className="w-full rounded-lg border border-sky-800 px-10 py-2.5 text-center text-sm font-medium text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white active:bg-sky-800"
          >
            記録する
          </button>
        </div>
      </form>
    </div>
  );
};
src/routes/MyPage.tsx
+ import type { User } from "@firebase/auth";
import { AreaChart, Metric } from "@tremor/react";
+ import React, { FC, useState } from "react";

+ type MyPageProps = {
+   user: User | null;
+ };

type Log = {
  date: string;
  "": number;
};

const timeFormatter = (time: number) => {
  return `${Math.floor(time / 60)}時間${time % 60}分`;
};

const dateFormatter = (date: Date) => {
  return `${date.getMonth() + 1}/${date.getDate()}`;
};

const dummyData: Log[] = [];
for (let i = 0; i < 30; i++) {
  dummyData.push({
    date: dateFormatter(new Date(2022, 8, i)),
    "": Math.floor(Math.random() * 100),
  });
}

+ export const MyPage: FC<MyPageProps> = ({ user }) => {
  const [chartData, setChartData] = useState<Log[] | null>(dummyData);

+ if (!user) {
+   return <div>マイページを確認するためにはログインが必要です。</div>;
+ }
  return (
    <div className="hidden-scrollbar  flex w-screen justify-center overflow-hidden">
      <div className="hidden-scrollbar container flex flex-col items-center overflow-scroll rounded-3xl bg-white pt-4 drop-shadow-md md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
        <div className=" container flex h-80 flex-col items-center pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
          <Metric className=" text-gray-600">学習チャート</Metric>
          {chartData && (
            <AreaChart
              className="h-72 px-5 py-10"
              data={chartData}
              index={"date"}
              categories={[""]}
              colors={["indigo"]}
              valueFormatter={timeFormatter}
              showLegend={false}
              showTooltip={true}
              yAxisWidth={80}
            ></AreaChart>
          )}
        </div>
      </div>
    </div>
  );
};

上記の処理に追記により、例えばログインしていない状態で/my-pageに手打ちでアクセスすると以下のような表示になります。

image.png

8 学習記録機能の実装

8.1 Firestore にコレクションを追加

ここからはいよいよ Firestore に学習記録を保存する機能を実装していきます。

Firestore は NoSQL のデータベースです。NoSQL のデータベースは RDB とは異なり、テーブルのような概念がなく、コレクションとドキュメントという概念でデータを管理します。
少しクセがあるのですが、Windows のエクスプローラーのようにフォルダのような構造でデータを管理していると思えばわかりやすいかもしれないです。
以下の公式ドキュメントにイラストがあるので参考にして見てください。

Firebase コンソールから、プロジェクトの Firestore を開き、コレクションを追加ボタンをクリックします。
image.png

学習記録を保存するstudylogという ID で入力し、次へをクリック
image.png

ドキュメント ID は自動生成にしておきます。
自動IDボタンをクリック
image.png

保存ボタンをクリック
image.png

studylogコレクションが作成されていることを確認します。
空のドキュメントは不要なので、先ほど作成したドキュメント ID 文字列の横にある 3 点リーダーをクリックし、ドキュメントを削除をクリック

誤ってコレクションを削除してしまった場合は再度作成していただければ問題ありません。

image.png

8.2 セキュリティルールの更新

続いて、セキュリティルールを更新します。
ルールタブをクリックしすると、以下のようなルールが記述されていることが確認できます。
これは全てのドキュメントに対して、誰でも読み取りはできるが書き込みはできないという設定になっています。
image.png

上記のままでは書き込みができませんので以下のように修正します。
修正したら公開ボタンをクリックして保存します。

ルール
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
-  match /{document=**} {
-      allow read, write: if true;
+   match /studylog/{docs} {
+     allow read;
+     allow create: if request.auth != null &&
+     request.resource.data.author.id is string &&
+     request.resource.data.author.id == request.auth.uid &&
+     request.resource.data.author.username is string &&
+     request.resource.data.author.photoUrl is string &&
+     request.resource.data.createdAt is timestamp &&
+     request.resource.data.detail is string &&
+     request.resource.data.hour is string &&
+     request.resource.data.minute is string &&
+     request.resource.data.time is number;
+     allow delete: if resource.data.author.id == request.auth.uid;
    }
  }
}

match /studylog/{docs} {は先ほど作成した studylog コレクションに対してのルールを記述しています。
allow read;は誰でも読み取りができることを意味しています。
allow create: if 条件 はドキュメントの作成する際のルールを記述しています。
request.resource.data.xxxはリクエスト時に送られてきたデータの xxx というプロパティを取得しています。
つまりドキュメントを作成することができる条件は

  • ユーザーがログインしていること
  • ドキュメントのauther.id が string 型であること
  • ドキュメントのauther.id がログインしているユーザーの ID と一致すること
  • username が string 型であること
  • photoUrl が string 型であること
  • createdAt が timestamp 型 であること
  • detail が string 型であること
  • hour が string 型であること
  • minute が string 型であること
  • time が number 型であること
    を全て満たしていることです。
    このように、ドキュメントの作成時に送られてきたデータを検証することで、不正なデータを保存することを防ぐことができます。
    フロント側である程度バリデーションを効かせるできますが、ユーザーからリクエストされたデータは信用できないことを前提にルールを設定することが大切です。

allow delete: if 条件 はドキュメントの削除する際のルールを記述しています。
上記ではドキュメントのauther.id がログインしているユーザーの ID と一致することを条件にしています。
つまり、自分が作成したドキュメントのみ削除することができるということです。

繰り返しになりますが、Firebase の環境構築時に記述した構成情報は公開前提のキーのため、セキュリティルールを適切に設定することが大切です。
アプリを構築する際は公式ドキュメントを読み込んでから実装することをおすすめします。

image.png

8.3 投稿機能の実装

それでは VSCode に戻り、投稿機能を実装していきましょう。
RecordStudy に追記していきます。

src/routes/RecordStudy.tsx
import type { User } from "@firebase/auth";
import { Metric } from "@tremor/react";
+ import { addDoc, collection, serverTimestamp } from "firebase/firestore";
import React, { ChangeEventHandler, FC, FormEvent, useCallback, useState } from "react";
+ import { useNavigate } from "react-router-dom";

+ import { auth, db } from "../firebase";

type RecordStudyProps = {
  user: User | null;
};

export const RecordStudy: FC<RecordStudyProps> = ({ user }) => {
  const [title, setTitle] = useState("");
  const [detail, setDetail] = useState("");
  const [hour, setHour] = useState("");
  const [minute, setMinute] = useState("");
  const [time, setTime] = useState(0);

+  const navigate = useNavigate();

  const handleHour: ChangeEventHandler<HTMLInputElement> = useCallback(
    (e) => {
      if (parseInt(e.target.value) > parseInt(e.target.max) || e.target.value.length > 2) {
        setHour("");
        setTime(parseInt("0") * 60 + parseInt(minute));
        return;
      }
      setHour(e.target.value.trim());
      setTime(
        parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim()) * 60 +
          parseInt(minute === "" ? "0" : minute)
      );
    },
    [minute]
  );

  const handleMinute: ChangeEventHandler<HTMLInputElement> = useCallback(
    (e) => {
      if (parseInt(e.target.value) > parseInt(e.target.max) || e.target.value.length > 2) {
        setMinute("");
        setTime(parseInt(hour) * 60 + parseInt("0"));
        return;
      }
      setMinute(e.target.value.trim());
      setTime(
        parseInt(hour === "" ? "0" : hour) * 60 +
          parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim())
      );
    },
    [hour]
  );

+ const recordStudy = async (e: FormEvent<HTMLFormElement>) => {
+   e.preventDefault();
+
+   await addDoc(collection(db, "studylog"), {
+     title: title,
+     author: {
+       id: auth.currentUser?.uid,
+       photoUrl: auth.currentUser?.photoURL,
+       username: auth.currentUser?.displayName,
+     },
+     createdAt: serverTimestamp(),
+     detail: detail,
+     hour: `${Math.floor(time / 60)}`,
+     minute: `${time % 60}`,
+     time: time,
+     updatedAt: serverTimestamp(),
+   });
+   navigate("/");
+ };

  if (!user) {
    return <div>学習を記録するにはログインが必要です。</div>;
  }
  return (
    <div className="flex w-full justify-center">
      <form
+       onSubmit={recordStudy}
        className="w-full rounded-3xl bg-white p-5 drop-shadow-md md:w-[50rem]"
      >
        <div className="mb-6">
          <Metric className=" text-center text-gray-600">学習を記録</Metric>
          <label htmlFor="title" className=" mb-2 block text-sm font-medium text-gray-900">
            学習内容
          </label>
          <input
            id="title"
            type="text"
            className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
            placeholder="例)数学A 問題集"
            required
            onChange={(e) => setTitle(e.target.value)}
            value={title}
          />
        </div>

        <div className="mb-6">
          <label htmlFor="hour" className="mb-2 block text-sm font-medium text-gray-900">
            学習時間
            <span className="px-1 text-gray-500">(時間:分)</span>
          </label>
          <div className="block h-10 w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 text-sm text-gray-900 shadow-sm">
            <div className="flex h-full w-full items-center">
              <input
                type="number"
                inputMode="numeric"
                id="hour"
                min="0"
                max="24"
                placeholder="00"
                className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
                onChange={handleHour}
                value={hour}
              />
              <span className="px-1">:</span>
              <input
                type="number"
                inputMode="numeric"
                id="minute"
                min="0"
                max="99"
                placeholder="00"
                className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
                onChange={handleMinute}
                value={minute}
              />
            </div>
          </div>
        </div>

        <div className="mb-6 ">
          <label htmlFor="detail" className="mb-2 block text-sm font-medium text-gray-900">
            メモ
          </label>
          <textarea
            id="detail"
            className="block h-40 w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
            onChange={(e) => setDetail(e.target.value)}
            value={detail}
          ></textarea>
        </div>
        <div className="flex w-full justify-center py-5">
          <button
            type="submit"
            className="w-full rounded-lg border border-sky-800 px-10 py-2.5 text-center text-sm font-medium text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white active:bg-sky-800"
          >
            記録する
          </button>
        </div>
      </form>
    </div>
  );
};

記録するボタンを押すかエンターを押した時(submit アクションが実行された時)に発火させる recordStudy 関数を定義しています。

フォームからはイベントを受け取り、e.preventDefault()を発火させることでページがリロードされるのを防いでいます。
Firebase にドキュメントを追加するにはaddDoc関数を使用します。

自動 ID でドキュメントを追加するには addDoc の第 1 引数にcollection関数を渡します。
collection の第 1 引数には getFirestore で取得した DB 情報。第 2 引数にはコレクション名を渡します。

addDocの第 2 引数にはドキュメントのデータをオブジェクト形式で渡します。
ここではフォームから受け取ったデータをオブジェクトにして渡しています。
author にはログインしているユーザーの情報を渡しています。そのためフォームに直接入力する欄は設けていません。

createdAtupdatedAtにはserverTimestampという Firebase ですでに用意されている関数を使用して送信日時を渡しています。

await で上記の送信処理が完了したのちに useNavigate でホーム画面にリダイレクトさせています。

8.4  動作確認

動作確認していきましょう。
ログインを済ませて、記録画面からフォームに入力して送信してみます。
image.png
送信が完了するとホーム画面へ遷移します。

ホーム画面にデータの取得処理を実装していないため見た目の変化はありませんが後で実装するのでご安心ください。

image.png

Firebase のコンソールを確認するとドキュメントが追加されていれば成功です。
image.png

9 投稿データを一覧に表示

ホーム画面からダミーデータを削除して、Firebase から取得したものに差し替えていきます。

Home.tsx
+ import { collection, getDocs, limit, orderBy, query } from "firebase/firestore";
import React, { useEffect, useState } from "react";

import { Card } from "../components/Card";
+ import { db } from "../firebase";
import { StudyLog } from "../types/types";

- const dummyData: StudyLog[] = [];
- for (let i = 0; i < 100; i++) {
-   dummyData.push({
-     id: i.toString(),
-     title: `第${i}回React勉強会`,
-     author: {
-       id: i.toString(),
-       photoUrl: "https://via.placeholder.com/150/92c952",
-       username: "takumi",
-     },
-     createdAt: new Date().toString(),
-     detail: "Reactの勉強をしました",
-     hour: "1",
-     minute: "30",
-
-     time: 90,
-   });
- }

export const Home = () => {
-  const [studyLog, setStudyLog] = useState<StudyLog[]>(dummyData);
+  const [studyLog, setStudyLog] = useState<StudyLog[]>([]);

+ useEffect(() => {
+   const getStudyLogs = async () => {
+     const studylogRef = collection(db, "studylog");
+     const data = await getDocs(query(studylogRef, orderBy("createdAt", "desc"), limit(100)));
+     setStudyLog(
+       data.docs.map((doc) => ({
+         id: doc.id,
+         title: doc.data().title,
+         author: {
+           id: doc.data().author.id,
+           photoUrl: doc.data().author.photoUrl,
+           username: doc.data().author.username,
+         },
+         createdAt: doc.data().timestamp,
+         detail: doc.data().detail,
+         hour: doc.data().hour,
+         minute: doc.data().minute,
+         time: doc.data().time,
+       }))
+     );
+   };
+   getStudyLogs();
+   // 依存関係にstudyLogを加えると無限ループになってしまうので注意!
+ }, []);

  return (
    <div className=" hidden-scrollbar  flex w-screen justify-center overflow-hidden">
      <div className="hidden-scrollbar container flex h-full flex-col items-center overflow-scroll pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
        <div className="container flex flex-col items-center pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
          {studyLog.map((card) => (
            <Card key={card.id} cardInfo={card} />
          ))}
        </div>
      </div>
    </div>
  );
};

studyLog の useState の初期値は空配列にしておきます。(データが 1 件も取得されなければ何も表示されない状態にしておく)
useEffect の中で、Firebase からデータを取得する処理を実装します。
Firebase からデータを取得する処理は、getDocs関数を使います。
getDocs 関数は、引数にクエリを渡すことで、条件に合致するドキュメントを取得することができます。
ホーム画面のデータ取得クエリは、studylog コレクションから createdAt フィールドを降順で 100 件取得するというものです。
取得したデータをdataという変数に格納して、setStudyLog関数を使って state を更新します。
取得したデータは、data.docsに格納されているので、map 関数を使ってそれぞれ必要なプロパティを詰め替えています。

最後に定義した getStudyLogs を実行してあげると、Firebase からデータを取得して表示することができます。

うまくいくと先ほど記録したデータが表示されるはずです。

image.png

image.png

10 チャートの実装

続いてマイページのチャートデータを差し替えていきましょう。
こちらも同様にダミーデータを削除して Firebase から値を取得します。

src/routes/MyPage.tsx
import type { User } from "@firebase/auth";
import { AreaChart, Metric } from "@tremor/react";
+ import { collection, getDocs, limit, orderBy, query, where } from "firebase/firestore";
+ import React, { FC, useEffect, useState } from "react";

+ import { db } from "../firebase";

type MyPageProps = {
  user: User | null;
};

type Log = {
  date: string;
  "": number;
};
+ type Result = {
+   date: Date;
+   time: number;
+ };

const timeFormatter = (time: number) => {
  return `${Math.floor(time / 60)}時間${time % 60}分`;
};

- const dummyData: Log[] = [];
- for (let i = 0; i < 30; i++) {
-   dummyData.push({
-     date: dateFormatter(new Date(2022, 8, i)),
-     "": Math.floor(Math.random() * 100),
-   });
- }

export const MyPage: FC<MyPageProps> = ({ user }) => {
+   const [chartData, setChartData] = useState<Log[] | null>();
+   const studylogRef = collection(db, "studylog");
+
+   const getLatestDate = async (): Promise<Date> => {
+     const latestData = await getDocs(
+       query(
+         studylogRef,
+         where("author.id", "==", user?.uid),
+         orderBy("createdAt", "desc"),
+         limit(1)
+       )
+     );
+     const latestDate: Date = await latestData.docs[0].data().createdAt.toDate();
+     return latestDate;
+   };
+
+   const findThirtyDaysAgo = (date: Date): Date => {
+     const thirtyDaysAgo: Date = new Date(date.getTime() - 30 * 24 * 60 * 60 * 1000);
+     thirtyDaysAgo.setHours(0, 0, 0, 0);
+     return thirtyDaysAgo;
+   };

  const dateFormatter = (date: Date) => {
    return `${date.getMonth() + 1}/${date.getDate()}`;
  };

+ useEffect(() => {
+   const getMyLogs = async () => {
+     // // 一番最新のデータの日付
+     const latestDate: Date = await getLatestDate();
+     const thirtyDaysAgo: Date = findThirtyDaysAgo(latestDate);
+     const data = await getDocs(
+       query(
+         studylogRef,
+         where("author.id", "==", user?.uid),
+         where("createdAt", ">=", new Date(thirtyDaysAgo)),
+         orderBy("createdAt", "desc")
+       )
+     );

+     const result = data.docs
+       // 日付と時間を取得
+       .map((doc) => ({
+         // 0時0分0秒にする
+         date: new Date(doc.data().createdAt.toDate().setHours(0, 0, 0, 0)),
+         time: doc.data().time,
+       }))
+       .reduce((acc: Result[], cur: Result) => {
+         const target = acc.find((item) => item.date.getTime() === cur.date.getTime());
+         if (target) {
+           target.time += cur.time;
+         } else {
+           acc.push(cur);
+         }
+         return acc;
+       }, []);

+     const addMissingData = (result: Result[]) => {
+       // 対象日のデータがない場合は、timeが0のデータを追加
+       for (let i = 0; i < 30; i++) {
+         const targetDate = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000);
+         const target = result.find((item) => item.date === targetDate);
+         if (!target) {
+           result.push({
+             date: targetDate,
+             time: 0,
+           });
+         }
+       }

+       result.sort((a, b) => {
+         if (a.date < b.date) {
+           return -1;
+         } else {
+           return 1;
+         }
+       });

+       setChartData(
+         result.map((doc) => ({
+           date: `${dateFormatter(doc.date)}`,
+           "": doc.time,
+         }))
+       );
+     };
+     addMissingData(result);
+   };
+   getMyLogs();
+ }, [user]);

  if (!user) {
    return <div>マイページを確認するためにはログインが必要です。</div>;
  }
  return (
    <div className="hidden-scrollbar  flex w-screen justify-center overflow-hidden">
      <div className="hidden-scrollbar container flex flex-col items-center overflow-scroll rounded-3xl bg-white pt-4 drop-shadow-md md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
        <div className=" container flex h-80 flex-col items-center pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
          <Metric className=" text-gray-600">学習チャート</Metric>
          {chartData && (
            <AreaChart
              className="h-72 px-5 py-10"
              data={chartData}
              index={"date"}
              categories={[""]}
              colors={["indigo"]}
              valueFormatter={timeFormatter}
              showLegend={false}
              showTooltip={true}
              yAxisWidth={80}
            ></AreaChart>
          )}
        </div>
      </div>
    </div>
  );
};

まず、一時的に値を詰め替えるための Result という型を定義しています。
続いて、chartData という状態を useState で作成します。

チャートには、一番直近のデータから 30 日前までのデータを表示するようにします。
そのためまず直近のデータがいつのものなのか取得する必要があります。
そこで、getLatestDate という関数を作成し、最新のデータを取得するようにしています。

次に直近データから 30 日前がいつなのかを割り出す findThurtyDaysAgo 関数を定義します。

データの取得は useEffect 内で行なっています。

チャートに使用するデータをまず data という変数に格納しています。

result 関数ではクエリ対象となるデータから日付と時間だけを取り出しています。
日毎のデータとしてチャート作成したいため、取得したデータの記録時間を 0 時 0 分 0 秒にして、JavaScript の redule 関数で同じ日付のデータをまとめています。

addMissingData 関数では学習記録がない日付を学習時間 0 とするようにデータを追加しています。
その後、日付順にソートし、チャート用のステートに格納しています。

最後にそれぞれの関数を実行し、チャートのデータ処理が完了です。

しかこのままではマイページを表示しても何も表示されません。
Where 句で絞り込む時に複合インデックスを貼る必要があるため、コンソールに出力されている URL に飛んで設定を行いましょう。
image.png
Firebase のコンソールに飛ぶと、自動的にインデックスを作成してくれるためそのまま保存します。
image.png
ビルド中から有効になれば、マイページにチャートが表示されるようになります。
ビルドには少し時間がかかるため気長に待ってください。
image.png
image.png
image.png

11 Hosting

これで 1 通りアプリの実装が完了しました。お疲れ様でした。🍵
最後にデプロイまでしてしまいましょう。

まずはプロジェクトから Hosting 画面に遷移します。
image.png
始めるをクリックします。
image.png
FirebasHosting の設定を行います。
image.png
上記の画面に指示されたコマンドをコピペして Firebase CLI をインストールします。インストールが完了したら、上記画面の次へをクリックします。

ターミナル
npm install -g firebase-tools

続いてプロジェクトの初期化を行います。
image.png

ターミナル
firebase login
ターミナル
firebase init

firebas init を実行すると、以下のような質問がされます。
矢印キーで移動し、スペースキーで選択し、Enter キーで決定します。
❯◉ Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub Actionを選択します。

? Which Firebase features do you want to set up for this directory? Press Space to select
features, then Enter to confirm your choices. (Press <space> to select, <a> to toggle all,
<i> to invert selection, and <enter> to proceed)
provision default instance
 ◯ Firestore: Configure security rules and indexes files for Firestore
 ◯ Functions: Configure a Cloud Functions directory and its files
❯◉ Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub Action
deploys
 ◯ Hosting: Set up GitHub Action deploys
 ◯ Storage: Configure a security rules file for Cloud Storage
(Move up and down to reveal more choices)

Use an exsizting projectを選択します。

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.

? Please select an option:
❯ Use an existing project
  Create a new project
  Add Firebase to an existing Google Cloud Platform project
  Don't set up a default project

作成したアプリ名を選択します。

? Select a default Firebase project for this directory: (Use arrow keys)
❯ just-do-it-1c79b (just-do-it)

buildと指定します。

Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.

? What do you want to use as your public directory? build

yを指定します。

? Configure as a single-page app (rewrite all urls to /index.html)? (y/N) y

Nを指定します。

? Set up automatic builds and deploys with GitHub? (y/N) N

Nを指定します。

? File build/index.html already exists. Overwrite? (y/N) N

✔ Firebase initialization complete!と表示されれば完了です。

続いてデプロイ前にビルドしておきます。

ターミナル
npm run build

最後にデプロイします。

ターミナル
firebase deploy

Hosting URL が表示されれば成功です。
その URL が本番環境になります。

11.APIに制限をかける

おまけに GCP から API を利用するドメインの制限をかけましょう。
GCP と検索すれば` Google Cloud - Google Cloud Platform(GCP)`みたいなページが出てくるのでコンソールにアクセスしてください。

ヘッダーのドロップダウンを開いて対象のアプリケーションを選択します。
image.png

ハンバーガーメニューを開いてAPIとサービス > 認証情報へ進みます。
image.png

APIキー欄にあるBrouwer keyをクリックします。
image.png

アプリケーションの制限の設定ウェブサイトを選択し、ADDボタンをクリックします。
image.png

deployコマンドの実行時に出力されたドメインを追加します。
追加したら保存してください。
image.png

これで対象のドメイン以外からAPIを使用できなくなりました。
試しにしばらく経ってから、ローカル環境を立ち上げてみてください。正常にデータを取得できなければ設定が完了しています。
ローカル環境で同じAPIを使用したい場合は、http://localhost:3000みたいな感じで追加すると使用できますが、それは第3者でも同じことなのでそれを踏まえて検討してください。

他にもAPIを保護するためにできることはいくつかありますが、今回はこの辺りで終わりたいと思います。
以下にAPIを保護するためのベストプラクティスを貼っておきますので、興味がある方はやってみてください。

12. 終わりに

今回作成したアプリはまだまだ未完成なので気が向いたらリファクタや追加実装してみてください。

  • userのステート管理をグローバルにする
  • サインインウィザードを作成する
  • マイページに任意の期間でデータを取得できる機能をつける
  • ユーザー情報や投稿の編集機能をつける
    ect.
3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1