LoginSignup
275
287

More than 1 year has passed since last update.

Rails API + React でマッチングアプリを作ってみた

Last updated at Posted at 2021-06-09

概要

タイトル通り。バックエンドにRails(API)、フロントエンドにReactを採用したマッチングアプリ的なものを作ってみたのでアウトプットしておきます。

完成イメージ

match.gif

割とそれっぽい雰囲気になってます。

使用技術

  • バックエンド
    • Ruby3
    • Rails6(APIモード)
    • MySQL8
    • Docker
  • フロントエンド
    • React
    • TypeScript
    • Material-UI

今回はバックエンドのみDockerで環境構築していきます。

実装の流れ

だいぶ長旅になるので、これからどういった流れで作業を進めていくのかザックリ整理しておきます。

  • 環境構築
    • Rails($ rails new)
    • React($ create react-app)
  • 認証機能を作成
    • gem「devise_token_auth」などを使用
  • マッチング機能を作成
    • 中間テーブルなどを活用

バックエンドとフロントエンドを分離しているため、あっちこっち手を動かす事になりますが、あらかじめご了承ください。

環境構築

何はともあれ、環境構築からスタートです。

Rails

まずはRailsから。

作業ディレクトリ&各種ファイルを作成

$ mkdir rails-react-matching-app && cd rails-react-matching-app
$ mkdir backend && cd backend
$ touch Dockerfile docker-compose.yml entrypoint.sh Gemfile Gemfile.lock
./Dockerfile
FROM ruby:3.0

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

COPY . $APP_PATH

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]
./docker-compose.yml
version: "3"
services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - mysql-data:/var/lib/mysql
      - /tmp/dockerdir:/etc/mysql/conf.d/
    ports:
      - 3306:3306
  api:
    build:
      context: .
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - "3001:3000"
    depends_on:
      - db
volumes:
  mysql-data:
./entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails", "~> 6"
/Gemfile.lock
# 空欄でOK

最終的に次のような構成になっていればOK。

rails-react-matching-app
└── backend
    ├── docker-compose.yml
    ├── Dockerfile
    ├── entrypoint.sh
    ├── Gemfile
    └── Gemfile.lock

rails new

いつものコマンドでプロジェクトを作成します。

$ docker-compose run api rails new . --force --no-deps -d mysql --api

Gemfileが更新されたので再ビルド。

$ docker-compose build

database.ymlを編集

./config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password # デフォルトだと空欄になっているはずなので変更
  host: db # デフォルトだとlocalhostになっているはずなので変更

development:
  <<: *default
  database: myapp_development

test:
  <<: *default
  database: myapp_test

production:
  <<: *default
  database: <%= ENV["DATABASE_NAME"] %>
  username: <%= ENV["DATABASE_USERNAME"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
  host: <%= ENV["DATABASE_HOST"] %>

データベースを作成

$ docker-compose run api rails db:create

localhost:3001にアクセス

$ docker-compose up -d

スクリーンショット 2021-05-28 22.57.00.png

localhost:3001 にアクセスしていつもの画面が表示されればOK。

テストAPIを作成

動作確認用のテストAPiを作成します。

$ docker-compose run api rails g controller api/v1/test
./app/controllers/api/v1/test_controller.rb
class Api::V1::TestController < ApplicationController
  def index
    render json: { status: 200, message: "Hello World!"}
  end
end
./config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :test, only: %i[index]
    end
  end
end

curlコマンドで呼び出してみましょう。

$ curl http://localhost:3001/api/v1/test

{"status":200,"message":"Hello World!"}

正常にJSONが返ってくればOK。

CORSを設定

今のままの状態でReact(クライアント)から直接呼び出そうとするとCORSエラーで弾かれてしまうため、その辺の設定を行います。

参照記事: CORSとは?

Railsの場合、CORS設定を簡単に行えるgemが存在するのでそちらを使いましょう。

rb./Gemfile
gem 'rack-cors'

今回はAPIモードで作成しているため、すでにGemfile内に記載されているはず。(26行目くらい)
そちらのコメントアウトを外すだけでOKです。

$ docker-compose build

Gemfileを更新したので再度ビルド。

./config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

あとは「./config/initializers/cors.rb」をいじくって外部ドメインからアクセス可能なようにしておきます。

React

次にReact側です。

create-react-app

おなじみの「create-react-app」でアプリの雛形を作ります。

# ルートディレクトリに移動した後
$ mkdir frontend && cd frontend
$ yarn create react-app . --template typescript

tsconfig.jsonを修正

./tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,

    ...省略...

    "baseUrl": "src"  追記
  },
  "include": [
    "src"
  ]
}

「./tsconfig.json」内に「"baseUrl": "src"」という1行を追記してください。

これにより、インポート先を「./tsconfig.json」が配置されているディレクトリから相対パスで指定できるようになるので非常に楽です。

baseUrlを指定しない場合

import Hoge from "../../components/Hoge" // 呼び出すファイルからの相対パス

baseUrlを指定した場合

import Hoge from "components/Hoge" // baseUrlからの相対パス

「../../」みたいな記述をしなくて済みます。

不要なファイルを整理

この先使う事の無いファイルは邪魔なので今のうちに消しておきましょう。

$ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts 

「./src/index.tsx」と「./src/App.tsx」を次のように変更します。

./src/index.tsx
import React from "react"
import ReactDOM from "react-dom"
import "./index.css"
import App from "./App"

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
)
./src/App.tsx
import React from "react"

const App: React.FC = () => {
  return (
    <h1>Hello World!</h1>
  )
}

export default App

一旦、動作確認してみましょう。

$ yarn start

スクリーンショット 2021-05-04 0.37.55.png

localhost:3000 にアクセスして「Hello World!」と返ってくればOK。

APIクライアントを作成

Rails側で作成したAPIを呼び出すための準備を行います。

$ mkdir src/lib
$ mkdir src/lib/api
$ touch src/lib/api/client.ts src/lib/api/test.ts src/lib/api/test.ts

$ yarn add axios axios-case-converter
$ yarn add -D @types/axios
  • axios
    • HTTPクライアント用のライブラリ
  • @types/axios
    • 型定義用のライブラリ
  • axios-case-converter
    • axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換、または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ
./src/lib/api/client.ts
import applyCaseMiddleware from "axios-case-converter"
import axios from "axios"

// applyCaseMiddleware:
// axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換
// または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ

// ヘッダーに関してはケバブケースのままで良いので適用を無視するオプションを追加
const options = {
  ignoreHeaders: true 
}

const client = applyCaseMiddleware(axios.create({
  baseURL: "http://localhost:3001/api/v1"
}), options)

export default client

慣習的にRubyなどの言語がスネークケースが基本であるのに対し、JavaScriptはキャメルケースが基本となるため、足並みを揃える(スネークケース→キャメルケースへの変換もしくはその逆)ために「applyCaseMiddleware」というライブラリを使わせてもらっています。

./src/lib/api/test.ts
import client from "lib/api/client"

// 動作確認用
export const execTest = () => {
  return client.get("/test")
}
./src/App.tsx
import React, { useEffect, useState } from "react"

import { execTest } from "lib/api/test"

const App: React.FC = () => {
  const [message, setMessage] = useState<string>("")

  const handleExecTest = async () => {
    const res = await execTest()

    if (res.status === 200) {
      setMessage(res.data.message)
    }
  }

  useEffect(() => {
    handleExecTest()
  }, [])

  return (
    <h1>{message}</h1>
  )
}

export default App

スクリーンショット 2021-05-04 0.37.55.png

再び localhost:3000 にアクセスして「Hello World!」と返ってくればRails側との通信に成功です。

もしダメな場合、大抵はDockerのコンテナを再起動していないなどが原因(config/~をいじくったため、反映させるためには再起動する必要がある)なので、

$ docker-compose down
$ docker-compose up -d

などで再起動をかけてください。

認証機能を作成

環境構築が済んだので、認証機能を作成していきます。

Rails

今回、認証機能は「dvise」および「devise_token_auth」というgemを使って実装します。

deviseをインストール

./Gemfile
gem 'devise'
gem 'devise_token_auth'

Gemfileを更新したので再度ビルド。

$ docker-compose build

devise本体とdevise_token_authをインストールし、Userモデルを作成します。

$ docker-compose run api rails g devise:install
$ docker-compose run api rails g devise_token_auth:install User auth
$ docker-compose run api rails db:migrate

「./app/config/initializers/devise_token_auth.rb」という設定ファイルが自動生成されているはずなので次のように変更してください。

./app/config/initializers/devise_token_auth.rb
# frozen_string_literal: true

DeviseTokenAuth.setup do |config|
  config.change_headers_on_each_request = false
  config.token_lifespan = 2.weeks
  config.token_cost = Rails.env.test? ? 4 : 10

  config.headers_names = {:'access-token' => 'access-token',
                         :'client' => 'client',
                         :'expiry' => 'expiry',
                         :'uid' => 'uid',
                         :'token-type' => 'token-type' }
end

また、ヘッダー情報を外部に公開するため、「./config/initializers/cors.rb」を次のように修正します。

./config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定

    resource "*",
      headers: :any,
      expose: ["access-token", "expiry", "token-type", "uid", "client"], # 追記
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

メール認証設定

今回はサンプルという事もあり、簡略化のためアカウント作成時のメール認証はスキップする方向で進めますが、後ほど実運用を想定した場合は必ず必要になると思うので一応やっておきます。

./config/environments/development.rb
config.action_mailer.default_url_options = { host: 'localhost', port: 3001 }

なお、開発環境でメール確認を行う場合は letter_opener_web などが便利だと思います。

各種コントローラーを作成&修正

各種コントローラーの作成および修正を行います。

$ docker-compose run api rails g controller api/v1/auth/registrations
$ docker-compose run api rails g controller api/v1/auth/sessions
./app/controllers/api/v1/auth/registrations_controller.rb
# アカウント作成用コントローラー
class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController
  private

    def sign_up_params
      params.permit(:email, :password, :password_confirmation, :name)
    end
end
./app/controllers/api/v1/auth/sessions_controller.rb
# 認証確認用コントローラー
class Api::V1::Auth::SessionsController < ApplicationController
  def index
    if current_api_v1_user
      render json: { status: 200, current_user: current_api_v1_user }
    else
      render json: { status: 500, message: "ユーザーが存在しません" }
    end
  end
end
./app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include DeviseTokenAuth::Concerns::SetUserByToken

  skip_before_action :verify_authenticity_token
  helper_method :current_user, :user_signed_in?
end

deviseにおいて「current_user」というヘルパーメソッドは定番ですが、今回はルーティングの部分で「api/v1/」というnamespaceを切る(後述)ので「current_api_v1_user」としなければならない点に注意です。

ルーティングを設定

ルーティングの設定もお忘れなく。

./app/config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :test, only: %i[index]

      mount_devise_token_auth_for 'User', at: 'auth', controllers: {
        registrations: 'api/v1/auth/registrations'
      }

      namespace :auth do
        resources :sessions, only: %i[index]
      end
    end
  end
end

動作確認

早速ですが、curlコマンドでアカウント作成およびサインインができるか試しみてましょう。

アカウント作成

$ curl -X POST http://localhost:3001/api/v1/auth -d "[name]=test&[email]=test@example.com&[password]=password&[password_confirmation]=password"

{
    "status": "success",
    "data": {
        "email": "test@example.com",
        "uid": "test@example.com",
        "id": 1,
        "provider": "email",
        "allow_password_change": false,
        "name": "test",
        "nickname": null,
        "image": null,
        "created_at": "2021-06-08T20:27:40.489Z",
        "updated_at": "2021-06-08T20:27:40.608Z"
    }
}

サインイン

