3/10/2023
3/12/2023
https://github.com/ryota-sb/images-uploader
/docker-compose.yml
version: "3"
services:
frontend:
build: ./frontend/
volumes:
- ./frontend/app:/usr/src/app
command: "yarn dev"
ports:
- "8000:3000"
backend:
build: ./backend/
volumes:
- ./backend:/app
command: /bin/sh -c "rm -f tmp/pids/server.pid && rails s -p 3000 -b '0.0.0.0'"
ports:
- "3000:3000"
depends_on:
- db
tty: true
stdin_open: true
db:
image: postgres
volumes:
- ./postgres:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
/frontend/Dockerfile
FROM node:16-bullseye-slim
WORKDIR /usr/src/app
参考記事 →
※ backendディレクトリを作成し、その中にDockerfile、Gemfile、Gemfile.lock、entrypoint.shを作成してください。
各コンテナの立ち上げ(frontend、backend、db)
$ docker-compose up -d --build
Next.jsのプロジェクトファイル作成(プロジェクト名は、appにしてください)
$ docker-compose exec frontend yarn create next-app --ts -e with-tailwindcss
$ docker-compose exec backend rails g Post model title:string image:string
/backend/app/models/post.rb
class Post < ApplicationRecord
mount_uploader :image, ImageUploader
end
mount_uploaderは、PostモデルのimageカラムとCarrierWaveのImageUploaderを紐付けるための記述です。
$ docker-compose exec backend rails g controller api/v1/posts index create update
/backend/app/controllers/api/v1/posts.rb
class Api::V1::PostsController < ApplicationController
def index
posts = Post.all
render json: posts
end
def create
post = Post.create(post_params)
if post.save
render json: post
else
render json: post.errors, status: :unprocessable_entity
end
end
def update
post = Post.find(params[:id])
if post.update(post_params)
render json: post
else
render json: post.errors, status: :unprocessable_entity
end
end
private
def post_params
params.require(:post).permit(:title, :image)
end
end
/backend/config/routes.rb
Rails.application.routes.draw do
namespace "api" do
namespace "v1" do
resources :posts, only: [:index, :create, :update]
end
end
end
/backend/config/environments/development.rb
Rails.application.configure do
config.cache_classes = false
config.eager_load = false
config.consider_all_requests_local = true
config.hosts << "backend" # 追記
... 省略
end
ホスト名には、RailsAPIのコンテナ名を指定する
/backend/Gemfile
gem 'carrierwave'
$ docker-compose exec backend bundle install
$ docker-compose exec backend rails g uploader Image
/backend/app/uploders/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 gif png)
end
end
carrierwave.rbを新規作成する
/backend/config/initializers/carrierwave.rb
CarrierWave.configure do |config|
config.asset_host = "http://backend:3000"
config.storage = :file
config.cache_storage = :file
end
asset_host に設定する値は、localhostではなく、RailsAPIのコンテナ名のbackendを指定する
/backend/Gemfile
gem 'rack-cors'
/backend/config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://localhost:8000'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
origins にローカル環境のオリジン名を指定します
React Hook Form インストール
$ docker-compose exec frontend npm install react-hook-form
types/index.tsを作成する
/frontend/app/types/index.ts
export type InputValue = {
title: string;
image: { url: string };
};
export type PostData = InputValue & {
id: number;
created_at: Date;
update_at: Date;
};
/frontend/app/next.config.js
module.exports = {
reactStrictMode: true,
images: {
domains: ["backend"],
},
};
images: { ... } を追記し、domainsにRailsAPIのコンテナ名のbackendを指定する
完成コード
/frontend/app/pages/index.tsx
import { NextPage } from "next";
import Head from "next/head";
import Image from "next/image";
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import { InputValue, PostData } from "../types";
const Home: NextPage = () => {
const [image, setImage] = useState<File | null>(null);
const [posts, setPosts] = useState([]);
const router = useRouter();
// useFormの中の必要なメソッドを使用できるように設定
const {
register,
handleSubmit,
formState: { errors },
} = useForm<InputValue>({
mode: "onChange",
defaultValues: { title: "", image: { url: "" } },
});
// フォームのファイル選択画像で選択されたファイルデータをsetImageに追加
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImage(file);
};
// ボタンを押した時にFormDataに入力内容を追加し、RailsAPIにPOSTして登録する
const onSubmit: SubmitHandler<InputValue> = async (inputValue) => {
const formData = new FormData();
formData.append("post[title]", inputValue.title);
formData.append("post[image]", image!, image!.name);
const response = await fetch("http://localhost:3000/api/v1/posts", {
method: "POST",
body: formData,
});
const data = await response.json();
router.reload();
};
// postを全取得する
const getPosts = async () => {
const response = await fetch("http://localhost:3000/api/v1/posts");
const data = await response.json();
setPosts(data);
};
useEffect(() => {
getPosts();
}, []);
return (
<div className="flex min-h-screen flex-col items-center justify-center py-2">
<Head>
<title>Create Next App</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div>
<h1 className="text-5xl my-10">Images Uploader</h1>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-y-3">
<div className="flex flex-col gap-y-3">
<label htmlFor="title">タイトル</label>
<input
type="text"
{...register("title", { required: true })}
className="border border-black py-2 px-4"
/>
{errors.title && (
<h1 className="text-red-500">タイトルは必須です</h1>
)}
</div>
<div className="flex flex-col gap-y-3">
<label htmlFor="image">画像アップロード</label>
<input
type="file"
{...register("image", { required: true })}
className="border border-black py-2 px-4"
onChange={onFileInputChange}
/>
{errors.image && <h1 className="text-red-500">画像は必須です</h1>}
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="mt-8 bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded"
>
追加する
</button>
</div>
</form>
<div className="grid grid-cols-3">
{posts &&
posts.map((post: PostData) => (
<div key={post.id}>
<h1>{post.title}</h1>
<Image src={post.image.url} width={300} height={300} alt={""} />
</div>
))}
</div>
</div>
</div>
);
};
export default Home;
useState、useEffectで状態管理
import { useState, useEffect } from "react";
// 画像の状態管理
const [image, setImage] = useState<File | null>(null);
// 投稿データの状態管理
const [posts, setPosts] = useState<PostData[]>([]);
useEffect(() => {
getPosts();
}, []);
ファイル選択された時に選択された画像データをsetImageを使って、image(状態変数)にデータを入れる
// フォームのファイル選択画像で選択されたファイルデータをsetImageに追加
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImage(file);
};
React Hook Form 関連の関数定義
// useFormの中の必要なメソッドを使用できるように設定
const {
register,
handleSubmit,
formState: { errors },
} = useForm<InputValue>({
mode: "onChange",
defaultValues: { title: "", image: { url: "" } },
});
// ボタンを押した時にFormDataに入力内容を追加し、RailsAPIにPOSTして登録する
const onSubmit: SubmitHandler<InputValue> = async (inputValue) => {
const formData = new FormData();
formData.append("post[title]", inputValue.title);
formData.append("post[image]", image!, image!.name);
const response = await fetch("http://localhost:3000/api/v1/posts", {
method: "POST",
body: formData,
});
const data = await response.json();
// 画面リロード
router.reload();
};
React Hook Form のregisterとhandleSubmitとerrorsを使用して、フォーム作成
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-y-3">
<div className="flex flex-col gap-y-3">
<label htmlFor="title">タイトル</label>
<input
type="text"
{...register("title", { required: true })}
className="border border-black py-2 px-4"
/>
{errors.title && <h1 className="text-red-500">タイトルは必須です</h1>}
</div>
<div className="flex flex-col gap-y-3">
<label htmlFor="image">画像アップロード</label>
<input
type="file"
{...register("image", { required: true })}
className="border border-black py-2 px-4"
onChange={onFileInputChange}
/>
{errors.image && <h1 className="text-red-500">画像は必須です</h1>}
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="mt-8 bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded"
>
追加する
</button>
</div>
</form>;
{...register("title", { required: true })}
の部分で、titleプロパティと必須項目の指定をしている。imageの方も一緒。 {errors.title && <h1 className="text-red-500">タイトルは必須です</h1>}
の部分で、入力フォームのエラーを表示。<form onSubmit={handleSubmit(onSubmit)}>...</form>
の部分でformタグ内のregister関数で取得したデータをもとにボタンが押された時の処理をする。
投稿データの全取得。useEffectで画面遷移時にこのメソッドを呼び出すようにしています
// postを全取得する
const getPosts = async () => {
const response = await fetch("http://localhost:3000/api/v1/posts");
const data = await response.json();
setPosts(data);
};