Next.jsでPublic配下に画像をアップロードする方法

Next.jsでPublic配下に画像をアップロードする記事が少なかったので、備忘録として残しておきます。

画像のアップローダの作り方は

[【React/TypeScript】単一画像をReact Hook Formを使って送信する](https://koutaroinoue-log.com/articles/submit-single-file-with-rhf/)

の上記記事を参考にしてつくりました。（画像アップローダーの詳しい説明は上記記事に詳しく解説されており、非常にわかりやすいです）

ここでは、Next.js配下のPublic配下のimagesフォルダに画像を保存するまでの流れを記載していきます。

## 事前準備

- formidable-serverlessのモジュールの追加

```
npm i formidable-serverless
```

## フロントエンドの処理

ざっと、フロントエンドの処理です。

inputにref属性を加え、画像に変更があった場合、新しい画像を取得するコンポーネントです。

```components/InputImage.tsx
import React, { InputHTMLAttributes, forwardRef } from 'react'

export type Props = {
  onChange: (event: React.ChangeEvent<HTMLInputElement>) => void
  id: InputHTMLAttributes<HTMLInputElement>['id']
}

const InputImage = forwardRef<HTMLInputElement, Props>(({ onChange, id }, ref) => {
  return <input ref={ref} id={id} type='file' accept='image/*' onChange={onChange} hidden />
})

InputImage.displayName = 'InputImage'

export default InputImage
```

プレビュー用の画像処理

```hook/useGetImageUrl.ts
import { useEffect, useState } from 'react'

type Args = {
  file: File | null
}

export const useGetImageUrl = ({ file }: Args) => {
  const [imageUrl, setImageUrl] = useState('')

  useEffect(() => {
    if (!file) {
      return
    }

    let reader: FileReader | null = new FileReader()
    reader.onloadend = () => {
      // base64のimageUrlを生成する。
      const base64 = reader && reader.result
      if (base64 && typeof base64 === 'string') {
        setImageUrl(base64)
      }
    }
    reader.readAsDataURL(file)

    return () => {
      reader = null
    }
  }, [file])

  return {
    imageUrl,
  }
}
```

メインのフロントエンドの処理は、画像の処理のみ書いてます。tailwind.cssを使っています。

```pages/posts/index.tsx
import Image from 'next/image';
import React, { FC, useRef, useState } from 'react';
import InputImage from '@/components/InputImage';
import { useGetImageUrl } from '@/hook/useGetImageUrl';

// 画像のID,ファイルのサイズ
const IMAGE_ID = 'imageId';
const FIELD_SIZE = 210;

const Post: FC = () => {
	const fileInputRef = useRef<HTMLInputElement>(null);
	const [imageFile, setImageFile] = useState<File | null>(null);
	// 画像のURLを取得
	const { imageUrl } = useGetImageUrl({ file: imageFile });

	// ファイルの設定
	const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
		if (e.currentTarget?.files && e.currentTarget.files[0]) {
			const targetFile = e.currentTarget.files[0];
			setImageFile(targetFile);
		}
	};

	// 画像のキャンセルの設定
	const handleClickCancelButton = (
		e: React.MouseEvent<HTMLButtonElement, MouseEvent>
	) => {
		e.preventDefault();
		setImageFile(null);
		// <input />タグの値をリセット
		if (fileInputRef.current) {
			fileInputRef.current.value = '';
		}
	};

	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
		e.preventDefault();
		if (!imageFile) {
			alert('画像を選択してください');
			return;
		}
		try {
			const formData = new FormData();
			const file = imageFile;
			const blob = file.slice(0, file.size, file.type);
			// ファイル名をユニークにする
			const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
			const newFile = new File([blob], uniqueSuffix + '-' + file.name, {
				type: file.type,
			});
			formData.append('file', newFile);
			const response = await fetch('/api/images/upload', {
				method: 'POST',
				body: formData,
			});
			if (response.status === 200) {
				console.log('画像がアップロードされました');
				/*
            newFile.name:画像名
            成功した時にDBに画像名を登録する処理を追加したい場合その処理を記述
            */
			} else {
				console.log('アップロードに失敗しました');
				throw new Error('アップロードに失敗しました');
			}
			alert('登録が完了しました');
		} catch (error) {
			console.error('画像のファイル登録が失敗しました。', error);
		}
		return (
			<div>
				<h2>画像アップローダー</h2>
				<form onSubmit={handleSubmit}>
					<div className="flex rounded-lg bg-white shadow-sm my-4">
						<span className="px-4 py-2 inline-flex items-center min-w-fit rounded-s-md border border-e-0 border-gray-200 bg-gray-600 text-sm text-gray-200 ">
							画像をアップロードする
						</span>
						<label
							htmlFor={IMAGE_ID}
							className={
								'border-white-3px-dotted w-[' +
								FIELD_SIZE +
								'px] h-[' +
								FIELD_SIZE +
								'px] flex justify-center items-center overflow-hidden cursor-pointer bg-sky-200'
							}
						>
							{imageUrl && imageFile ? (
								<Image
									src={imageUrl}
									alt="アップロード画像"
									width={FIELD_SIZE}
									height={FIELD_SIZE}
									className="object-cover w-full h-full"
								/>
							) : (
								'+ 画像をアップロード'
							)}
							{/* ダミーインプット: 見えない */}
							<InputImage
								ref={fileInputRef}
								id={IMAGE_ID}
								onChange={handleFileChange}
							/>
						</label>

						<div style={{ height: 20 }} />
						{/* キャンセルボタン */}
						<button
							className="font-bold bg-gray-200 p-4"
							onClick={(e) => handleClickCancelButton(e)}
						>
							キャンセル
						</button>
					</div>

					<button
						type="submit"
						className="w-full sm:w-auto py-3 px-4 inline-flex justify-center items-center gap-x-2 text-md font-semibold rounded-lg border border-transparent bg-gradient-to-r from-purple-600 to-blue-400 hover:opacity-80 text-white disabled:opacity-50 disabled:pointer-events-none"
					>
						画像をアップロードする
					</button>
				</form>
			</div>
		);
	};
};
```

と、参考記事を例にすると、このように、フォームを作成することができます。

## APIの作成

それでは、ここから、Public配下に画像を設置するようにapiを作っていきます。

```pages/api/images/upload.ts
import fs from 'fs'
import path from 'path'
import formidable, { File, IncomingForm } from 'formidable-serverless'
import { NextApiRequest, NextApiResponse } from 'next'

// ファイルのアップロード処理
export const config = {
  api: {
    bodyParser: false,
  },
}

export default async function handler(req: NextApiRequest | File | Date, res: NextApiResponse) {
  const form = new IncomingForm()
  form.uploadDir = './public/images'
  form.keepExtensions = true
  form.parse(req, (err, fields, files: { [key: string]: any }) => {
    if (err) {
      res.status(500).json({ error: 'ファイルのアップロード中にエラーが発生しました' })
      return
    }

    const file = files.file
    if (!file) {
      res.status(400).json({ error: 'ファイルが見つかりません' })
      return
    }

    // アップロードされたファイルをpublic/imagesに移動
    const newPath = path.join(process.cwd(), 'public', file.name)
    fs.rename(file.path, newPath, (err) => {
      if (err) {
        res.status(500).json({ error: 'ファイルの保存中にエラーが発生しました' })
        return
      }
      res.status(200).json({ message: 'ファイルが正常にアップロードされました' })
      return
    })
  })
}

```

ただこのままだと、型エラーが出ます。

それを消していきたいと思います。

プロジェクト配下か、`types`のディレクトリに、`formidable-serverless.d.ts`を設置します。

```formidable-serverless.d.ts
declare module 'formidable-serverless' {
  import { IncomingForm as OriginalIncomingForm, File } from 'formidable'

  export interface IncomingForm extends OriginalIncomingForm {
    uploadDir: string
    keepExtensions: boolean
    parse(req: any, callback: (err: any, fields: any, files: { [key: string]: File }) => void): void
  }

  export interface File {
    size: number
    path: string
    name: string
    type: string
    hash: string
    lastModifiedDate?: Date
    originalFilename: string
    toJSON: () => string
  }

  // formidableのIncomingFormを拡張
  export class IncomingForm extends OriginalIncomingForm {
    parse(
      req: any,
      callback: (err: any, fields: any, timestamp: any, files: { [key: string]: File }) => void,
    ): void
  }
}
```

型は便宜上anyにしている個所がありますが、厳密に指定した場合は、適宜変更すると良いと思います。

参考になりましたら幸いです。


## 参考サイト
- [【React/TypeScript】単一画像をReact Hook Formを使って送信する](https://koutaroinoue-log.com/articles/submit-single-file-with-rhf/)