$ curl -X POST -v http://localhost:3001/api/v1/auth/sign_in -d "[email]=test@example.com&[password]=password"

Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3001 (#0)
> POST /api/v1/auth/sign_in HTTP/1.1
> Host: localhost:3001
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Length: 44
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 44 out of 44 bytes
< HTTP/1.1 200 OK
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< X-Download-Options: noopen
< X-Permitted-Cross-Domain-Policies: none
< Referrer-Policy: strict-origin-when-cross-origin
< Content-Type: application/json; charset=utf-8
< Vary: Accept, Origin
< access-token: xg-4vaua1T2KTUZUFfdYDg
< token-type: Bearer
< client: 2Rdmffk44hfoFCdmNafMSw
< expiry: 1624393713
< uid: test@example.com
< ETag: W/"fc564a9145d11564e827b204d5a4ce36"
< Cache-Control: max-age=0, private, must-revalidate
< X-Request-Id: 9a749e3d-0ba6-4e53-bbfb-1baff500e2e0
< X-Runtime: 0.363334
< Transfer-Encoding: chunked
< 
* Connection #0 to host localhost left intact
{"data":{"email":"test@example.com","uid":"test@example.com","id":1,"provider":"email","allow_password_change":false,"name":"test","nickname":null,"image":null}}* Closing connection 0

それぞれこんな感じで返ってくれば無事成功です。

なお、サインイン時に返ってくる

  • access-token
  • client
  • uid

この3つは後ほどReact側で認証を行う際に必要となる値なので、重要なものだと頭に入れておきましょう。

React

例の如く次はReact側の実装です。

各種ディレクトリ・ファイルを準備

$ mkdir components
$ mkdir components/layouts components/pages components/utils
$ mkdir interfaces

$ touch components/layouts/CommonLayout.tsx components/layouts/Header.tsx
$ touch components/pages/Home.tsx components/pages/SignIn.tsx components/pages/SignUp.tsx
$ touch components/utils/AlertMessage.tsx
$ touch interfaces/index.ts

$ mv components interfaces src
$ touch src/lib/api/auth.ts

最終的に次のような構成になっていればOK。

rails-react-auth
├── backend
└── frontend
    ├── node_modules
    ├── public
    │   ├── favicon.ico
    │   ├── index.html
    │   ├── logo192.png
    │   ├── logo512.png
    │   ├── manifest.json
    │   └── robots.txt
    ├── src
    │   ├── components
    │   │   ├── layouts
    │   │   │   ├── CommonLayout.tsx
    │   │   │   └── Header.tsx
    │   │   ├── pages
    │   │   │   ├── Home.tsx
    │   │   │   ├── SignIn.tsx
    │   │   │   └── SignUp.tsx
    │   │   └── utils
    │   │       └── AlertMessage.tsx
    │   ├── interfaces
    │   │   └── index.ts
    │   ├── lib
    │   │   └── api
    │   │       ├── auth.ts
    │   │       ├── client.ts
    │   │       └── test.ts
    │   ├── App.tsx
    │   ├── index.css
    │   ├── index.tsx
    │   └── react-app-env.d.ts
    ├── .gitignore
    ├── package.json
    ├── README.md
    ├── tsconfig.json
    └── yarn.lock

各種ライブラリをインストール

$ yarn add @material-ui/core @material-ui/icons @material-ui/lab @material-ui/pickers @date-io/date-fns@1.3.13 date-fns js-cookie react-router-dom
$ yarn add -D @types/js-cookie @types/react-router-dom
  • material-ui
    • UIを整える用のライブラリ
  • date-fns
    • 日付関連を操作するためのライブラリ(v1.3.13じゃないとエラーが発生するので注意)
  • js-cookie
    • Cookieを操作するためのライブラリ
  • react-router-dom
    • ルーティング設定用のライブラリ
  • @types/◯○
    • 型定義用のライブラリ

型定義

./src/interfaces/index.ts
// サインアップ
export interface SignUpData {
  name: string
  email: string
  password: string
  passwordConfirmation: string
}

// サインイン
export interface SignInData {
  email: string
  password: string
}

// ユーザー
export interface User {
  id: number
  uid: string
  provider: string
  email: string
  name: string
  nickname?: string
  image?: string
  allowPasswordChange: boolean
}

認証API用の関数を作成

./src/lib/api/auth.ts
import client from "lib/api/client"
import Cookies from "js-cookie"

import { SignUpData, SignInData } from "interfaces/index"

// サインアップ(新規アカウント作成)
export const signUp = (data: SignUpData) => {
  return client.post("auth", data)
}

// サインイン(ログイン)
export const signIn = (data: SignInData)  => {
  return client.post("auth/sign_in", data)
}

// サインアウト(ログアウト)
export const signOut = () => {
  return client.delete("auth/sign_out", { headers: {
    "access-token": Cookies.get("_access_token"),
    "client": Cookies.get("_client"),
    "uid": Cookies.get("_uid")
  }})  
}

// 認証済みのユーザーを取得
export const getCurrentUser = () => {
  if (!Cookies.get("_access_token") || !Cookies.get("_client") || !Cookies.get("_uid")) return
  return client.get("/auth/sessions", { headers: {
    "access-token": Cookies.get("_access_token"),
    "client": Cookies.get("_client"),
    "uid": Cookies.get("_uid")
  }})
}

サインイン中かどうかを判別するための各値(access-token、client、uid)をどこで保持するかについて、色々と議論の余地はあるようですが、今回はCookie内に含める事とします。

参照: React(SPA)での認証についてまとめ

各種ビューを作成

各種ビューの部分を作成します。

./src/App.tsx
import React, { useState, useEffect, createContext } from "react"
import { BrowserRouter as Router, Switch, Route, Redirect } from "react-router-dom"

import CommonLayout from "components/layouts/CommonLayout"
import Home from "components/pages/Home"
import SignUp from "components/pages/SignUp"
import SignIn from "components/pages/SignIn"

import { getCurrentUser } from "lib/api/auth"
import { User } from "interfaces/index"

// グローバルで扱う変数・関数
export const AuthContext = createContext({} as {
  loading: boolean
  setLoading: React.Dispatch<React.SetStateAction<boolean>>
  isSignedIn: boolean
  setIsSignedIn: React.Dispatch<React.SetStateAction<boolean>>
  currentUser: User | undefined
  setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>>
})

const App: React.FC = () => {
  const [loading, setLoading] = useState<boolean>(true)
  const [isSignedIn, setIsSignedIn] = useState<boolean>(false)
  const [currentUser, setCurrentUser] = useState<User | undefined>()

  // 認証済みのユーザーがいるかどうかチェック
  // 確認できた場合はそのユーザーの情報を取得
  const handleGetCurrentUser = async () => {
    try {
      const res = await getCurrentUser()
      console.log(res)

      if (res?.status === 200) {
        setIsSignedIn(true)
        setCurrentUser(res?.data.currentUser)
      } else {
        console.log("No current user")
      }
    } catch (err) {
      console.log(err)
    }

    setLoading(false)
  }

  useEffect(() => {
    handleGetCurrentUser()
  }, [setCurrentUser])


  // ユーザーが認証済みかどうかでルーティングを決定
  // 未認証だった場合は「/signin」ページに促す
  const Private = ({ children }: { children: React.ReactElement }) => {
    if (!loading) {
      if (isSignedIn) {
        return children
      } else {
        return <Redirect to="/signin" />
      }
    } else {
      return <></>
    }
  }

  return (
    <Router>
      <AuthContext.Provider value={{ loading, setLoading, isSignedIn, setIsSignedIn, currentUser, setCurrentUser}}>
        <CommonLayout>
          <Switch>
            <Route exact path="/signup" component={SignUp} />
            <Route exact path="/signin" component={SignIn} />
            <Private>
              <Switch>
                <Route exact path="/" component={Home} />
              </Switch>
            </Private>
          </Switch>
        </CommonLayout>
      </AuthContext.Provider>
    </Router>
  )
}

export default App
./src/components/layouts/Header.tsx
import React, { useContext } from "react"
import { useHistory, Link } from "react-router-dom"
import Cookies from "js-cookie"

import { makeStyles, Theme } from "@material-ui/core/styles"

import AppBar from "@material-ui/core/AppBar"
import Toolbar from "@material-ui/core/Toolbar"
import Typography from "@material-ui/core/Typography"
import Button from "@material-ui/core/Button"
import IconButton from "@material-ui/core/IconButton"
import MenuIcon from "@material-ui/icons/Menu"

import { signOut } from "lib/api/auth"

import { AuthContext } from "App"

const useStyles = makeStyles((theme: Theme) => ({
  iconButton: {
    marginRight: theme.spacing(2),
  },
  title: {
    flexGrow: 1,
    textDecoration: "none",
    color: "inherit"
  },
  linkBtn: {
    textTransform: "none"
  }
}))

const Header: React.FC = () => {
  const { loading, isSignedIn, setIsSignedIn } = useContext(AuthContext)
  const classes = useStyles()
  const histroy = useHistory()

  const handleSignOut = async (e: React.MouseEvent<HTMLButtonElement>) => {
    try {
      const res = await signOut()

      if (res.data.success === true) {
        // サインアウト時には各Cookieを削除
        Cookies.remove("_access_token")
        Cookies.remove("_client")
        Cookies.remove("_uid")

        setIsSignedIn(false)
        histroy.push("/signin")

        console.log("Succeeded in sign out")
      } else {
        console.log("Failed in sign out")
      }
    } catch (err) {
      console.log(err)
    }
  }

  const AuthButtons = () => {
    // 認証完了後はサインアウト用のボタンを表示
    // 未認証時は認証用のボタンを表示
    if (!loading) {
      if (isSignedIn) {
        return (
          <Button
            color="inherit"
            className={classes.linkBtn}
            onClick={handleSignOut}
          >
            サインアウト
          </Button>
        )
      } else {
        return (
          <Button
            component={Link}
            to="/signin"
            color="inherit"
            className={classes.linkBtn}
          >
            サインイン
          </Button>
        )
      }
    } else {
      return <></>
    }
  }

  return (
    <>
      <AppBar position="static">
        <Toolbar>
          <IconButton
            edge="start"
            className={classes.iconButton}
            color="inherit"
          >
            <MenuIcon />
          </IconButton>
          <Typography
            component={Link}
            to="/"
            variant="h6"
            className={classes.title}
          >
            Sample
          </Typography>
          <AuthButtons />
        </Toolbar>
      </AppBar>
    </>
  )
}

export default Header
./src/components/layouts/CommonLayout.tsx
import React from "react"

import { Container, Grid } from "@material-ui/core"
import { makeStyles } from "@material-ui/core/styles"

import Header from "components/layouts/Header"

const useStyles = makeStyles(() => ({
  container: {
    paddingTop: "3rem"
  }
}))

interface CommonLayoutProps {
  children: React.ReactElement
}

// 全てのページで共通となるレイアウト
const CommonLayout = ({ children }: CommonLayoutProps) => {
  const classes = useStyles()

  return (
    <>
      <header>
        <Header />
      </header>
      <main>
        <Container maxWidth="lg" className={classes.container}>
          <Grid container justify="center">
            <Grid item>
              {children}
            </Grid>   
          </Grid>
        </Container>
      </main>
    </>
  )
}

export default CommonLayout
./src/components/pages/Home.tsx
import React, { useContext } from "react"

import { AuthContext } from "App"

// とりあえず認証済みユーザーの名前やメールアドレスを表示
const Home: React.FC = () => {
  const { isSignedIn, currentUser } = useContext(AuthContext)

  return (
    <>
      {
        isSignedIn && currentUser ? (
          <>
            <h2>メールアドレス: {currentUser?.email}</h2>
            <h2>名前: {currentUser?.name}</h2>
          </>
        ) : (
          <></>
        )
      }
    </>
  )
}

export default Home
./src/components/pages/SignIn.tsx
import React, { useState, useContext } from "react"
import { useHistory, Link } from "react-router-dom"
import Cookies from "js-cookie"

import { makeStyles, Theme } from "@material-ui/core/styles"
import { Typography } from "@material-ui/core"
import TextField from "@material-ui/core/TextField"
import Card from "@material-ui/core/Card"
import CardContent from "@material-ui/core/CardContent"
import CardHeader from "@material-ui/core/CardHeader"
import Button from "@material-ui/core/Button"
import Box from "@material-ui/core/Box"

import { AuthContext } from "App"
import AlertMessage from "components/utils/AlertMessage"
import { signIn } from "lib/api/auth"
import { SignInData } from "interfaces/index"

const useStyles = makeStyles((theme: Theme) => ({
  submitBtn: {
    paddingTop: theme.spacing(2),
    textAlign: "right",
    flexGrow: 1,
    textTransform: "none"
  },
  header: {
    textAlign: "center"
  },
  card: {
    padding: theme.spacing(2),
    maxWidth: 400
  },
  box: {
    paddingTop: "2rem"
  },
  link: {
    textDecoration: "none"
  }
}))

// サインイン用ページ
const SignIn: React.FC = () => {
  const classes = useStyles()
  const history = useHistory()

  const { setIsSignedIn, setCurrentUser } = useContext(AuthContext)

  const [email, setEmail] = useState<string>("")
  const [password, setPassword] = useState<string>("")
  const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false)

  const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()

    const data: SignInData = {
      email: email,
      password: password
    }

    try {
      const res = await signIn(data)
      console.log(res)

      if (res.status === 200) {
        // 成功した場合はCookieに各値を格納
        Cookies.set("_access_token", res.headers["access-token"])
        Cookies.set("_client", res.headers["client"])
        Cookies.set("_uid", res.headers["uid"])

        setIsSignedIn(true)
        setCurrentUser(res.data.data)

        history.push("/")

        console.log("Signed in successfully!")
      } else {
        setAlertMessageOpen(true)
      }
    } catch (err) {
      console.log(err)
      setAlertMessageOpen(true)
    }
  }

  return (
    <>
      <form noValidate autoComplete="off">
        <Card className={classes.card}>
          <CardHeader className={classes.header} title="サインイン" />
          <CardContent>
            <TextField
              variant="outlined"
              required
              fullWidth
              label="メールアドレス"
              value={email}
              margin="dense"
              onChange={event => setEmail(event.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="パスワード"
              type="password"
              placeholder="6文字以上"
              value={password}
              margin="dense"
              autoComplete="current-password"
              onChange={event => setPassword(event.target.value)}
            />
            <Box className={classes.submitBtn} >
              <Button
                type="submit"
                variant="outlined"
                color="primary"
                disabled={!email || !password ? true : false}
                onClick={handleSubmit}
              >
                送信
              </Button>
            </Box>
            <Box textAlign="center" className={classes.box}>
              <Typography variant="body2">
                まだアカウントをお持ちでない方は
                <Link to="/signup" className={classes.link}>
                  こちら
                </Link>
                 から作成してください。
              </Typography>
            </Box>
          </CardContent>
        </Card>
      </form>
      <AlertMessage // エラーが発生した場合はアラートを表示
        open={alertMessageOpen}
        setOpen={setAlertMessageOpen}
        severity="error"
        message="メールアドレスかパスワードが間違っています"
      />
    </>
  )
}

export default SignIn
./src/components/pages/SignUp.tsx
import React, { useState, useContext } from "react"
import { useHistory } from "react-router-dom"
import Cookies from "js-cookie"

import { makeStyles, Theme } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Card from "@material-ui/core/Card"
import CardContent from "@material-ui/core/CardContent"
import CardHeader from "@material-ui/core/CardHeader"
import Button from "@material-ui/core/Button"

import { AuthContext } from "App"
import AlertMessage from "components/utils/AlertMessage"
import { signUp } from "lib/api/auth"
import { SignUpData } from "interfaces/index"

const useStyles = makeStyles((theme: Theme) => ({
  submitBtn: {
    paddingTop: theme.spacing(2),
    textAlign: "right",
    flexGrow: 1,
    textTransform: "none"
  },
  header: {
    textAlign: "center"
  },
  card: {
    padding: theme.spacing(2),
    maxWidth: 400
  }
}))

// サインアップ用ページ
const SignUp: React.FC = () => {
  const classes = useStyles()
  const histroy = useHistory()

  const { setIsSignedIn, setCurrentUser } = useContext(AuthContext)

  const [name, setName] = useState<string>("")
  const [email, setEmail] = useState<string>("")
  const [password, setPassword] = useState<string>("")
  const [passwordConfirmation, setPasswordConfirmation] = useState<string>("")
  const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false)

  const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()

    const data: SignUpData = {
      name: name,
      email: email,
      password: password,
      passwordConfirmation: passwordConfirmation
    }

    try {
      const res = await signUp(data)
      console.log(res)

      if (res.status === 200) {
        // アカウント作成と同時にサインインさせてしまう
        // 本来であればメール確認などを挟むべきだが、今回はサンプルなので
        Cookies.set("_access_token", res.headers["access-token"])
        Cookies.set("_client", res.headers["client"])
        Cookies.set("_uid", res.headers["uid"])

        setIsSignedIn(true)
        setCurrentUser(res.data.data)

        histroy.push("/")

        console.log("Signed in successfully!")
      } else {
        setAlertMessageOpen(true)
      }
    } catch (err) {
      console.log(err)
      setAlertMessageOpen(true)
    }
  }

  return (
    <>
      <form noValidate autoComplete="off">
        <Card className={classes.card}>
          <CardHeader className={classes.header} title="サインアップ" />
          <CardContent>
            <TextField
              variant="outlined"
              required
              fullWidth
              label="名前"
              value={name}
              margin="dense"
              onChange={event => setName(event.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="メールアドレス"
              value={email}
              margin="dense"
              onChange={event => setEmail(event.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="パスワード"
              type="password"
              value={password}
              margin="dense"
              autoComplete="current-password"
              onChange={event => setPassword(event.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="パスワード(確認用)"
              type="password"
              value={passwordConfirmation}
              margin="dense"
              autoComplete="current-password"
              onChange={event => setPasswordConfirmation(event.target.value)}
            />
            <div className={classes.submitBtn}>
              <Button
                type="submit"
                variant="outlined"
                color="primary"
                disabled={!name || !email || !password || !passwordConfirmation ? true : false}
                onClick={handleSubmit}
              >
                送信
              </Button>
            </div>
          </CardContent>
        </Card>
      </form>
      <AlertMessage // エラーが発生した場合はアラートを表示
        open={alertMessageOpen}
        setOpen={setAlertMessageOpen}
        severity="error"
        message="メールアドレスかパスワードが間違っています"
      />
    </>
  )
}

export default SignUp
./src/components/utils/AlertMessage.tsx
import React from "react"
import Snackbar from "@material-ui/core/Snackbar"
import MuiAlert, { AlertProps } from "@material-ui/lab/Alert"

const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(
  props,
  ref,
) {
  return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
})

interface AlertMessageProps {
  open: boolean
  setOpen: Function
  severity: "error" | "success" | "info" | "warning"
  message: string
}

// アラートメッセージ(何かアクションを行なった際の案内用に使い回す)
const AlertMessage = ({ open, setOpen, severity, message}: AlertMessageProps) => {
  const handleCloseAlertMessage = (e?: React.SyntheticEvent, reason?: string) => {
    if (reason === "clickaway") return

    setOpen(false)
  }

  return (
    <>
      <Snackbar
        open={open}
        autoHideDuration={6000}
        anchorOrigin={{ vertical: "top", horizontal: "center" }}
        onClose={handleCloseAlertMessage}
      >
        <Alert onClose={handleCloseAlertMessage} severity={severity}>
          {message}
        </Alert>
      </Snackbar>
    </>
  )
}

export default AlertMessage

動作確認

サインアップ

スクリーンショット 2021-06-09 12.05.26.png

サインイン

スクリーンショット 2021-06-09 12.06.58.png

トップページ

スクリーンショット 2021-06-09 12.06.01.png

アラートメッセージ

スクリーンショット 2021-06-09 12.06.33.png

特に問題が無さそうであれば認証機能は完成です。

マッチング機能を作成

だいぶそれっぽい雰囲気になってきたので、最後にマッチング機能を作成していきます。

Rails

マッチング機能に関しては中間テーブルなどを活用する事で実現していきます。

テーブル設計

全体的なテーブルはこんな感じです。

Untitled Diagram (1).png

※雑ですみません...。

Userモデルにカラムを追加

deviseで作成したUserモデルに

  • email
  • name
  • image

といったカラムがデフォルトで入っていますが、マッチングアプリという観点からするとこれだけの情報ではやや物足りないため、

  • gender(性別)
  • birthday(誕生日)
  • prefecture(都道府県)
  • profile(自己紹介)

といったカラムを別途追加していきたいと思います。

$ docker-compose run api rails g migration AddColumnsToUsers
./db/migrate/20210609035043_add_columns_to_users.rb
class AddColumnsToUsers < ActiveRecord::Migration[6.1]
  def change
    add_column :users, :gender, :integer, null: false, default: 0, after: :nickname
    add_column :users, :birthday, :date, after: :email
    add_column :users, :profile, :string, limit: 1000, after: :birthday
    add_column :users, :prefecture, :integer, null: false, default: 1, after: :profile

    remove_column :users, :nickname, :string # 特に使う予定の無いカラムなので削除
  end
end

マイグレーションファイルを上記のように変更後、

$ docker-compose run api rails db:migrate

を実行してデータベースに反映させてください。

画像アップロード機能を作成

そういえば、まだ画像アップロード機能が無いので実装していきましょう。今回は定番のgem「carrierwave」を使用します。

./Gemfile
gem 'carrierwave'

Gemfileを更新したので再度ビルド。

$ docker-compose build

アップローダーを作成。

$ docker-compose run api rails g uploader Image

すると「./app/uploaders/image_uploader.rb」が自動生成されるので次のように変更します。

.app/uploaders/image_uploader.rb
class ImageUploader < CarrierWave::Uploader::Base
  storage :file

  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  # 受け付け可能なファイルの拡張子を指定
  def extension_allowlist
    %w(jpg jpeg png)
  end
end

また、「./config/initializers/」配下にcarrierwave設定用のファイルを作成。

$ touch config/initializers/carrierwave.rb
./config/initializers/carrierwave.rb
CarrierWave.configure do |config|
  config.asset_host = "http://localhost:3001"
  config.storage = :file
  config.cache_storage = :file
end

Userモデルにアップローダーをマウントします。

./app/models/user.rb
# frozen_string_literal: true

class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  include DeviseTokenAuth::Concerns::User

  mount_uploader :image, ImageUploader # 追記
end

あとは「./app/controllers/api/v1/auth/registrations_controller.rb」内のストロングパラメータに先ほど追加したカラムを記述しておきます。

./app/controllers/api/v1/auth/registrations_controller.rb
class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController
  private

    def sign_up_params
      params.permit(:email, :password, :password_confirmation, :name, :image, :gender, :prefecture, :birthday)
    end
end

これで準備は完了です。

スクリーンショット 2021-06-09 17.57.04.png

あとは動作確認のためルートディレクトリに適当な画像を「sample.jpg」という名前配置し、次のcurlコマンドを実行しましょう。

$ curl -F "email=imagetest@example.com" -F "password=password" -F "password=password_confirmation" -F "name=imagetest" -F "gender=0" -F "birthday=2000-01-01" -F "prefecture=13" -F "profile=画像テストです" -F "image=@sample.jpg" http://localhost:3001/api/v1/auth

{
    "status": "success",
    "data": {
        "email": "imagetest@example.com",
        "uid": "imagetest@example.com",
        "image": {
            "url": "http://localhost:3001/uploads/user/image/3/sample.jpg"
        },
        "id": 3,
        "provider": "email",
        "allow_password_change": false,
        "name": "imagetest",
        "gender": 0,
        "birthday": "2000-01-01",
        "profile": null,
        "prefecture": 13,
        "created_at": "2021-06-09T08:53:03.944Z",
        "updated_at": "2021-06-09T08:53:04.116Z"
    }
}

こんな感じで画像のパスが保存されていればOKです。

Likeモデルを作成

今回、マッチングが成立するための条件を

  • 双方のユーザーが相手に対して「いいね」を押す事

とするため、誰が誰に対して押したのかという情報を記録するためのLikeモデルを作成します。

$ docker-compose run api rails g model Like

マイグレーションファイルを次のように変更。

./db/migrate/20210609090711_create_likes.rb
class CreateLikes < ActiveRecord::Migration[6.1]
  def change
    create_table :likes do |t|
      t.integer :from_user_id, null: false # 誰が
      t.integer :to_user_id, null: false   # 誰に対して

      t.timestamps
    end
  end
end

データベースに反映します。

$ docker-compose run api rails db:migrate

Userモデルとのリレーションを作成しましょう。

./app/models/user.rb
# frozen_string_literal: true

class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  include DeviseTokenAuth::Concerns::User

  mount_uploader :image, ImageUploader

  # 以下を追記
  has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy
  has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy
  has_many :active_likes, through: :likes_from, source: :to_user  # 自分からのいいね
  has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね
end
./app/models/like.rb
class Like < ApplicationRecord
  belongs_to :to_user, class_name: "User", foreign_key: :to_user_id
  belongs_to :from_user, class_name: "User", foreign_key: :from_user_id
end

ChatRoomモデル・ChatRoomUserモデルを作成

マッチングが成立した際はメッセージのやりとりを行うための部屋が必要になるため、ChatRoomモデルおよびChatRoomUserモデルを作成します。

  • ChatRoom
    • メッセージのやりとりを行う部屋
  • ChatRoomUser
    • どの部屋にどのユーザーがいるのかという情報を記録
$ docker-compose run api rails g model ChatRoom
$ docker-compose run api rails g model ChatRoomUser

マイグレーションファイルをそれぞれ次のように変更。

./db/migrate/20210609092254_create_chat_rooms.rb
class CreateChatRooms < ActiveRecord::Migration[6.1]
  def change
    create_table :chat_rooms do |t|

      t.timestamps
    end
  end
end
./db/migrate/20210609092312_create_chat_room_users.rb
class CreateChatRoomUsers < ActiveRecord::Migration[6.1]
  def change
    create_table :chat_room_users do |t|
      t.integer :chat_room_id, null: false
      t.integer :user_id, null: false

      t.timestamps
    end
  end
end

データベースに反映します。

$ docker-compose run api rails db:migrate

User、ChatRoom、ChatRoomUser、それぞれのリレーションを作成しましょう。

./app/models/user.eb
# frozen_string_literal: true

class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  include DeviseTokenAuth::Concerns::User

  mount_uploader :image, ImageUploader

  has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy
  has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy
  has_many :active_likes, through: :likes_from, source: :to_user  # 自分からのいいね
  has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね

  # 以下を追記
  has_many :chat_room_users
  has_many :chat_rooms, through: :chat_room_users
end
./app/models/chat_room.rb
class ChatRoom < ApplicationRecord
  has_many :chat_room_users
  has_many :users, through: :chat_room_users # 中間テーブルのchat_room_usersを介してusersを取得
end
./app/models/chat_room_user.rb
class ChatRoomUser < ApplicationRecord
  belongs_to :chat_room
  belongs_to :user
end

Messageモデルを作成

あとはメッセージそのものとなるMessageモデルを作成します。なお、メッセージにはどの部屋のものなのか、誰が送信したものなのかといった情報も一緒に記録しておきたいところです。

$ docker-compose run api rails g model Message  

マイグレーションファイルを次のように変更。

./db/migrate/20210609093540_create_messages.rb
class CreateMessages < ActiveRecord::Migration[6.1]
  def change
    create_table :messages do |t|
      t.integer :chat_room_id, null: false
      t.integer :user_id, null: false
      t.string :content, null: false

      t.timestamps
    end
  end
end

データベースに反映します。

$ docker-compose run api rails db:migrate

User、ChatRoomとそれぞれリレーションを作成しましょう。

./app/models/user.rb
# frozen_string_literal: true

class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  include DeviseTokenAuth::Concerns::User

  mount_uploader :image, ImageUploader

  has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy
  has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy
  has_many :active_likes, through: :likes_from, source: :to_user  # 自分からのいいね
  has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね
  has_many :chat_room_users
  has_many :chat_rooms, through: :chat_room_users

  # 以下を追記
  has_many :messages
end
./app/models/chat_room.rb
class ChatRoom < ApplicationRecord
  has_many :chat_room_users
  has_many :users, through: :chat_room_users

  # 以下を追記
  has_many :messages
end
./app/models/message.rb
class Message < ApplicationRecord
  belongs_to :chat_room
  belongs_to :user
end

各種コントローラーを作成

上記のモデルたちを操作するためのコントローラーを作成していきます。

$ docker-compose run api rails g controller api/v1/likes
$ docker-compose run api rails g controller api/v1/chat_rooms
$ docker-compose run api rails g controller api/v1/messages
$ docker-compose run api rails g controller api/v1/users
./app/controllers/api/v1/likes_controller.rb
class Api::V1::LikesController < ApplicationController
  def index
    render json: {
      status: 200,
      active_likes: current_api_v1_user.active_likes,  # 自分からのいいね
      passive_likes: current_api_v1_user.passive_likes # 相手からのいいね
    }
  end

  def create
    is_matched = false # マッチングが成立したかどうかのフラグ

    active_like = Like.find_or_initialize_by(like_params)
    passsive_like = Like.find_by(
      from_user_id: active_like.to_user_id, 
      to_user_id: active_like.from_user_id
    )

    if passsive_like # いいねを押した際、相手からのいいねがすでに存在する場合はマッチング成立
      chat_room = ChatRoom.create # メッセージ交換用の部屋を作成

      # 自分
      ChatRoomUser.find_or_create_by(
        chat_room_id: chat_room.id,
        user_id: active_like.from_user_id
      )

      # 相手
      ChatRoomUser.find_or_create_by(
        chat_room_id: chat_room.id,
        user_id: passsive_like.from_user_id
      )

      is_matched = true
    end

    if active_like.save
      render json: { status: 200, like: active_like, is_matched: is_matched }
    else
      render json: { status: 500, message: "作成に失敗しました" }
    end
  end

  private

    def like_params
      params.permit(:from_user_id, :to_user_id)
    end
end
./app/controllers/api/v1/chat_rooms_controller.rb
class Api::V1::ChatRoomsController < ApplicationController
  before_action :set_chat_room, only: %i[show]

  def index
    chat_rooms = []

    current_api_v1_user.chat_rooms.order("created_at DESC").each do |chat_room|
      # 部屋の情報(相手のユーザーは誰か、最後に送信されたメッセージはどれか)をJSON形式で作成
      chat_rooms << {
        chat_room: chat_room,
        other_user: chat_room.users.where.not(id: current_api_v1_user.id)[0],
        last_message: chat_room.messages[-1]
      }
    end

    render json: { status: 200, chat_rooms: chat_rooms } 
  end

  def show
    other_user = @chat_room.users.where.not(id: current_api_v1_user.id)[0]
    messages = @chat_room.messages.order("created_at ASC")

    render json: { status: 200, other_user: other_user, messages: messages }
  end

  private

    def set_chat_room
      @chat_room = ChatRoom.find(params[:id])
    end
end
./app/controllers/api/v1/messages_controller.rb
class Api::V1::MessagesController < ApplicationController
  def create
    message = Message.new(message_params)

    if message.save
      render json: { status: 200, message: message }
    else
      render json: { status: 500, message: "作成に失敗しました" }
    end
  end

  private

    def message_params
      params.permit(:chat_room_id, :user_id, :content)
    end
end
./app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  before_action :set_user, only: %i[show update]

  def index
    # 都道府県が同じで性別の異なるユーザーを取得(自分以外)
    users = User.where(prefecture: current_api_v1_user.prefecture).where.not(id: current_api_v1_user.id, gender: current_api_v1_user.gender).order("created_at DESC")
    render json: { status: 200, users: users }
  end

  def show
    render json: { status: 200, user: @user }
  end

  def update
    @user.name = user_params[:name]
    @user.prefecture = user_params[:prefecture]
    @user.profile = user_params[:profile]
    @user.image = user_params[:image] if user_params[:image] != ""

    if @user.save
      render json: { status: 200, user: @user }
    else
      render json: { status: 500, message: "更新に失敗しました" }
    end
  end

  private

    def set_user
      @user = User.find(params[:id])
    end

    def user_params
      params.permit(:name, :prefecture, :profile, :image)
    end
end

各種ルーティングを設定

最後にルーティングの設定を行えばRails側の準備は全て完了です。

./config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :test, only: %i[index]
      resources :likes, only: %i[index create]
      resources :chat_rooms, only: %i[index show]
      resources :messages, only: %i[create]
      resources :users, only: %i[index show update]

      mount_devise_token_auth_for 'User', at: 'auth', controllers: {
        registrations: 'api/v1/auth/registrations'
      }

      namespace :auth do
        resources :sessions, only: %i[index]
      end
    end
  end
end

React

いよいよ仕上げです。Railsで準備したAPIを呼び出しつつ、ビューの部分を作り込んでいきます。

各種ディレクトリ・ファイルを準備

$ touch src/components/pages/Users.tsx src/components/pages/ChatRooms.tsx src/components/pages/ChatRoom.tsx src/components/pages/NotFound.tsx

$ mkdir src/data
$ touch src/data/genders.ts src/data/prefectures.ts

$ touch src/lib/api/users.ts src/lib/api/likes.ts src/lib/api/chat_rooms.ts src/lib/api/messages.ts
$ touch src/lib/api/likes.ts
$ touch src/lib/api/chat_rooms.ts
$ touch src/lib/api/messages.ts

最終的に次のような構成になっていればOK。

rails-react-matching-app
├── backend
└── frontend
    ├── node_modules
    ├── public
    │   ├── favicon.ico
    │   ├── index.html
    │   ├── logo192.png
    │   ├── logo512.png
    │   ├── manifest.json
    │   └── robots.txt
    ├── src
    │   ├── components
    │   │   ├── layouts
    │   │   │   ├── CommonLayout.tsx
    │   │   │   └── Header.tsx
    │   │   ├── pages
    │   │   │   ├── ChatRoom.tsx
    │   │   │   ├── ChatRooms.tsx
    │   │   │   ├── Home.tsx
    │   │   │   ├── NotFound.tsx
    │   │   │   ├── SignIn.tsx
    │   │   │   ├── SignUp.tsx
    │   │   │   └── Users.tsx
    │   │   └── utils
    │   │       └── AlertMessage.tsx
    │   ├── data
    │   │   ├── genders.ts
    │   │   └── prefectures.ts
    │   ├── interfaces
    │   │   └── index.ts
    │   ├── lib
    │   │   └── api
    │   │       ├── auth.ts
    │   │       ├── chat_rooms.ts
    │   │       ├── client.ts
    │   │       ├── likes.ts
    │   │       ├── messages.ts
    │   │       ├── test.ts
    │   │       └── users.ts
    │   ├── App.tsx
    │   ├── index.css
    │   ├── index.tsx
    │   └── react-app-env.d.ts
    ├── tsconfig.json
    └── yarn.lock

型定義

./src/interfaces/index.ts
// サインアップ
export interface SignUpData {
  name: string
  email: string
  password: string
  passwordConfirmation: string
  gender: number
  prefecture: number
  birthday: Date
  image: string
}

export interface SignUpFormData extends FormData {
  append(name: keyof SignUpData, value: String | Blob, fileName?: string): any
}

// サインイン
export interface SignInData {
  email: string
  password: string
}

// ユーザー
export interface User {
  id: number
  uid: string
  provider: string
  email: string
  name: string
  image: {
    url: string
  }
  gender: number
  birthday: String | number | Date
  profile: string
  prefecture: number
  allowPasswordChange: boolean
  createdAt?: Date
  updatedAt?: Date
}

export interface UpdateUserData {
  id: number | undefined | null
  name?: string 
  prefecture?: number
  profile?: string
  image?: string
}

export interface UpdateUserFormData extends FormData {
  append(name: keyof UpdateUserData, value: String | Blob, fileName?: string): any
}

// いいね
export interface Like {
  id?: number
  fromUserId: number | undefined | null
  toUserId: number | undefined | null
}

// チャットルーム
export interface ChatRoom {
  chatRoom: {
    id: number
  }
  otherUser: User,
  lastMessage: Message
}

// メッセージ
export interface Message {
  chatRoomId: number
  userId: number | undefined
  content: string
  createdAt?: Date
}

マスターデータを作成

性別や都道府県といった不変的な情報はマスターデータとして保持しておきます。

./src/data/genders.ts
// 性別のマスターデータ
export const genders: string[] = [
  "男性",
  "女性",
  "その他"
]
./src/data/prefectures.ts
// 都道府県のマスターデータ
export const prefectures: string[] = [
  "北海道",
  "青森県",
  "岩手県",
  "宮城県",
  "秋田県",
  "山形県",
  "福島県",
  "茨城県",
  "栃木県",
  "群馬県",
  "埼玉県",
  "千葉県",
  "東京都",
  "神奈川県",
  "新潟県",
  "富山県",
  "石川県",
  "福井県",
  "山梨県",
  "長野県",
  "岐阜県",
  "静岡県",
  "愛知県",
  "三重県",
  "滋賀県",
  "京都府",
  "大阪府",
  "兵庫県",
  "奈良県",
  "和歌山県",
  "鳥取県",
  "島根県",
  "岡山県",
  "広島県",
  "山口県",
  "徳島県",
  "香川県",
  "愛媛県",
  "高知県",
  "福岡県",
  "佐賀県",
  "長崎県",
  "熊本県",
  "大分県",
  "宮崎県",
  "鹿児島県",
  "沖縄県"
]

API用の関数を作成

./src/lib/api/auth.ts
import client from "lib/api/client"
import Cookies from "js-cookie"

import { SignUpFormData, SignInData } from "interfaces/index"

// サインアップ
export const signUp = (data: SignUpFormData) => {
  return client.post("auth", data)
}

// サインイン
export const signIn = (data: SignInData)  => {
  return client.post("auth/sign_in", data)
}

// サインアウト
export const signOut = () => {
  return client.delete("auth/sign_out", { headers: {
    "access-token": Cookies.get("_access_token"),
    "client": Cookies.get("_client"),
    "uid": Cookies.get("_uid")
  }})  
}

// 認証中ユーザーの情報を取得
export const getCurrentUser = () => {
  if (!Cookies.get("_access_token") || !Cookies.get("_client") || !Cookies.get("_uid")) return
  return client.get("auth/sessions", { headers: {
    "access-token": Cookies.get("_access_token"),
    "client": Cookies.get("_client"),
    "uid": Cookies.get("_uid")
  }})
}
./src/lib/api/users.ts
import client from "lib/api/client"
import { UpdateUserFormData} from "interfaces/index"

import Cookies from "js-cookie"

// 都道府県が同じで性別の異なるユーザー情報一覧を取得(自分以外)
export const getUsers = () => {
  return client.get("users", { headers: {
    "access-token": Cookies.get("_access_token"),
    "client": Cookies.get("_client"),
    "uid": Cookies.get("_uid")
  }})
}

// id指定でユーザー情報を個別に取得
export const getUser = (id: number | undefined) => {
  return client.get(`users/${id}`)
}

// ユーザー情報を更新
export const updateUser = (id: number | undefined | null, data: UpdateUserFormData) => {
  return client.put(`users/${id}`, data)
}
./src/lib/api/likes.ts
import client from "lib/api/client"
import { Like } from "interfaces/index"

import Cookies from "js-cookie"

// 全てのいいね情報(自分から、相手から両方)を取得
export const getLikes = () => {
  return client.get("likes", { headers: {
    "access-token": Cookies.get("_access_token"),
    "client": Cookies.get("_client"),
    "uid": Cookies.get("_uid")
  }})
}

// いいねを作成
export const createLike= (data: Like) => {
  return client.post("likes", data)
}
./src/lib/api/chat_rooms.ts
import client from "lib/api/client"
import Cookies from "js-cookie"

// マッチングしたユーザーとの全てのチャットルーム情報を取得
export const getChatRooms = () => {
  return client.get("chat_rooms", { headers: {
    "access-token": Cookies.get("_access_token"),
    "client": Cookies.get("_client"),
    "uid": Cookies.get("_uid")
  }})
}

// id指定でチャットルーム情報を個別に取得
export const getChatRoom = (id: number) => {
  return client.get(`chat_rooms/${id}`, { headers: {
    "access-token": Cookies.get("_access_token"),
    "client": Cookies.get("_client"),
    "uid": Cookies.get("_uid")
  }})
}
./src/lib/api/messages.ts
import client from "lib/api/client"
import { Message } from "interfaces/index"

// メッセージを作成
export const createMessage = (data: Message) => {
  return client.post("messages", data)
}

各種ビューを作成

各種ビューの部分を作成します。

./src/components/layouts/CommonLayout.tsx
import React from "react"

import { Container, Grid } from "@material-ui/core"
import { makeStyles } from "@material-ui/core/styles"

import Header from "components/layouts/Header"

const useStyles = makeStyles(() => ({
  container: {
    marginTop: "3rem"
  }
}))

interface CommonLayoutProps {
  children: React.ReactElement
}

// 全てのページで共通となるレイアウト
const CommonLayout = ({ children }: CommonLayoutProps) => {
  const classes = useStyles()

  return (
    <>
      <header>
        <Header />
      </header>
      <main>
        <Container maxWidth="lg" className={classes.container}>
          <Grid container justify="center">
            {children}
          </Grid>
        </Container>
      </main>
    </>
  )
}

export default CommonLayout
./src/components/layouts/Header.tsx
import React, { useContext } from "react"
import { Link } from "react-router-dom"

import { makeStyles, Theme } from "@material-ui/core/styles"

import AppBar from "@material-ui/core/AppBar"
import Toolbar from "@material-ui/core/Toolbar"
import Typography from "@material-ui/core/Typography"
import IconButton from "@material-ui/core/IconButton"
import ExitToAppIcon from "@material-ui/icons/ExitToApp"
import PersonIcon from "@material-ui/icons/Person"
import SearchIcon from "@material-ui/icons/Search"
import ChatBubbleIcon from "@material-ui/icons/ChatBubble"

import { AuthContext } from "App"

const useStyles = makeStyles((theme: Theme) => ({
  title: {
    flexGrow: 1,
    textDecoration: "none",
    color: "inherit"
  },
  linkBtn: {
    textTransform: "none",
    marginLeft: theme.spacing(1)
  }
}))

const Header: React.FC = () => {
  const { loading, isSignedIn } = useContext(AuthContext)
  const classes = useStyles()

  // 認証済みかどうかで表示ボタンを変更
  const AuthButtons = () => {
    if (!loading) {
      if (isSignedIn) {
        return (
          <>
            <IconButton
              component={Link}
              to="/users"
              edge="start"
              className={classes.linkBtn}
              color="inherit"
            >
              <SearchIcon />
            </IconButton>
            <IconButton
              component={Link}
              to="/chat_rooms"
              edge="start"
              className={classes.linkBtn}
              color="inherit"
            >
              <ChatBubbleIcon />
            </IconButton>
            <IconButton
              component={Link}
              to="/home"
              edge="start"
              className={classes.linkBtn}
              color="inherit"
            >
              <PersonIcon />
            </IconButton>
          </>
        )
      } else {
        return (
          <>
            <IconButton
              component={Link}
              to="/signin"
              edge="start"
              className={classes.linkBtn}
              color="inherit"
            >
              <ExitToAppIcon />
            </IconButton>
          </>
        )
      }
    } else {
      return <></>
    }
  }

  return (
    <>
      <AppBar position="static">
        <Toolbar>
          <Typography
            component={Link}
            to="/users"
            variant="h6"
            className={classes.title}
          >
            Sample
          </Typography>
          <AuthButtons />
        </Toolbar>
      </AppBar>
    </>
  )
}

export default Header
./src/components/pages/SignIn.tsx
import React, { useState, useContext } from "react"
import { useHistory, Link } from "react-router-dom"
import Cookies from "js-cookie"

import { makeStyles, Theme } from "@material-ui/core/styles"
import { Typography } from "@material-ui/core"
import TextField from "@material-ui/core/TextField"
import Card from "@material-ui/core/Card"
import CardContent from "@material-ui/core/CardContent"
import CardHeader from "@material-ui/core/CardHeader"
import Button from "@material-ui/core/Button"
import Box from "@material-ui/core/Box"

import { AuthContext } from "App"
import AlertMessage from "components/utils/AlertMessage"
import { signIn } from "lib/api/auth"
import { SignInData } from "interfaces/index"

const useStyles = makeStyles((theme: Theme) => ({
  container: {
    marginTop: theme.spacing(6)
  },
  submitBtn: {
    marginTop: theme.spacing(2),
    flexGrow: 1,
    textTransform: "none"
  },
  header: {
    textAlign: "center"
  },
  card: {
    padding: theme.spacing(2),
    maxWidth: 340
  },
  box: {
    marginTop: "2rem"
  },
  link: {
    textDecoration: "none"
  }
}))

// サインインページ
const SignIn: React.FC = () => {
  const classes = useStyles()
  const history = useHistory()

  const { setIsSignedIn, setCurrentUser } = useContext(AuthContext)

  const [email, setEmail] = useState<string>("")
  const [password, setPassword] = useState<string>("")
  const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false)

  const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()

    const data: SignInData = {
      email: email,
      password: password
    }

    try {
      const res = await signIn(data)
      console.log(res)

      if (res.status === 200) {
        // ログインに成功した場合はCookieに各情報を格納
        Cookies.set("_access_token", res.headers["access-token"])
        Cookies.set("_client", res.headers["client"])
        Cookies.set("_uid", res.headers["uid"])

        setIsSignedIn(true)
        setCurrentUser(res.data.data)

        history.push("/home")

        setEmail("")
        setPassword("")

        console.log("Signed in successfully!")
      } else {
        setAlertMessageOpen(true)
      }
    } catch (err) {
      console.log(err)
      setAlertMessageOpen(true)
    }
  }

  return (
    <>
      <form noValidate autoComplete="off">
        <Card className={classes.card}>
          <CardHeader className={classes.header} title="サインイン" />
          <CardContent>
            <TextField
              variant="outlined"
              required
              fullWidth
              label="メールアドレス"
              value={email}
              margin="dense"
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="パスワード"
              type="password"
              placeholder="最低6文字以上"
              value={password}
              margin="dense"
              autoComplete="current-password"
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
            />
            <div style={{ textAlign: "right"}} >
              <Button
                type="submit"
                variant="outlined"
                color="primary"
                disabled={!email || !password ? true : false} // 空欄があった場合はボタンを押せないように
                className={classes.submitBtn}
                onClick={handleSubmit}
              >
                送信
              </Button>
            </div>
            <Box textAlign="center" className={classes.box}>
              <Typography variant="body2">
                まだアカウントをお持ちでない方は
                <Link to="/signup" className={classes.link}>
                  こちら
                </Link>
                から作成してください。
              </Typography>
            </Box>
          </CardContent>
        </Card>
      </form>
      <AlertMessage // エラーが発生した場合はアラートを表示
        open={alertMessageOpen}
        setOpen={setAlertMessageOpen}
        severity="error"
        message="メールアドレスかパスワードが間違っています"
      />
    </>
  )
}

export default SignIn
./src/components/pages/SignUp.tsx
import React, { useState, useContext, useCallback } from "react"
import { useHistory } from "react-router-dom"
import Cookies from "js-cookie"
import "date-fns"
import DateFnsUtils from "@date-io/date-fns" // バージョンに注意(https://stackoverflow.com/questions/59600125/cannot-get-material-ui-datepicker-to-work)

import { makeStyles, Theme } from "@material-ui/core/styles"
import Grid from "@material-ui/core/Grid"
import TextField from "@material-ui/core/TextField"
import InputLabel from "@material-ui/core/InputLabel"
import MenuItem from "@material-ui/core/MenuItem"
import FormControl from "@material-ui/core/FormControl"
import Select from "@material-ui/core/Select"
import {
  MuiPickersUtilsProvider,
  KeyboardDatePicker
} from "@material-ui/pickers"
import Card from "@material-ui/core/Card"
import CardContent from "@material-ui/core/CardContent"
import CardHeader from "@material-ui/core/CardHeader"
import Button from "@material-ui/core/Button"
import IconButton from "@material-ui/core/IconButton"
import PhotoCamera from "@material-ui/icons/PhotoCamera"
import Box from "@material-ui/core/Box"
import CancelIcon from "@material-ui/icons/Cancel"

import { AuthContext } from "App"
import AlertMessage from "components/utils/AlertMessage"
import { signUp } from "lib/api/auth"
import { SignUpFormData } from "interfaces/index"
import { prefectures } from "data/prefectures"
import { genders } from "data/genders"

const useStyles = makeStyles((theme: Theme) => ({
  container: {
    marginTop: theme.spacing(6)
  },
  submitBtn: {
    marginTop: theme.spacing(1),
    flexGrow: 1,
    textTransform: "none"
  },
  header: {
    textAlign: "center"
  },
  card: {
    padding: theme.spacing(2),
    maxWidth: 340
  },
  inputFileButton: {
    textTransform: "none",
    color: theme.palette.primary.main
  },
  imageUploadBtn: {
    textAlign: "right"
  },
  input: {
    display: "none"
  },
  box: {
    marginBottom: "1.5rem"
  },
  preview: {
    width: "100%"
  }
}))

// サインアップページ
const SignUp: React.FC = () => {
  const classes = useStyles()
  const histroy = useHistory()

  const { setIsSignedIn, setCurrentUser } = useContext(AuthContext)

  const [name, setName] = useState<string>("")
  const [email, setEmail] = useState<string>("")
  const [password, setPassword] = useState<string>("")
  const [passwordConfirmation, setPasswordConfirmation] = useState<string>("")
  const [gender, setGender] = useState<number>()
  const [prefecture, setPrefecture] = useState<number>()
  const [birthday, setBirthday] = useState<Date | null>(
    new Date("2000-01-01T00:00:00"),
  )
  const [image, setImage] = useState<string>("")
  const [preview, setPreview] = useState<string>("")
  const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false)

  // アップロードした画像のデータを取得
  const uploadImage = useCallback((e) => {
    const file = e.target.files[0]
    setImage(file)
  }, [])

  // 画像プレビューを表示
  const previewImage = useCallback((e) => {
    const file = e.target.files[0]
    setPreview(window.URL.createObjectURL(file))
  }, [])

  // フォームデータを作成
  const createFormData = (): SignUpFormData => {
    const formData = new FormData()

    formData.append("name", name)
    formData.append("email", email)
    formData.append("password", password)
    formData.append("passwordConfirmation", passwordConfirmation)
    formData.append("gender", String(gender))
    formData.append("prefecture", String(prefecture))
    formData.append("birthday", String(birthday))
    formData.append("image", image)

    return formData
  }

  const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()

    const data = createFormData()

    try {
      const res = await signUp(data)
      console.log(res)

      if (res.status === 200) {
        Cookies.set("_access_token", res.headers["access-token"])
        Cookies.set("_client", res.headers["client"])
        Cookies.set("_uid", res.headers["uid"])

        setIsSignedIn(true)
        setCurrentUser(res.data.data)

        histroy.push("/home")

        setName("")
        setEmail("")
        setPassword("")
        setPasswordConfirmation("")
        setGender(undefined)
        setPrefecture(undefined)
        setBirthday(null)

        console.log("Signed in successfully!")
      } else {
        setAlertMessageOpen(true)
      }
    } catch (err) {
      console.log(err)
      setAlertMessageOpen(true)
    }
  }

  return (
    <>
      <form noValidate autoComplete="off">
        <Card className={classes.card}>
          <CardHeader className={classes.header} title="サインアップ" />
          <CardContent>
            <TextField
              variant="outlined"
              required
              fullWidth
              label="名前"
              value={name}
              margin="dense"
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="メールアドレス"
              value={email}
              margin="dense"
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="パスワード"
              type="password"
              value={password}
              margin="dense"
              autoComplete="current-password"
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="パスワード(確認用)"
              type="password"
              value={passwordConfirmation}
              margin="dense"
              autoComplete="current-password"
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordConfirmation(e.target.value)}
            />
            <FormControl
              variant="outlined"
              margin="dense"
              fullWidth
            >
              <InputLabel id="demo-simple-select-outlined-label">性別</InputLabel>
              <Select
                labelId="demo-simple-select-outlined-label"
                id="demo-simple-select-outlined"
                value={gender}
                onChange={(e: React.ChangeEvent<{ value: unknown }>) => setGender(e.target.value as number)}
                label="性別"
              >
                {
                  genders.map((gender: string, index: number) => 
                    <MenuItem value={index}>{gender}</MenuItem>
                  )
                }
              </Select>
            </FormControl>
            <FormControl
              variant="outlined"
              margin="dense"
              fullWidth
            >
              <InputLabel id="demo-simple-select-outlined-label">都道府県</InputLabel>
              <Select
                labelId="demo-simple-select-outlined-label"
                id="demo-simple-select-outlined"
                value={prefecture}
                onChange={(e: React.ChangeEvent<{ value: unknown }>) => setPrefecture(e.target.value as number)}
                label="都道府県"
              >
                {
                  prefectures.map((prefecture, index) => 
                    <MenuItem key={index +1} value={index + 1}>{prefecture}</MenuItem>
                  )
                }
              </Select>
            </FormControl>
            <MuiPickersUtilsProvider utils={DateFnsUtils}>
              <Grid container justify="space-around">
                <KeyboardDatePicker
                  fullWidth
                  inputVariant="outlined"
                  margin="dense"
                  id="date-picker-dialog"
                  label="誕生日"
                  format="MM/dd/yyyy"
                  value={birthday}
                  onChange={(date: Date | null) => {
                    setBirthday(date)
                  }}
                  KeyboardButtonProps={{
                    "aria-label": "change date",
                  }}
                />
              </Grid>
            </MuiPickersUtilsProvider>
            <div className={classes.imageUploadBtn}>
              <input
                accept="image/*"
                className={classes.input}
                id="icon-button-file"
                type="file"
                onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
                  uploadImage(e)
                  previewImage(e)
                }}
              />
              <label htmlFor="icon-button-file">
                <IconButton
                  color="primary"
                  aria-label="upload picture"
                  component="span"
                >
                  <PhotoCamera />
                </IconButton>
              </label>
            </div>
            {
              preview ? (
                <Box
                  className={classes.box}
                >
                  <IconButton
                    color="inherit"
                    onClick={() => setPreview("")}
                  >
                    <CancelIcon />
                  </IconButton>
                  <img
                    src={preview}
                    alt="preview img"
                    className={classes.preview}
                  />
                </Box>
              ) : null
            }
            <div style={{ textAlign: "right"}} >
              <Button
                type="submit"
                variant="outlined"
                color="primary"
                disabled={!name || !email || !password || !passwordConfirmation ? true : false} // 空欄があった場合はボタンを押せないように
                className={classes.submitBtn}
                onClick={handleSubmit}
              >
                送信
              </Button>
            </div>
          </CardContent>
        </Card>
      </form>
      <AlertMessage // エラーが発生した場合はアラートを表示
        open={alertMessageOpen}
        setOpen={setAlertMessageOpen}
        severity="error"
        message="メールアドレスかパスワードが間違っています"
      />
    </>
  )
}

