[Next.js]Cloudinaryを使ってみた

## はじめに
Next.jsにチャレンジしていく過程をメモ/備忘録として記録していきます。
同じようにこれから始める方の参考となればと思います。

## やりたいこと
学習用に簡単なTodoアプリを作っていて、アプリ内で利用する画像を
Cloudinaryで管理したい

## 環境
下記のDocker開発環境にて行います。

https://qiita.com/mkthrkw/items/30115c9ac54c2204faef


## Cloudinaryについて
クラウドベースのメディア管理プラットフォームで、主に画像や動画のアップロード、保存、最適化、配信を行うことができるサービス。
無料プランでも十分に使え、自動最適化やSDKも提供されている、魅力的なサービスです。
今回は使いませんが、AIによるオートクロッピングや顔認識の機能もあるようで、いつか使ってみたいと思います。

↓詳細はこちら↓
https://cloudinary.com/developers


## 導入

Next.js用のライブラリがあり、こちらの内容に沿って進めていきます。

https://next.cloudinary.dev/installation

### 1. サインアップ
「SIGN UP FOR FREE」からアカウントを作成

![スクリーンショット 2024-08-13 22.43.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/0dddb9a4-39df-0ce2-f7e7-61a3a3bb72c5.png)


### 2. インストール
Next.js用のSDKが提供されているのでインストール

```shell
npm i next-cloudinary
```

### 3. envへ設定

```.env.local``` や ```.env```ファイルへ自分のAPIキーなどの情報を記載します。
情報はcoudinaryダッシュボードの「Product Environment」から取得できます。

```config:.env
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="<Your Cloud Name>"
NEXT_PUBLIC_CLOUDINARY_API_KEY="<Your API Key>"
CLOUDINARY_API_SECRET="<Your API Secret>"
```

### ４. サンプル画像でテスト
とりあえず、getting-startedに書いているものでテスト。

```typescript:
  <div className="avatar">
    <div className="w-32 rounded-full">
      <CldImage
        src="samples/animals/three-dogs" // Use this sample image or upload your own via the Media Explorer
        width="150" // Transform the image: auto-crop to square aspect_ratio
        height="150"
        alt='avatar'
      />
    </div>
  </div>
```

CldImageコンポーネントで簡単に表示出来ました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/b637985f-2d66-1e57-e900-d76ea1cf9010.png)


### 5. 署名付きアップロード用のAPIエンドポイントを作成

署名付きアップロードは ```CldUploadWidget``` にAPIエンドポイントをプロパティとして渡すだけで実現出来ます。
Cloudinary Node SDKを使って、エンドポイントを作成します。

https://cloudinary.com/documentation/node_image_and_video_upload#direct_uploading_from_the_browser

まずはSDKをインストール

```shell
npm install cloudinary
```
次にエンドポイントのroute.tsファイルを作成

```typescript:app/api/sign-cloudinary-params/route.ts
import { v2 as cloudinary } from "cloudinary";
 
cloudinary.config({
  cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});
 
export async function POST(request: Request) {
  const body = await request.json();
  const { paramsToSign } = body;
 
  const signature = cloudinary.utils.api_sign_request(paramsToSign, process.env.CLOUDINARY_API_SECRET);
  
  return Response.json({ signature });
}
```


### 6. クライアントコンポーネントを作成

#### ①CldUploadWidget

基本はこの形でwidgetが開き、署名付きでアップロードができるようになっています。
一方的にcloudinaryにアップロードするだけなのでもう少し手を加えます。

```Typescript: 
<CldUploadWidget signatureEndpoint="/api/sign-cloudinary-params">
  {({ open }) => {
    return (
      <button onClick={() => open()}>
        Upload an Image
      </button>
    );
  }}
</CldUploadWidget>
```

#### ②成功時の処理追加

widgetにonSuccessを追加して成功時の処理を追加。

```typescript:
onSuccess={onSuccessHandler}
```
下記のようなレスポンスが返ってくるので、レコードにpublic_idの情報を取り出して追加。

```json:
{
    "event": "success",
    "info": {
        "id": "test1234",
        "batchId": "test1234",
        "asset_id": "abcdefghijklmnopqrstuvwxyz0123456789",
        "public_id": "test1234",
        "version": 123456789,
        "version_id": "abcdefghijklmnopqrstuvwxyz0123456789",
        "signature": "abcdefghijklmnopqrstuvwxyz0123456789",
        "width": 100,
        "height": 100,
        "format": "png",
        "resource_type": "image",
        "created_at": "2024-08-17T12:00:26Z",
        "tags": [],
        "bytes": 1234,
        "type": "upload",
        "etag": "abcdefghijklmnopqrstuvwxyz0123456789",
        "placeholder": false,
        "url": "http://test1234/test1234/test1234/test1234.png",
        "secure_url": "http://test1234/test1234/test1234/test1234.png",
        "asset_folder": "test1234",
        "display_name": "test1234",
        "api_key": "123456789",
        "path": "test1234/test1234/test1234/test1234.png",
        "thumbnail_url": "https://test1234/test1234/test1234/test1234/test1234/test1234.png"
    }
}
```

