Next.js + TypescriptでミニCMSを作る（7. Cloudinaryで画像表示、Youtubeのサムネイル表示）

# 画像の表示を出し分けする

:::note info
[前回](https://qiita.com/AndanteSysDes/items/d557080915334489b277)の続きです。
:::

[Cloudinary](https://cloudinary.com/)を使って画像の保存と表示を進めます。Youtube記事である場合は、サムネイル画像の表示ができるようにします。

## 下準備
まずはアカウントを作りましょう。
ログインすると、Media Libraryの画面になります。
Assetsのタブを選ぶと、すでにいくつかサンプル画像が用意されているので、ひとまずこれでテストしてみます。
画像を選択し、URLをコピーします。Public IDも確認しておきます。

![Cloudinary.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547786/c673a6cb-62d4-e55a-dd3e-769f0ba77e46.png)

テストデータを準備し、`imgUrl`に画像URL、`imgPublicId`にCloudinaryの画像のPublic IDを転記します。


## 外部サイトの画像をNext.jsで使うために

Next.jsではセキュリティの観点から、許可されたホストから配信されている画像のみを表示することになっています。
詳細は以下を御覧ください。

https://nextjs.org/docs/pages/building-your-application/optimizing/images#remote-images

https://nextjs.org/docs/messages/next-image-unconfigured-host

ということで、`next.config.js`にCloudinaryからの画像を表示するよう設定します。

```diff_tsx:next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
+  images: {
+    remotePatterns: [
+      {
+        protocol: 'https',
+        hostname: 'res.cloudinary.com', 
+        pathname: '/<Cloudinaryのクラウド名(Product environment cloud name)>/**',        
+      },
+    ],
+  }, 
};

export default nextConfig;

```

Cloudinaryのクラウド名は、画像URLからも確認できます。これでCloudinaryの自分のアカウントの画像のみ許可を出せました。

これで、Cloudinaryの画像が表示されるようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547786/93950ab1-0738-4557-8a9e-0ac88ab83876.png)


## CldImageコンポーネント

CloudinaryはNext.jsのImageコンポーネントの機能を拡張した、独自の`CldImage`コンポーネントを提供しています。
それにより高度な画像操作に活用することができます。管理画面ではあまり恩恵はありませんが、後々使えるよう導入します。


https://cloudinary.com/guides/front-end-development/integrating-cloudinary-with-next-js

### パッケージインストール

アプリフォルダで、以下のコマンドを実行します。
```
npm install next-cloudinary
```

### 環境変数の設定

`.env.local`ファイルを作成し、以下の記述を追加します。

```
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="<Your Cloud Name>"
```

`<Your Cloud Name>(クラウド名)`は、`next.config.js`の`pathname`で設定したものと同様です。

:::note

#### `.env`と`.env.local`

どちらのファイルも開発環境・本番環境において読み込まれる環境変数を設定できますが、`.env*.local`はデフォルトで`.gitignore`によって隠されるファイルです。
環境変数で外部に公開したくない値は`.env.local`, それ以外は`.env`に設定するとよいです。

※今回はDBをDockerでローカル環境に立てているので、DB系の環境変数は`.env`に入れています。外部DBを利用する場合や各種APIキーなどは`.env*.local`に隠すのが無難でしょう。

https://nextjs.org/docs/app/building-your-application/configuring/environment-variables

:::

### ソースコード書き換え

Imageコンポーネントから CldImageコンポーネントに差し替えします。

```diff_typescript:ReviewCard.tsx

import Image from "next/image";
import styles from "./reviewCard.module.css";
import Link from "next/link";
import { ReviewWithProductId } from "@/app/types";
+ import { CldImage } from "next-cloudinary";

//中略

export default function ReviewCard({review, onDeleteReview } : Props) {

    return (

    //中略
    
                {/* THUMBNAIL */}
                <div className={styles.imageContainer}>
-                   <Image
-                       src={review.imgUrl || "/img/placehold_500_300.png"}
+                   <CldImage
+                       src={review.imgPublicId || "/img/placehold_500_300.png"}
                        alt="サムネイル画像"
                        fill
                        sizes="150px"
                        quality={50}
                        className={styles.thumbnail}/>
                </div>

                <div className={styles.textContainer}>
    
    //中略
    
```

CldImageコンポーネントを使う場合は、`next.config.mjs`の記載は不要になります。
Imageコンポーネントでも使えるよう残しておいても構いませんし、消してしまっても大丈夫です。


これで、Public IDを使って画像の表示ができるようになりました。


# Youtube記事の出し分け

今回のプロジェクトでのレビュー記事は、自分でサムネイル画像をアップロードする通常記事と、Youtube動画を掲載するYoutube記事の２種類があります。
どちらの記事かは、Reviewテーブルに持たせている`isYoutube`フラグで管理します。Youtube記事の場合は、動画のサムネイル画像を取得することにします。

### リファクタリング

`ReviewCard`コンテナの該当部分ですが、ロジックを足すと複雑になりますので別のコンテナに切り出そうと思います。

```diff_typescript:ReviewCard.tsx

import Image from "next/image";
import styles from "./reviewCard.module.css";
import Link from "next/link";
import { ReviewWithProductId } from "@/app/types";
import { CldImage } from "next-cloudinary";
+ import ReviewThumbnail from "../ReviewThumbnail/ReviewThumbnail";

//中略

export default function ReviewCard({review, onDeleteReview } : Props) {

    return (

    //中略
    
                {/* THUMBNAIL */}
+                <ReviewThumbnail review={ review } />
-                <div className={styles.imageContainer}>
-                    <CldImage
-                        src={review.imgPublicId || "/img/placehold_500_300.png"}
-                        alt="サムネイル画像"
-                        fill
-                        sizes="150px"
-                        quality={50}
-                        className={styles.thumbnail}/>
-                </div>

                <div className={styles.textContainer}>
    
    //中略
    
```

```tsx: components/ReviewThumbnail/ReviewThumbnail.tsx

import { ReviewWithProductId } from "@/app/types";
import {CldImage} from 'next-cloudinary';
import styles from "./ReviewThumbnail.module.css";

interface Props {
    review : ReviewWithProductId
}

export default function ReviewThumbnail({review} : Props) {

    return (
        <div className={styles.imageContainer}>
            <CldImage
                src={review.imgPublicId || "/img/placehold_500_300.png"}
                alt="サムネイル画像"
                fill
                sizes="150px"
                quality={50}
                className={styles.thumbnail}/>
        </div>
    )
}
```

<details> <summary>CSS</summary>

```css: components/ReviewThumbnail/ReviewThumbnail.module.css
.imageContainer {
  position: relative;
  margin: 4px;
  min-height: 110px;
}

.thumbnail{
  object-fit: contain;
}
```

reviewCard.module.cssの該当部分は消しておいてください。

</details>


### Youtube記事の分岐

それでは、記事の分岐ロジックを考えていきます。
今回は、`isYoutube`のフラグON/OFFで分岐させますが、下書き記事のような不完全なデータも同じロジックを使いたいので、もう少し細かく条件設定していきます。
ReviewテーブルでYoutubeや画像に関わるカラムは以下です。

- `isYoutube` : Youtube記事かどうかのフラグ
- `youtubeId` : `isYoutube`がTRUEの場合に、YoutubeのビデオIDを記録します（動画URLの　watch?v=　の後の部分）
- `imgUrl` : サムネイル画像のURL
- `imgPublicId` : Cloudinaryの画像である場合は、そのPublic IDを記録します

Youtube記事の場合、動画のサムネイル画像はYoutubeのビデオIDを利用して取得するので、サムネイル表示は`youtubeId`が記録されていることが必須です。
ビデオIDは動画URLから取得する必要があるので、下書き状態で記事の参照URLが記載なしの場合はダミー画像などを表示する必要があります。このあたりが分岐のポイントになります。

今後実装する予定のレビュー投稿機能で、Cloudinary, Youtubeそれぞれサムネイル画像のURLを計算して、どちらも`imgUrl`に記録するという手法も考えられます。
しかし、分岐の判断をする材料や投稿時の計算ロジックが増えてしまうので、今回は`imgUrl`をDBに持たせない方針で実装してみたいと思います。

テストデータはこんな感じで用意してみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547786/db29f7a6-cf6f-32d9-d0ad-0e963eab83cf.png)