export default SignUp
./src/components/pages/Users.tsx
import React, { useState, useEffect, useContext } from "react"

import { makeStyles, Theme } from "@material-ui/core/styles"
import { Grid, Typography } from "@material-ui/core"

import Dialog from "@material-ui/core/Dialog"
import DialogContent from "@material-ui/core/DialogContent"

import Avatar from "@material-ui/core/Avatar"
import Button from "@material-ui/core/Button"
import Divider from "@material-ui/core/Divider"
import FavoriteIcon from "@material-ui/icons/Favorite"
import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder"

import AlertMessage from "components/utils/AlertMessage"

import { prefectures } from "data/prefectures"
import { getUsers } from "lib/api/users"
import { getLikes, createLike } from "lib/api/likes"
import { User, Like } from "interfaces/index"

import { AuthContext } from "App"

const useStyles = makeStyles((theme: Theme) => ({
  avatar: {
    width: theme.spacing(10),
    height: theme.spacing(10)
  }
}))

// ユーザー一覧ページ
const Users: React.FC = () => {
  const { currentUser } = useContext(AuthContext)
  const classes = useStyles()

  const initialUserState: User = {
    id: 0,
    uid: "",
    provider: "",
    email: "",
    name: "",
    image: {
      url: ""
    },
    gender: 0,
    birthday: "",
    profile: "",
    prefecture: 13,
    allowPasswordChange: true
  }

  const [loading, setLoading] = useState<boolean>(true)
  const [users, setUsers] = useState<User[]>([])
  const [user, setUser] = useState<User>(initialUserState)
  const [userDetailOpen, setUserDetailOpen] = useState<boolean>(false)
  const [likedUsers, setLikedUsers] = useState<User[]>([])
  const [likes, setLikes] = useState<Like[]>([])
  const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false)

  // 生年月日から年齢を計算する 年齢 = floor((今日 - 誕生日) / 10000)
  const userAge = (): number | void => {
    const birthday = user.birthday.toString().replace(/-/g, "")
    if (birthday.length !== 8) return

    const date = new Date()
    const today = date.getFullYear() + ("0" + (date.getMonth() + 1)).slice(-2) + ("0" + date.getDate()).slice(-2)

    return Math.floor((parseInt(today) - parseInt(birthday)) / 10000)
  }

  // 都道府県
  const userPrefecture = (): string => {
    return prefectures[(user.prefecture) - 1]
  }

  // いいね作成
  const handleCreateLike = async (user: User) => {
    const data: Like = {
      fromUserId: currentUser?.id,
      toUserId: user.id
    }

    try {
      const res = await createLike(data)
      console.log(res)

      if (res?.status === 200) {
        setLikes([res.data.like, ...likes])
        setLikedUsers([user, ...likedUsers])

        console.log(res?.data.like)
      } else {
        console.log("Failed")
      }

      if (res?.data.isMatched === true) {
        setAlertMessageOpen(true)
        setUserDetailOpen(false)
      }
    } catch (err) {
      console.log(err)
    }
  }

  // ユーザー一覧を取得
  const handleGetUsers = async () => {
    try {
      const res = await getUsers()
      console.log(res)

      if (res?.status === 200) {
        setUsers(res?.data.users)
      } else {
        console.log("No users")
      }
    } catch (err) {
      console.log(err)
    }

    setLoading(false)
  }

  // いいね一覧を取得
  const handleGetLikes = async () => {
    try {
      const res = await getLikes()
      console.log(res)

      if (res?.status === 200) {
        setLikedUsers(res?.data.activeLikes)
      } else {
        console.log("No likes")
      }
    } catch (err) {
      console.log(err)
    }
  }

  useEffect(() => {
    handleGetUsers()
    handleGetLikes()
  }, [])

  // すでにいいねを押されているユーザーかどうかの判定
  const isLikedUser = (userId: number | undefined): boolean => {
    return likedUsers?.some((likedUser: User) => likedUser.id === userId)
  }

  return (
    <>
      {
        !loading ? (
          users?.length > 0 ? (
            <Grid container justify="center">
              {
                users?.map((user: User, index: number) => {
                  return (
                    <div key={index} onClick={() => {
                      setUser(user)
                      setUserDetailOpen(true)
                    }}>
                      <Grid item style={{ margin: "0.5rem", cursor: "pointer" }}>
                        <Avatar
                          alt="avatar"
                          src={user?.image.url}
                          className={classes.avatar}
                        />
                        <Typography
                          variant="body2"
                          component="p"
                          gutterBottom
                          style={{ marginTop: "0.5rem", textAlign: "center" }}
                        >
                          {user.name}
                        </Typography>
                      </Grid>
                    </div>  
                  ) 
                })
              }
            </Grid>
          ) : (
            <Typography
              component="p"
              variant="body2"
              color="textSecondary"
            >
              まだ1人もユーザーがいません。
            </Typography>
          )
        ) : (
          <></>
        )
      }
      <Dialog
        open={userDetailOpen}
        keepMounted
        onClose={() => setUserDetailOpen(false)}
      >
        <DialogContent>
          <Grid container justify="center">
            <Grid item>
              <Avatar
                alt="avatar"
                src={user?.image.url}
                className={classes.avatar}
              />
            </Grid>
          </Grid>
          <Grid container justify="center">
            <Grid item style={{ marginTop: "1rem" }}>
              <Typography variant="body1" component="p" gutterBottom style={{ textAlign: "center" }}>
                {user.name} {userAge()}歳 ({userPrefecture()})
              </Typography>
              <Divider />
              <Typography
                variant="body2"
                component="p"
                gutterBottom
                style={{ marginTop: "0.5rem", fontWeight: "bold" }}
              >
                自己紹介
              </Typography>
              <Typography variant="body2" component="p" color="textSecondary" style={{ marginTop: "0.5rem" }}>
                {user.profile ? user.profile : "よろしくお願いします。" }
              </Typography>
            </Grid>
          </Grid>
          <Grid container justify="center">
            <Button
              variant="outlined"
              onClick={() => isLikedUser(user.id) ? void(0) : handleCreateLike(user)}
              color="secondary"
              startIcon={isLikedUser(user.id) ? <FavoriteIcon /> : <FavoriteBorderIcon />}
              disabled={isLikedUser(user.id) ? true : false}
              style={{ marginTop: "1rem", marginBottom: "1rem" }}
            >
              {isLikedUser(user.id) ? "いいね済み" : "いいね"}
            </Button>
          </Grid>
        </DialogContent>
      </Dialog>
      <AlertMessage
        open={alertMessageOpen}
        setOpen={setAlertMessageOpen}
        severity="success"
        message="マッチングが成立しました!"
      />
    </>
  )
}