```typescript:
const onSuccessHandler = (results:CloudinaryUploadWidgetResults)  => {
    if(results.info && typeof results.info === 'object' && 'public_id' in results.info){
      someUploadAction(results.info.public_id);
    }else{
      console.log('something wrong:', results);
    }
}
```


#### ③エラー時の処理を追加

onErrorでエラー処理を追加。
今回は省略してコンソールに表示にしています。

```typescript:
onError={onErrorHandler}
```

```typescript:
const onErrorHandler = (error: CloudinaryUploadWidgetError) => {
    if (typeof error === 'object' && error?.statusText) {
      console.error(error.statusText);
    } else {
      console.error(error);
    }
}
```

#### ④エラー時の処理を追加

onQueuesEndで終了後にwidgetを閉じるように追加。

```Typescript
onQueuesEnd={(results, { widget }) => {
    widget.close();
}}
```

### ⑤uploadPresetを設定

cloudinary > setting > Upload Presets にて
アップロードのオプションのセットを一元的に定義して作成しておけます。
署名付きにするか署名なしにするか、PublicIDのネーミングルールなど予め設定しておきます。
https://cloudinary.com/documentation/upload_presets

作成したプリセットをwidgetへ追加。

```
uploadPreset="test1234"
```


#### ⑥まとめるとこうなりました

```typescript
"use client";

import { ActionState, ObjectDetail } from '../type'
import {
  CldImage,
  CldUploadWidget,
  CldUploadWidgetPropsChildren,
  CloudinaryUploadWidgetError,
  CloudinaryUploadWidgetResults
} from 'next-cloudinary';
import CameraIcon from '@/components/icons/svg/CameraIcon';
import { toast } from 'react-toastify';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import defaultImg from '@/public/images/object/default.jpeg';

export default function AvatarForm({someObject}:{someObject:ObjectDetail}) {

  const router = useRouter();
  
  const AvatarImage = () => {
    if (someObject.image_url){
      return (
        <CldImage
          src={someObject.image_url}
          width="150"
          height="150"
          alt='avatar'
        />
      );
    }else{
      return (
        <Image
          src={defaultImg}
          width="150"
          height="150"
          alt="avatar"
        />
      );
    }
  }

  const uploadAvatar = async (public_id: String) => {
      someUploadAction(results.info.public_id);
      router.refresh();
  }

  const onSuccessHandler = (results:CloudinaryUploadWidgetResults)  => {
    if(results.info && typeof results.info === 'object' && 'public_id' in results.info){
      uploadAvatar(results.info.public_id);
    }else{
      console.log('something wrong:', results);
    }
  }
  const onErrorHandler = (error: CloudinaryUploadWidgetError) => {
    if (typeof error === 'object' && error?.statusText) {
      console.error(error.statusText);
    } else {
      console.error(error);
    }
  }

  const widgetChildren = ({ open }: CldUploadWidgetPropsChildren) => {
    return (
      <button
        onClick={() => open()}
        className="btn btn-xs text-sm hover:underline w-11 h-11 rounded-full bg-slate-700/70 hover:bg-slate-500/70"
      >
        <CameraIcon width={24} height={24} addClass='fill-slate-400'/>
      </button>
    );
  }
  
  return (
    <div className='text-center'>
      <div className="avatar">
        <div className="w-32 rounded-full">
          <AvatarImage />
        </div>
      </div>
      <div className='relative left-10 bottom-8'>
        <CldUploadWidget
          signatureEndpoint="/api/sign-cloudinary-params"
          uploadPreset="test1234"
          onSuccess={onSuccessHandler}
          onError={onErrorHandler}
          onQueuesEnd={(results, { widget }) => {
            widget.close();
          }}
        >
          {widgetChildren}
        </CldUploadWidget>
      </div>
    </div>
  )
}
```

## テスト
無事ボタンを押すとウィジェットが表示され、アップロードすることができました。
ファイルだけではなく、カメラやGoogleDriveからのアップロードもウィジェットについており、重宝しそうです。

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/40b5f574-198d-719e-a686-5420033c4492.png">

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/85578f27-839d-7cb2-a855-9fc085cb8a14.png">


## さいごに
WEBのコンソール、widgetもかなり使いやすく、便利なサービスでした。
今後はドキュメントに書かれた内容だけでなく、APIをもっと使いこなしていけたらと思います。

## 参考

https://console.cloudinary.com/pm/c-dac4e11b7709cc6e15bf7311fdf3bc/getting-started

https://github.com/cloudinary-community/next-cloudinary