:::note
Youtubeのサムネイル画像URLは、以下の形式になります。
 `https://img.youtube.com/vi/${youtubeId}/0.jpg`

 外部ホストから配信される画像になりますので、`next.config.mjs`を以下の通り更新しましょう。

```diff_tsx:next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
+  images: {
+    remotePatterns: [
+      {
+        protocol: 'https',
+        hostname: 'img.youtube.com',     
+      },
+    ],
+  }, 
};

export default nextConfig;

```

:::



```diff_typescript: components/ReviewThumbnail/ReviewThumbnail.tsx

import { ReviewWithProductId } from "@/app/types";
import {CldImage} from 'next-cloudinary';
import styles from "./ReviewThumbnail.module.css";
+ import Image from "next/image";

interface Props {
    review : ReviewWithProductId
}

export default function ReviewThumbnail({review} : Props) {

    return (
        <div className={styles.imageContainer}>
        
-            <CldImage
-                src={review.imgPublicId || "/img/placehold_500_300.png"}
-                alt="サムネイル画像"
-                fill
-                sizes="150px"
-                quality={50}
-                className={styles.thumbnail}/>
                
+            {review.isYoutube && review.youtubeId
+                // Youtube article with Youtube video ID
+                ? (<Image
+                    src={`https://img.youtube.com/vi/${review.youtubeId}/0.jpg`}
+                    alt="サムネイル画像"
+                    fill
+                    sizes="150px"
+                    quality={50}
+                    className={styles.thumbnail}/>)
+                    
+                : review.imgPublicId
+                    // Blog article with Cloudinary Public ID
+                    ? (<CldImage
+                        src={review.imgPublicId}
+                        alt="サムネイル画像"
+                        fill
+                        sizes="150px"
+                        quality={50}
+                        className={styles.thumbnail}/>)
+
+                    // the others (without image)
+                    : (<Image
+                        src="/img/placehold_500_300.png"
+                        alt="サムネイル画像"
+                        fill
+                        sizes="150px"
+                        quality={50}
+                        className={styles.thumbnail}/>) }

        </div>
    )
}
```

これで、以下の通り画像の出し分けができていることが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547786/4b680916-0ae6-1a3c-387f-6251ca7c0c1e.png)


## DBのスキーマ変更

`imgUrl`は使わない事になりそうなので、DBのスキーマから除外します。

```diff_typescript: schema.prisma
//中略

model Review {
  id          Int       @id @default(autoincrement())
  title       String?   @db.Text
  detail      String?   @db.Text
  url         String?   @db.VarChar(2100)
  isYoutube   Boolean?
  youtubeId   String?   @db.VarChar(30)
  articleDate DateTime? @db.DateTime(0) // Based on UTC time locale
- imgUrl      String?   @db.VarChar(2100)
  imgPublicId String?   @db.VarChar(40)
  displayTop  Boolean?
  isDraft     Boolean?
  createdAt   DateTime @default(now()) @db.DateTime(0)
  updatedAt   DateTime @updatedAt @db.DateTime(0)
  products    Product[] 
}

//中略

```

スキーマを変更したら、DBに同期させます。

```
npx prisma db push
```

これでカラムが変更されました。

# 次回

すこーし、リファクタリングをしたい気持ちが高まってきました。