export default Users
./src/components/pages/ChatRooms.tsx
import React, { useEffect, useState } from "react"
import { Link } from "react-router-dom"

import { makeStyles, Theme } from "@material-ui/core/styles"
import { Grid, Typography } from "@material-ui/core"
import Avatar from "@material-ui/core/Avatar"

import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import Divider from '@material-ui/core/Divider';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';

import { getChatRooms } from "lib/api/chat_rooms"
import { ChatRoom } from "interfaces/index" 

const useStyles = makeStyles((theme: Theme) => ({
  root: {
    flexGrow: 1,
    minWidth: 340,
    maxWidth: "100%"
  },
  link: {
    textDecoration: "none",
    color: "inherit"
  }
}))

// チャットルーム一覧ページ
const ChatRooms: React.FC = () => {
  const classes = useStyles()

  const [loading, setLoading] = useState<boolean>(true)
  const [chatRooms, setChatRooms] = useState<ChatRoom[]>([])

  const handleGetChatRooms = async () => {
    try {
      const res = await getChatRooms()

      if (res.status === 200) {
        setChatRooms(res.data.chatRooms)
      } else {
        console.log("No chat rooms")
      }
    } catch (err) {
      console.log(err)
    }

    setLoading(false)
  }

  useEffect(() => {
    handleGetChatRooms()
  }, [])

  return (
    <>
      {
        !loading ? (
          chatRooms.length > 0 ? (
            chatRooms.map((chatRoom: ChatRoom, index: number) => {
              return (
                <Grid container key={index} justify="center">
                  <List>
                    {/* 個別のチャットルームへ飛ばす */}
                    <Link to={`/chatroom/${chatRoom.chatRoom.id}`} className={classes.link}>
                      <div className={classes.root}>
                        <ListItem alignItems="flex-start" style={{padding: 0 }}>
                          <ListItemAvatar>
                            <Avatar
                              alt="avatar"
                              src={chatRoom.otherUser.image.url}
                            />
                          </ListItemAvatar>
                          <ListItemText
                            primary={chatRoom.otherUser.name}
                            secondary={
                              <div style={{ marginTop: "0.5rem" }}>
                                <Typography
                                  component="span"
                                  variant="body2"
                                  color="textSecondary"
                                >
                                  {chatRoom.lastMessage === null ? "まだメッセージはありません。" : chatRoom.lastMessage.content.length > 30 ? chatRoom.lastMessage.content.substr(0, 30) + "..." : chatRoom.lastMessage.content}
                                </Typography>
                              </div>
                            }
                          />
                        </ListItem>
                      </div>
                    </Link>
                    <Divider component="li" />
                  </List>
                </Grid>
              )
            })
          ) : (
            <Typography
              component="p"
              variant="body2"
              color="textSecondary"
            >
              マッチング中の相手はいません。
            </Typography>
          )
        ) : (
          <></>
        )
      }
    </>
  )
}

