概要
タイトル通り。バックエンドにRails(API)、フロントエンドにReactを採用したマッチングアプリ的なものを作ってみたのでアウトプットしておきます。
完成イメージ
割とそれっぽい雰囲気になってます。
使用技術
- バックエンド
- 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
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"]
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:
#!/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 "$@"
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
gem "rails", "~> 6"
# 空欄で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を編集
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
localhost:3001 にアクセスしていつもの画面が表示されればOK。
テストAPIを作成
動作確認用のテストAPiを作成します。
$ docker-compose run api rails g controller api/v1/test
class Api::V1::TestController < ApplicationController
def index
render json: { status: 200, message: "Hello World!"}
end
end
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が存在するのでそちらを使いましょう。
gem 'rack-cors'
今回はAPIモードで作成しているため、すでにGemfile内に記載されているはず。(26行目くらい)
そちらのコメントアウトを外すだけでOKです。
$ docker-compose build
Gemfileを更新したので再度ビルド。
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を修正
{
"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」を次のように変更します。
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")
)
import React from "react"
const App: React.FC = () => {
return (
<h1>Hello World!</h1>
)
}
export default App
一旦、動作確認してみましょう。
$ yarn start
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で受け取ったレスポンスの値をスネークケース→キャメルケースに変換、または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ
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」というライブラリを使わせてもらっています。
import client from "lib/api/client"
// 動作確認用
export const execTest = () => {
return client.get("/test")
}
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
再び localhost:3000 にアクセスして「Hello World!」と返ってくればRails側との通信に成功です。
もしダメな場合、大抵はDockerのコンテナを再起動していないなどが原因(config/~をいじくったため、反映させるためには再起動する必要がある)なので、
$ docker-compose down
$ docker-compose up -d
などで再起動をかけてください。
認証機能を作成
環境構築が済んだので、認証機能を作成していきます。
Rails
今回、認証機能は「dvise」および「devise_token_auth」というgemを使って実装します。
deviseをインストール
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」という設定ファイルが自動生成されているはずなので次のように変更してください。
# 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」を次のように修正します。
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.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
# アカウント作成用コントローラー
class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController
private
def sign_up_params
params.permit(:email, :password, :password_confirmation, :name)
end
end
# 認証確認用コントローラー
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
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」としなければならない点に注意です。
ルーティングを設定
ルーティングの設定もお忘れなく。
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/◯○
- 型定義用のライブラリ
型定義
// サインアップ
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用の関数を作成
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内に含める事とします。
各種ビューを作成
各種ビューの部分を作成します。
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
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
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
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
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
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
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
動作確認
サインアップ
サインイン
トップページ
アラートメッセージ
特に問題が無さそうであれば認証機能は完成です。
マッチング機能を作成
だいぶそれっぽい雰囲気になってきたので、最後にマッチング機能を作成していきます。
Rails
マッチング機能に関しては中間テーブルなどを活用する事で実現していきます。
テーブル設計
全体的なテーブルはこんな感じです。
※雑ですみません...。
Userモデルにカラムを追加
deviseで作成したUserモデルに
- name
- image
といったカラムがデフォルトで入っていますが、マッチングアプリという観点からするとこれだけの情報ではやや物足りないため、
- gender(性別)
- birthday(誕生日)
- prefecture(都道府県)
- profile(自己紹介)
といったカラムを別途追加していきたいと思います。
$ docker-compose run api rails g migration AddColumnsToUsers
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」を使用します。
gem 'carrierwave'
Gemfileを更新したので再度ビルド。
$ docker-compose build
アップローダーを作成。
$ docker-compose run api rails g uploader Image
すると「./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
CarrierWave.configure do |config|
config.asset_host = "http://localhost:3001"
config.storage = :file
config.cache_storage = :file
end
Userモデルにアップローダーをマウントします。
# 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」内のストロングパラメータに先ほど追加したカラムを記述しておきます。
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
これで準備は完了です。
あとは動作確認のためルートディレクトリに適当な画像を「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
マイグレーションファイルを次のように変更。
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モデルとのリレーションを作成しましょう。
# 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
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
マイグレーションファイルをそれぞれ次のように変更。
class CreateChatRooms < ActiveRecord::Migration[6.1]
def change
create_table :chat_rooms do |t|
t.timestamps
end
end
end
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、それぞれのリレーションを作成しましょう。
# 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
class ChatRoom < ApplicationRecord
has_many :chat_room_users
has_many :users, through: :chat_room_users # 中間テーブルのchat_room_usersを介してusersを取得
end
class ChatRoomUser < ApplicationRecord
belongs_to :chat_room
belongs_to :user
end
Messageモデルを作成
あとはメッセージそのものとなるMessageモデルを作成します。なお、メッセージにはどの部屋のものなのか、誰が送信したものなのかといった情報も一緒に記録しておきたいところです。
$ docker-compose run api rails g model Message
マイグレーションファイルを次のように変更。
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とそれぞれリレーションを作成しましょう。
# 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
class ChatRoom < ApplicationRecord
has_many :chat_room_users
has_many :users, through: :chat_room_users
# 以下を追記
has_many :messages
end
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
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
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
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
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側の準備は全て完了です。
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
型定義
// サインアップ
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
}
マスターデータを作成
性別や都道府県といった不変的な情報はマスターデータとして保持しておきます。
// 性別のマスターデータ
export const genders: string[] = [
"男性",
"女性",
"その他"
]
// 都道府県のマスターデータ
export const prefectures: string[] = [
"北海道",
"青森県",
"岩手県",
"宮城県",
"秋田県",
"山形県",
"福島県",
"茨城県",
"栃木県",
"群馬県",
"埼玉県",
"千葉県",
"東京都",
"神奈川県",
"新潟県",
"富山県",
"石川県",
"福井県",
"山梨県",
"長野県",
"岐阜県",
"静岡県",
"愛知県",
"三重県",
"滋賀県",
"京都府",
"大阪府",
"兵庫県",
"奈良県",
"和歌山県",
"鳥取県",
"島根県",
"岡山県",
"広島県",
"山口県",
"徳島県",
"香川県",
"愛媛県",
"高知県",
"福岡県",
"佐賀県",
"長崎県",
"熊本県",
"大分県",
"宮崎県",
"鹿児島県",
"沖縄県"
]
API用の関数を作成
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")
}})
}
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)
}
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)
}
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")
}})
}
import client from "lib/api/client"
import { Message } from "interfaces/index"
// メッセージを作成
export const createMessage = (data: Message) => {
return client.post("messages", data)
}
各種ビューを作成
各種ビューの部分を作成します。
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
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
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
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
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
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
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
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
import React from "react"
// 存在しないページにアクセスされた場合の表示
const NotFound: React.FC = () => {
return (
<h2>404 Not Found</h2>
)
}
export default NotFound
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
動作確認
あとは全体的に問題が無いか動作確認しましょう。
サインアップ
サインイン
ホーム
ユーザー一覧
マッチング一覧
チャットルーム
※ユーザーデータなどは各自適当に入れてください。
あとがき
以上、Rails APIモード + React + Material-UIでマッチングアプリを作ってみました。
だいぶコード量が多いので、所々で雑になってしまっているかもしれません。特にエラーハンドリングの部分とか全然できてないと思います...。あとスタイルの当て方もだいぶ雑な気が(笑)
今回はあくまでサンプルという事で、細かい部分の調整は各自お好みでお願いします。
こんな感じで趣味カードみたいなものを追加するとよりそれっぽくなるかも。これベースに色々工夫してみていただけると幸いです。
一応、GitHubのリンクも載せておくので、もし動かない部分などあったらそちらと照らし合わせて間違ってる部分は無いか確認してください。