export default ChatRooms
./src/components/pages/ChatRoom.tsx
import React, { useEffect, useState, useContext } from "react"
import { RouteComponentProps } from "react-router-dom"

import { makeStyles, Theme } from "@material-ui/core/styles"
import { Grid, Typography } from "@material-ui/core"
import Avatar from "@material-ui/core/Avatar"
import TextField from "@material-ui/core/TextField"
import Box from "@material-ui/core/Box"
import Button from "@material-ui/core/Button"
import SendIcon from "@material-ui/icons/Send"

import { getChatRoom } from "lib/api/chat_rooms"
import { createMessage } from "lib/api/messages"
import { User, Message } from "interfaces/index"

import { AuthContext } from "App"

const useStyles = makeStyles((theme: Theme) => ({
  avatar: {
    width: theme.spacing(10),
    height: theme.spacing(10),
    margin: "0 auto"
  },
  formWrapper : {
    padding: "2px 4px",
    display: "flex",
    alignItems: "center",
    width: 340
  },
  textInputWrapper : {
    width: "100%"
  },
  button: {
    marginLeft: theme.spacing(1)
  }
}))

type ChatRoomProps = RouteComponentProps<{ id: string }>

// 個別のチャットルームページ
const ChatRoom: React.FC<ChatRoomProps> = (props) => {
  const classes = useStyles()

  const { currentUser } = useContext(AuthContext)
  const id = parseInt(props.match.params.id) // URLからidを取得

  const [loading, setLoading] = useState<boolean>(true)
  const [otherUser, setOtherUser] = useState<User>()
  const [messages, setMeesages] = useState<Message[]>([])
  const [content, setContent] = useState<string>("")

  const handleGetChatRoom = async () => {
    try {
      const res = await getChatRoom(id)
      console.log(res)

      if (res?.status === 200) {
        setOtherUser(res?.data.otherUser)
        setMeesages(res?.data.messages)
      } else {
        console.log("No other user")
      }
    } catch (err) {
      console.log(err)
    }

    setLoading(false)
  }

  useEffect(() => {
    handleGetChatRoom()
  }, [])

  const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()

    const data: Message = {
      chatRoomId: id,
      userId: currentUser?.id,
      content: content
    }

    try {
      const res = await createMessage(data)
      console.log(res)

      if (res.status === 200) {
        setMeesages([...messages, res.data.message])
        setContent("")
      }
    } catch (err) {
      console.log(err)
    }
  }

  // Railsから渡ってくるtimestamp(ISO8601)をdatetimeに変換
  const iso8601ToDateTime = (iso8601: string) => {
    const date = new Date(Date.parse(iso8601))
    const year = date.getFullYear()
    const month = date.getMonth() + 1
    const day = date.getDate()
    const hour = date.getHours()
    const minute = date.getMinutes()

    return year + "" + month + "" + day + "" + hour + "" + minute + ""
  }

  return (
    <>
      {
        !loading ? (
          <div style={{ maxWidth: 360 }}>
            <Grid container justify="center" style={{ marginBottom: "1rem" }}>
              <Grid item>
                <Avatar
                  alt="avatar"
                  src={otherUser?.image.url || ""}
                  className={classes.avatar}
                />
                <Typography
                  variant="body2"
                  component="p"
                  gutterBottom
                  style={{ marginTop: "0.5rem", marginBottom: "1rem", textAlign: "center" }}
                >
                  {otherUser?.name}
                </Typography>
              </Grid>
            </Grid>
            {
              messages.map((message: Message, index: number) => {
                return (
                  <Grid key={index} container justify={message.userId === otherUser?.id ? "flex-start" : "flex-end"}>
                    <Grid item>
                      <Box
                        borderRadius={message.userId === otherUser?.id ? "30px 30px 30px 0px" : "30px 30px 0px 30px"}
                        bgcolor={message.userId === otherUser?.id ? "#d3d3d3" : "#ffb6c1"}
                        color={message.userId === otherUser?.id ? "#000000" : "#ffffff"}
                        m={1}
                        border={0}
                        style={{ padding: "1rem" }}
                      >
                        <Typography variant="body1" component="p">
                          {message.content}
                        </Typography>
                      </Box>
                      <Typography
                        variant="body2"
                        component="p"
                        color="textSecondary"
                        style={{ textAlign: message.userId === otherUser?.id ? "left" : "right" }}
                      >
                        {iso8601ToDateTime(message.createdAt?.toString() || "100000000")}
                      </Typography>
                    </Grid>
                  </Grid>
                )
              })
            }
            <Grid container justify="center" style={{ marginTop: "2rem" }}>
              <form className={classes.formWrapper}  noValidate autoComplete="off">
                <TextField
                  required
                  multiline
                  value={content}
                  onChange={(e: React.ChangeEvent<HTMLInputElement>) => setContent(e.target.value)}
                  className={classes.textInputWrapper}
                />
                <Button
                  variant="contained"
                  color="primary"
                  disabled={!content ? true : false}
                  onClick={handleSubmit}
                  className={classes.button}
                >
                  <SendIcon />
                </Button>
              </form>
            </Grid>
          </div>
        ) : (
          <></>
        )
      }
    </>
  )
}

export default ChatRoom
./src/components/pages/Home.tsx
import React, { useContext, useEffect, useState, useCallback } from "react"
import { useHistory } from "react-router-dom"
import Cookies from "js-cookie"

import { makeStyles, Theme } from "@material-ui/core/styles"
import { Grid, Typography } from "@material-ui/core"
import Card from "@material-ui/core/Card"
import CardContent from "@material-ui/core/CardContent"
import IconButton from "@material-ui/core/IconButton"
import SettingsIcon from "@material-ui/icons/Settings"

import Dialog from "@material-ui/core/Dialog"
import TextField from "@material-ui/core/TextField"
import DialogActions from "@material-ui/core/DialogActions"
import DialogContent from "@material-ui/core/DialogContent"
import DialogTitle from "@material-ui/core/DialogTitle"

import InputLabel from "@material-ui/core/InputLabel"
import MenuItem from "@material-ui/core/MenuItem"
import FormControl from "@material-ui/core/FormControl"
import Select from "@material-ui/core/Select"

import PhotoCamera from "@material-ui/icons/PhotoCamera"
import Box from "@material-ui/core/Box"
import CancelIcon from "@material-ui/icons/Cancel"
import ExitToAppIcon from "@material-ui/icons/ExitToApp"
import Button from "@material-ui/core/Button"

import Avatar from "@material-ui/core/Avatar"
import Divider from "@material-ui/core/Divider"

import { AuthContext } from "App"
import { prefectures } from "data/prefectures"

import { signOut } from "lib/api/auth"
import { getUser, updateUser } from "lib/api/users"
import { UpdateUserFormData } from "interfaces/index"

const useStyles = makeStyles((theme: Theme) => ({
  avatar: {
    width: theme.spacing(10),
    height: theme.spacing(10)
  },
  card: {
    width: 340
  },
  imageUploadBtn: {
    textAlign: "right"
  },
  input: {
    display: "none"
  },
  box: {
    marginBottom: "1.5rem"
  },
  preview: {
    width: "100%"
  }
}))

// ホーム(マイページ的な)
const Home: React.FC = () => {
  const { isSignedIn, setIsSignedIn, currentUser, setCurrentUser } = useContext(AuthContext)

  const classes = useStyles()
  const histroy = useHistory()

  const [editFormOpen, setEditFormOpen] = useState<boolean>(false)
  const [name, setName] = useState<string | undefined>(currentUser?.name)
  const [prefecture, setPrefecture] = useState<number | undefined>(currentUser?.prefecture || 0)
  const [profile, setProfile] = useState<string | undefined>(currentUser?.profile)
  const [image, setImage] = useState<string>("")
  const [preview, setPreview] = useState<string>("")

  // アップロードした画像の情報を取得
  const uploadImage = useCallback((e) => {
    const file = e.target.files[0]
    setImage(file)
  }, [])

  // 画像プレビュー
  const previewImage = useCallback((e) => {
    const file = e.target.files[0]
    setPreview(window.URL.createObjectURL(file))
  }, [])

  // 生年月日から年齢を計算する 年齢 = floor((今日 - 誕生日) / 10000)
  const currentUserAge = (): number | void => {
    const birthday = currentUser?.birthday.toString().replace(/-/g, "") || ""
    if (birthday.length !== 8) return

    const date = new Date()
    const today = date.getFullYear() + ("0" + (date.getMonth() + 1)).slice(-2) + ("0" + date.getDate()).slice(-2)

    return Math.floor((parseInt(today) - parseInt(birthday)) / 10000)
  }

  // 都道府県
  const currentUserPrefecture = (): string => {
    return prefectures[(currentUser?.prefecture || 0) - 1]
  }

  const createFormData = (): UpdateUserFormData => {
    const formData = new FormData()

    formData.append("name", name || "")
    formData.append("prefecture", String(prefecture))
    formData.append("profile", profile || "")
    formData.append("image", image)

    return formData
  }

  const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()

    const data = createFormData()

    try {
      const res = await updateUser(currentUser?.id, data)
      console.log(res)

      if (res.status === 200) {
        setEditFormOpen(false)
        setCurrentUser(res.data.user)

        console.log("Update user successfully!")
      } else {
        console.log(res.data.message)
      }
    } catch (err) {
      console.log(err)
      console.log("Failed in updating user!")
    }
  }

  // サインアウト用の処理
  const handleSignOut = async (e: React.MouseEvent<HTMLButtonElement>) => {
    try {
      const res = await signOut()

      if (res.data.success === true) {
        // Cookieから各情報を削除
        Cookies.remove("_access_token")
        Cookies.remove("_client")
        Cookies.remove("_uid")

        setIsSignedIn(false)
        histroy.push("/signin")

        console.log("Succeeded in sign out")
      } else {
        console.log("Failed in sign out")
      }
    } catch (err) {
      console.log(err)
    }
  }

  return (
    <>
      {
        isSignedIn && currentUser ? (
          <>
            <Card className={classes.card}>
              <CardContent>
                <Grid container justify="flex-end">
                  <Grid item>
                    <IconButton
                      onClick={() => setEditFormOpen(true)}
                    >
                      <SettingsIcon
                        color="action"
                        fontSize="small"
                      />
                    </IconButton>
                  </Grid>
                </Grid>
                <Grid container justify="center">
                  <Grid item>
                    <Avatar
                      alt="avatar"
                      src={currentUser?.image.url}
                      className={classes.avatar}
                    />
                  </Grid>
                </Grid>
                <Grid container justify="center">
                  <Grid item style={{ marginTop: "1.5rem"}}>
                    <Typography variant="body1" component="p" gutterBottom>
                      {currentUser?.name} {currentUserAge()}歳 ({currentUserPrefecture()})
                    </Typography>
                    <Divider style={{ marginTop: "0.5rem"}}/>
                    <Typography
                      variant="body2"
                      component="p"
                      gutterBottom
                      style={{ marginTop: "0.5rem", fontWeight: "bold" }}
                    >
                      自己紹介
                    </Typography>
                    {
                      currentUser.profile ? (
                        <Typography variant="body2" component="p" color="textSecondary">
                          {currentUser.profile}
                        </Typography>
                      ): (
                        <Typography variant="body2" component="p" color="textSecondary">
                          よろしくお願いいたします。
                        </Typography>
                      )
                    }
                    <Button
                      variant="outlined"
                      onClick={handleSignOut}
                      color="primary"
                      fullWidth
                      startIcon={<ExitToAppIcon />}
                      style={{ marginTop: "1rem"}}
                    >
                      サインアウト
                    </Button>
                  </Grid>
                </Grid>
              </CardContent>
            </Card>
            <form noValidate autoComplete="off">
              <Dialog
                open={editFormOpen}
                keepMounted
                onClose={() => setEditFormOpen(false)}
              >
                <DialogTitle style={{ textAlign: "center"}}>
                  プロフィールの変更
                </DialogTitle>
                <DialogContent>
                  <TextField
                    variant="outlined"
                    required
                    fullWidth
                    label="名前"
                    value={name}
                    margin="dense"
                    onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
                  />
                  <FormControl
                    variant="outlined"
                    margin="dense"
                    fullWidth
                  >
                    <InputLabel id="demo-simple-select-outlined-label">都道府県</InputLabel>
                    <Select
                      labelId="demo-simple-select-outlined-label"
                      id="demo-simple-select-outlined"
                      value={prefecture}
                      onChange={(e: React.ChangeEvent<{ value: unknown }>) => setPrefecture(e.target.value as number)}
                      label="都道府県"
                    >
                      {
                        prefectures.map((prefecture, index) => 
                          <MenuItem key={index + 1} value={index + 1}>{prefecture}</MenuItem>
                        )
                      }
                    </Select>
                  </FormControl>
                  <TextField
                    placeholder="1000文字以内で書いてください。"
                    variant="outlined"
                    multiline
                    fullWidth
                    label="自己紹介"
                    rows="8"
                    value={profile}
                    margin="dense"
                    onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
                      setProfile(e.target.value)
                    }}
                  />
                  <div className={classes.imageUploadBtn}>
                    <input
                      accept="image/*"
                      className={classes.input}
                      id="icon-button-file"
                      type="file"
                      onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
                        uploadImage(e)
                        previewImage(e)
                      }}
                    />
                    <label htmlFor="icon-button-file">
                      <IconButton
                        color="primary"
                        aria-label="upload picture"
                        component="span"
                      >
                        <PhotoCamera />
                      </IconButton>
                    </label>
                  </div>
                  {
                    preview ? (
                      <Box
                        className={classes.box}
                      >
                        <IconButton
                          color="inherit"
                          onClick={() => setPreview("")}
                        >
                          <CancelIcon />
                        </IconButton>
                        <img
                          src={preview}
                          alt="preview img"
                          className={classes.preview}
                        />
                      </Box>
                    ) : null
                  }
                </DialogContent>
                <DialogActions>
                  <Button
                    onClick={handleSubmit}
                    color="primary"
                    disabled={!name || !profile ? true : false}
                  >
                    送信
                  </Button>
                </DialogActions>
              </Dialog>
            </form>
          </>
        ) : (
          <></>
        )
      }
    </>
  )
}

export default Home
./src/components/pages/NotFound.tsx
import React from "react"

// 存在しないページにアクセスされた場合の表示
const NotFound: React.FC = () => {
  return (
    <h2>404 Not Found</h2>
  )
}

export default NotFound
./src/components/App.tsx
import React, { useState, useEffect, createContext } from "react"
import { BrowserRouter as Router, Switch, Route, Redirect } from "react-router-dom"

import CommonLayout from "components/layouts/CommonLayout"
import Home from "components/pages/Home"
import ChatRooms from "components/pages/ChatRooms"
import ChatRoom from "components/pages/ChatRoom"
import Users from "components/pages/Users"
import SignUp from "components/pages/SignUp"
import SignIn from "components/pages/SignIn"
import NotFound from "components/pages/NotFound"

import { getCurrentUser } from "lib/api/auth"
import { User } from "interfaces/index"

// グローバルで扱う変数・関数(contextで管理)
export const AuthContext = createContext({} as {
  loading: boolean
  isSignedIn: boolean
  setIsSignedIn: React.Dispatch<React.SetStateAction<boolean>>
  currentUser: User | undefined
  setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>>
})

const App: React.FC = () => {
  const [loading, setLoading] = useState<boolean>(true)
  const [isSignedIn, setIsSignedIn] = useState<boolean>(false)
  const [currentUser, setCurrentUser] = useState<User | undefined>()

  const handleGetCurrentUser = async () => {
    try {
      const res = await getCurrentUser()
      console.log(res)

      if (res?.status === 200) {
        setIsSignedIn(true)
        setCurrentUser(res?.data.currentUser)
      } else {
        console.log("No current user")
      }
    } catch (err) {
      console.log(err)
    }

    setLoading(false)
  }

  useEffect(() => {
    handleGetCurrentUser()
  }, [setCurrentUser])

  // ユーザーが認証済みかどうかでルーティングを決定
  // 未認証だった場合は「/signin」ページに促す
  const Private = ({ children }: { children: React.ReactElement }) => {
    if (!loading) {
      if (isSignedIn) {
        return children
      } else {
        return <Redirect to="/signin" />
      }
    } else {
      return <></>
    }
  }

  return (
    <Router>
      <AuthContext.Provider value={{ loading, isSignedIn, setIsSignedIn, currentUser, setCurrentUser }}>
        <CommonLayout>
          <Switch>
            <Route exact path="/signup" component={SignUp} />
            <Route exact path="/signin" component={SignIn} />
            <Private>
              <Switch>
                <Route exact path="/home" component={Home} />
                <Route exact path="/users" component={Users} />
                <Route exact path="/chat_rooms" component={ChatRooms} />
                <Route path="/chatroom/:id" component={ChatRoom} />
                <Route component={NotFound} />
              </Switch>
            </Private>
          </Switch>
        </CommonLayout>
      </AuthContext.Provider>
    </Router>
  )
}

export default App

動作確認

あとは全体的に問題が無いか動作確認しましょう。

サインアップ

スクリーンショット 2021-06-10 1.31.01.png

サインイン

スクリーンショット 2021-06-10 1.31.33.png

ホーム

スクリーンショット 2021-06-10 1.31.53.png

ユーザー一覧

スクリーンショット 2021-06-10 1.32.15.png

マッチング一覧

スクリーンショット 2021-06-10 1.32.33.png

チャットルーム

スクリーンショット 2021-06-10 1.32.56.png

※ユーザーデータなどは各自適当に入れてください。

あとがき

以上、Rails APIモード + React + Material-UIでマッチングアプリを作ってみました。

だいぶコード量が多いので、所々で雑になってしまっているかもしれません。特にエラーハンドリングの部分とか全然できてないと思います...。あとスタイルの当て方もだいぶ雑な気が(笑)

今回はあくまでサンプルという事で、細かい部分の調整は各自お好みでお願いします。

タイトルなし.gif

こんな感じで趣味カードみたいなものを追加するとよりそれっぽくなるかも。これベースに色々工夫してみていただけると幸いです。

一応、GitHubのリンクも載せておくので、もし動かない部分などあったらそちらと照らし合わせて間違ってる部分は無いか確認してください。

275
287
2

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
275
287