【60分でマスター】Reactで画像ジェネレーターを作ろう！【TypeScript/Supabase/StabilityAI/TailwindCSS】

![BUILD FIRST APP.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/8c792c8a-78a3-178c-5754-a865d0ae54b3.png)


# はじめに

こんにちは、Watanabe Jin(@Sicut_study)です。
アプリを作る上で事前に手札が多いほうが、実現できるアイデアの幅も広くなります。

今回は「AIによる画像生成」「ストレージへの保存」をメインとした**画像ジェネレーター**をReactで実装するアプリケーションを作成していきましょう。

![Videotogif (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/bf715147-f0c2-21ad-4c0e-a37baae57703.gif)


今回はNext.jsの開発元であるVeceltとパートナー強化がされた「Supabase」を利用して素早く実装します。
今後Supabaseはより選択肢になる可能性が高くなる素晴らしいBaaSです。触ったことない方でもわかるように丁寧に解説しています。

# 動画で更に詳しく解説

この記事ではReact自体の解説は細かくしておりません。
もしReactやTypeScriptに不安がある方、やったことがない方は以下の動画をみてください！

<iframe width="560" height="315" src="https://www.youtube.com/embed/n6WVA5zDEug?si=BGAXCm--ZTqIW_dc" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

https://youtu.be/n6WVA5zDEug

# ハンズオンの対象者

- Reactの基本がなんとなくわかる
- ハンズオンで学びたい人
- Supabaseを使ってみたい人
- AIを自分でも使ってみたい人

# 1. Reactの環境構築

ReactとTailwindCSSが実行できる環境を用意してきましょう。
Node.jsが実行できる環境がお手元にない方は以下を参考にそれぞれのOSにあった方法でインストールしてください！

https://nodejs.jp/

https://www.sejuku.net/blog/72545

インストールができたことを以下の確認で確認してください

```
❯ node -v
v18.17.0
```

Reactのプロジェクトを構築します。
今回はViteを利用していきます。Viteは次世代のビルドツールで早くて無駄のない環境を提供してくれます。

https://ja.vitejs.dev/guide/

```
❯ npm create vite
Need to install the following packages:
  create-vite@5.5.2
Ok to proceed? (y) y
✔ Project name: … image-generator
✔ Select a framework: › React
✔ Select a variant: › TypeScript

$ cd image-generator/
$ npm i
$ npm run dev
```

[http://localhost:5173](http://localhost:5173)にアクセスして以下の画面が表示されればReact環境が無事できています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/8e8f586e-e0a8-2b5f-011f-80bf8d8fcf5b.png)

次にTailwindCSSを導入していきます。

```
$ npm install -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
```

プロジェクトをVSCodeで開いて`tailwind.config.js`をいかに変えます

```js:tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
```

`src/index.css`を変更します

```css:src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

`src/App.tsx`を変更してスタイルがあたるかをチェックします

```tsx:src/App.tsx
function App() {
  return (
    <>
      <div>
        <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
          Button
        </button>
      </div>
    </>
  );
}

export default App;
```

一度サーバーを落として`npm run dev`で起動したらボタンが表示されました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/81fe2d2d-9ecc-ef17-da67-cdc57b863a10.png)

# 2. APIで画像生成を行う

アプリの根幹であるAIによる画像生成を行います。
AI画像生成のAPIはいくつかありますが今回は無料で利用できる**Stability AI**を利用します。

https://platform.stability.ai/?_gl=1*166lqye*_ga*MTY4OTA2NjEyOS4xNjg0MzE0NTQw*_ga_W4CMY55YQZ*MTcwNjI0ODkxNy4xNTkuMS4xNzA2MjQ5MDI5LjAuMC4w

まずは以下の記事を参考にAPIキーを取得するところまで行ってください

https://qiita.com/nqdior/items/6e3d873f91f792e5bea3

APIキーを手に入れたら`.env`の設定から行います。

```
$ touch .env
```

```.env
VITE_STABILITY_API_KEY=あなたのAPIキー
```

`VITE_`で始めているのはViteで環境変数を読み込むときにプレフィックスがついていないといけないルールがあるからです。

https://ja.vitejs.dev/guide/env-and-mode

`.env`を作ったら`.gitignore`にも追加しておきましょう

```.gitignore
// 末尾に追加
.env
```

最初はインプットフォームにプロンプトを入れて生成ボタンを押したら画像が表示される仕組みを作ってAPIを利用できるかを確認します。

```tsx:src/App.tsx
import { useState } from "react";

interface GenerationResponse {
  artifacts: Array<{
    base64: string;
    seed: number;
    finishReason: string;
  }>;
}

function App() {
  const [generatedImage, setGeneratedImage] = useState<string | null>(null);
  const [prompt, setPrompt] = useState("");

  const engineId = "stable-diffusion-v1-6";
  const apiKey = import.meta.env.VITE_STABILITY_API_KEY;
  const apiHost = "https://api.stability.ai";

  const handleGenerateImage = async () => {
    const response = await fetch(
      `${apiHost}/v1/generation/${engineId}/text-to-image`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
          Authorization: `Bearer ${apiKey}`,
        },
        body: JSON.stringify({
          text_prompts: [
            {
              text: prompt,
            },
          ],
          cfg_scale: 7,
          height: 1024,
          width: 1024,
          steps: 30,
          samples: 1,
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`Non-200 response: ${await response.text()}`);
    }

    const responseJSON = (await response.json()) as GenerationResponse;
    const base64Image = responseJSON.artifacts[0].base64;
    setGeneratedImage(`data:image/png;base64,${base64Image}`);
  };

  return (
    <>
      <div>
        <input
          type="text"
          className="border"
          onChange={(e) => setPrompt(e.target.value)}
          value={prompt}
        />
        <button
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
          onClick={handleGenerateImage}
        >
          生成
        </button>
      </div>
      {generatedImage && (
        <div>
          <h2>Generated Image:</h2>
          <img src={generatedImage} alt="Generated" />
        </div>
      )}
    </>
  );
}

export default App;
```

APIの呼び出しは以下を参考にしました

https://platform.stability.ai/docs/api-reference#tag/SDXL-and-SD1.6

また画像はBase64で返却されるのでそのまま表示できる形でステートに保存します

```tsx
    const responseJSON = (await response.json()) as GenerationResponse;
    const base64Image = responseJSON.artifacts[0].base64;
    setGeneratedImage(`data:image/png;base64,${base64Image}`);
```

`A lighthouse on a cliff`と入力して生成ボタンを押すと画像が表示されました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/57a4b7fd-2619-7a97-057b-09f06c6019a4.png)

# 3. 画像を保存する

表示された画像はお気に入り登録をして最後にギャラリーで表示できるようにします。
そこで`Supabase`のストレージを今回は利用していきます。

https://supabase.com/

Supabaseのプロジェクトを作成するところまで以下を参考に実施してください

https://ritaiz.com/articles/setup-supabase

まずはSupabaseのAPIキーを取得します。
「Project Settings」をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/5965ba21-f233-802a-e5b2-ac4102056aaf.png)

「API」をクリックして「Project URL」と「Project API Keys」の「anon」のキーを使います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/94f25472-5cbc-5512-c8a7-27270e9ac075.png)

`.env`に追加していきます

```.env
VITE_STABILITY_API_KEY=あなたのAPIキー
VITE_SUPABASE_URL=あなたのProject URL
VITE_SUPABASE_ANON_KEY=あなたのanonキー
```

それでは、お気に入りボタンを追加してクリックしたらStorageに保存されるようにしましょう
Supabaseの画面から「Storage」をクリックします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/75242f3a-58c4-2cd4-9065-380a087d4d37.png)

「New Bucket」を選択

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/e295c306-9907-eec6-7ed8-8b2ca432b6e0.png)

Nameに`generate-image`とSaveをクリックします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/28f964de-7009-4ddd-69e0-f9c9260a4885.png)

このままだとバケットに誰もアクセスできないのでポリシーを作成します
「Policies」をクリックします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/5410a5ee-08e8-253a-13b3-0af563477a30.png)

generate-imageの「New policy」をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/55740d98-191d-7fd9-7f60-dca6ae50c735.png)

「For full customization」をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/14cd0974-d561-fb64-a31e-a7770fc31e2d.png)

Policy name : anon_policy
Allow operation : すべてチェック
Target role : anon

「Review」->「Save policy」の順でクリックして保存

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d0a2df86-af9c-00ee-8f61-0f0ff73a0ac2.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/2fb37398-f3f6-64ce-2ed8-b60db08087af.png)

これで画像を保存する準備ができました

以下のAPIドキュメントを見ながら保存の実装をしていきます

https://supabase.com/docs/guides/storage/uploads/standard-uploads

まずはSupabaseクライアントの初期設定を行います

```
$ npm i @supabase/supabase-js
$ mkdir src/utils
$ touch src/utils/supabase.ts
```

```ts:supabase.ts
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseKey);
```

これはsupabaseクライアントを使う際に必ずやる設定になります。
では画像を保存するコードを書いていきましょう

```tsx:src/App.tsx
import { useState } from "react";
import { supabase } from "./utils/supabase";

interface GenerationResponse {
  artifacts: Array<{
    base64: string;
    seed: number;
    finishReason: string;
  }>;
}

function App() {
  const [generatedImage, setGeneratedImage] = useState<string | null>(null);
  const [prompt, setPrompt] = useState("");
  const engineId = "stable-diffusion-v1-6";
  const apiKey = import.meta.env.VITE_STABILITY_API_KEY;
  const apiHost = "https://api.stability.ai";

  const handleGenerateImage = async () => {
    console.log(apiKey);
    const response = await fetch(
      `${apiHost}/v1/generation/${engineId}/text-to-image`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
          Authorization: `Bearer ${apiKey}`,
        },
        body: JSON.stringify({
          text_prompts: [
            {
              text: prompt,
            },
          ],
          cfg_scale: 7,
          height: 1024,
          width: 1024,
          steps: 30,
          samples: 1,
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`Non-200 response: ${await response.text()}`);
    }

    const responseJSON = (await response.json()) as GenerationResponse;
    const base64Image = responseJSON.artifacts[0].base64;
    setGeneratedImage(`data:image/png;base64,${base64Image}`);
  };

  const handleSaveImage = async () => {
    if (!generatedImage) {
      return;
    }

    const fileName = `${prompt}.png`;

    // Base64文字列からプレフィックスを削除
    const base64Data = generatedImage.replace(/^data:image\/png;base64,/, "");

    // Base64をバイナリデータに変換
    const binaryData = Uint8Array.from(atob(base64Data), (char) =>
      char.charCodeAt(0)
    );

    // 画像をストレージにアップロード
    const { error } = await supabase.storage
      .from("generate-image")
      .upload(fileName, binaryData.buffer, {
        contentType: "image/png",
      });

    if (error) {
      console.error("Error uploading image: ", error);
    } else {
      console.log("Image uploaded successfully!");
    }
  };

  return (
    <>
      <div>
        <input
          type="text"
          className="border"
          onChange={(e) => setPrompt(e.target.value)}
          value={prompt}
        />
        <button
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
          onClick={handleGenerateImage}
        >
          生成
        </button>
      </div>
      {generatedImage && (
        <div>
          <h2>Generated Image:</h2>
          <img src={generatedImage} alt="Generated" />
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            onClick={handleSaveImage}
          >
            保存
          </button>
        </div>
      )}
    </>
  );
}

export default App;
```

ポイントはBase64のエンコード(文字列)をバイナリ(写真)に変更して保存することです

```tsx
    // Base64文字列からプレフィックスを削除
    const base64Data = generatedImage.replace(/^data:image\/png;base64,/, "");

    // Base64をバイナリデータに変換
    const binaryData = Uint8Array.from(atob(base64Data), (char) =>
      char.charCodeAt(0)
    );
```

画像生成をすると「保存」ボタンが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/8ebd94c0-01d9-4865-b353-ea8aedffa306.png)

保存ボタンをおすとStorageに保存されたことがわかります

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/36a1b760-dead-fc0a-621e-38df572014de.png)

# 4. 画像を一覧で表示する

次にギャラリーを作るために保存された画像の一覧を取得できるかたしかめます

```tsx:src/App.tsx
import { useEffect, useState } from "react";
import { supabase } from "./utils/supabase";

interface GenerationResponse {
  artifacts: Array<{
    base64: string;
    seed: number;
    finishReason: string;
  }>;
}

function App() {
  const [generatedImage, setGeneratedImage] = useState<string | null>(null);
  const [imageList, setImageList] = useState<string[]>([]);
  const [prompt, setPrompt] = useState("");
  const engineId = "stable-diffusion-v1-6";
  const apiKey = import.meta.env.VITE_STABILITY_API_KEY;
  const apiHost = "https://api.stability.ai";

  useEffect(() => {
    fetchImages();
  }, []);

  async function fetchImages() {
    const { data, error } = await supabase.storage
      .from("generate-image")
      .list();

    if (error) {
      console.error("Error fetching images: ", error);
      return;
    }

    if (data) {
      const imageUrls = await Promise.all(
        data.map(async (image) => {
          if (image.name === ".emptyFolderPlaceholder") {
            return "";
          }

          const { data: signedUrlData, error: signedUrlError } =
            await supabase.storage
              .from("generate-image")
              .createSignedUrl(image.name, 60);

          if (signedUrlError) {
            console.error("Error creating signed URL: ", signedUrlError);
            return "";
          }

          return signedUrlData?.signedUrl ?? "";
        })
      );

      setImageList(imageUrls.filter((url) => url !== ""));
    }
  }

  const handleGenerateImage = async () => {
    const response = await fetch(
      `${apiHost}/v1/generation/${engineId}/text-to-image`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
          Authorization: `Bearer ${apiKey}`,
        },
        body: JSON.stringify({
          text_prompts: [
            {
              text: prompt,
            },
          ],
          cfg_scale: 7,
          height: 1024,
          width: 1024,
          steps: 30,
          samples: 1,
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`Non-200 response: ${await response.text()}`);
    }

    const responseJSON = (await response.json()) as GenerationResponse;
    const base64Image = responseJSON.artifacts[0].base64;
    setGeneratedImage(`data:image/png;base64,${base64Image}`);
  };

  const handleSaveImage = async () => {
    if (!generatedImage) {
      return;
    }

    const fileName = `${prompt}.png`;
    const base64Data = generatedImage.replace(/^data:image\/png;base64,/, "");
    const binaryData = Uint8Array.from(atob(base64Data), (char) =>
      char.charCodeAt(0)
    );

    const { error } = await supabase.storage
      .from("generate-image")
      .upload(fileName, binaryData.buffer, {
        contentType: "image/png",
      });

    if (error) {
      console.error("Error uploading image: ", error);
    } else {
      console.log("Image uploaded successfully!");
      // 画像が保存された後、リストを再取得
      fetchImages();
    }
  };

  return (
    <>
      <div>
        <input
          type="text"
          className="border"
          onChange={(e) => setPrompt(e.target.value)}
          value={prompt}
        />
        <button
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
          onClick={handleGenerateImage}
        >
          生成
        </button>
      </div>
      <ul>
        {imageList.map((imageUrl, index) => (
          <li key={index}>
            <img src={imageUrl} alt={`Generated ${index}`} />
          </li>
        ))}
      </ul>
      {generatedImage && (
        <div>
          <h2>Generated Image:</h2>
          <img src={generatedImage} alt="Generated" />
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            onClick={handleSaveImage}
          >
            保存
          </button>
        </div>
      )}
    </>
  );
}

export default App;
```

ポイントを紹介します。

```tsx
    if (data) {
      const imageUrls = await Promise.all(
        data.map(async (image) => {
          if (image.name === ".emptyFolderPlaceholder") {
            return "";
          }

          const { data: signedUrlData, error: signedUrlError } =
            await supabase.storage
              .from("generate-image")
              .createSignedUrl(image.name, 60);
```

supabaseの画像は複数あるため`Promise.all`で非同期に並列で行っています。
storageからデータを取得すると`.emptyFolderPlaceholder`という謎のデータが帰ってくるので除外する必要があります。

```tsx
          if (image.name === ".emptyFolderPlaceholder") {
            return "";
          }
```

また、画像は60分だけ有効なURLを発行していまうす

```tsx
            await supabase.storage
              .from("generate-image")
              .createSignedUrl(image.name, 60);
```


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/a0fc19aa-65e4-2cfe-0b01-df0fc3b2b28b.png)

ここまでで今回のアプリに必要な機能はすべて実装できたのでスタイリングをしていきます。

# 5. デザインを整える

あとはTailwindCSSでデザインを整えます。
ここは本質ではないのでコードを載せておきますので、各自確認してみてください！

```tsx:src/App.tsx
import { useEffect, useState } from "react";
import { supabase } from "./utils/supabase";

interface GenerationResponse {
  artifacts: Array<{
    base64: string;
    seed: number;
    finishReason: string;
  }>;
}

function App() {
  const [generatedImage, setGeneratedImage] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [imageList, setImageList] = useState<string[]>([]);
  const [prompt, setPrompt] = useState("");
  const engineId = "stable-diffusion-v1-6";
  const apiKey = import.meta.env.VITE_STABILITY_API_KEY;
  const apiHost = "https://api.stability.ai";

  useEffect(() => {
    fetchImages();
  }, []);

  async function fetchImages() {
    const { data, error } = await supabase.storage
      .from("generate-image")
      .list();

    if (error) {
      console.error("Error fetching images: ", error);
      return;
    }

    if (data) {
      const imageUrls = await Promise.all(
        data.map(async (image) => {
          if (image.name === ".emptyFolderPlaceholder") {
            return "";
          }

          const { data: signedUrlData, error: signedUrlError } =
            await supabase.storage
              .from("generate-image")
              .createSignedUrl(image.name, 60);

          if (signedUrlError) {
            console.error("Error creating signed URL: ", signedUrlError);
            return "";
          }

          return signedUrlData?.signedUrl ?? "";
        })
      );

      setImageList(imageUrls.filter((url) => url !== ""));
    }
  }

  const handleGenerateImage = async () => {
    setIsLoading(true);
    const response = await fetch(
      `${apiHost}/v1/generation/${engineId}/text-to-image`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
          Authorization: `Bearer ${apiKey}`,
        },
        body: JSON.stringify({
          text_prompts: [
            {
              text: prompt,
            },
          ],
          cfg_scale: 7,
          height: 1024,
          width: 1024,
          steps: 30,
          samples: 1,
        }),
      }
    );

    if (!response.ok) {
      setIsLoading(false);
      throw new Error(`Non-200 response: ${await response.text()}`);
    }

    const responseJSON = (await response.json()) as GenerationResponse;
    const base64Image = responseJSON.artifacts[0].base64;
    setGeneratedImage(`data:image/png;base64,${base64Image}`);
    setIsLoading(false);
  };

  const handleSaveImage = async () => {
    if (!generatedImage) {
      return;
    }

    const fileName = `${prompt}.png`;
    const base64Data = generatedImage.replace(/^data:image\/png;base64,/, "");
    const binaryData = Uint8Array.from(atob(base64Data), (char) =>
      char.charCodeAt(0)
    );

    const { error } = await supabase.storage
      .from("generate-image")
      .upload(fileName, binaryData.buffer, {
        contentType: "image/png",
      });

    if (error) {
      console.error("Error uploading image: ", error);
    } else {
      console.log("Image uploaded successfully!");
      fetchImages();
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-black via-gray-900 to-gray-800 text-white p-8">
      <h1 className="text-6xl text-center bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-pink-600 font-extrabold tracking-wide my-8">
        AI Image Generator
      </h1>
      <div className="flex justify-center mb-8">
        <div className="flex flex-col sm:flex-row gap-2 p-4 bg-gray-800 rounded-lg shadow-lg w-full max-w-xl">
          <input
            type="text"
            placeholder="Describe your imagination..."
            className="flex-grow p-3 bg-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 transition"
            onChange={(e) => setPrompt(e.target.value)}
            value={prompt}
          />
          <button
            disabled={isLoading}
            className="bg-gradient-to-r from-purple-500 to-pink-500 text-white px-6 py-3 rounded-md hover:opacity-80 transition-opacity disabled:opacity-50 flex items-center justify-center"
            onClick={handleGenerateImage}
          >
            {isLoading ? (
              <div className="animate-spin h-5 w-5 border-4 border-white rounded-full border-t-transparent"></div>
            ) : (
              <>Generate</>
            )}
          </button>
        </div>
      </div>

      <div className="mb-12 transition-all duration-500 ease-in-out max-w-xl mx-auto">
        <div className="relative group aspect-square shadow-xl">
          {generatedImage ? (
            <img
              src={generatedImage}
              alt="Generated"
              className="w-full h-full object-cover rounded-lg"
            />
          ) : (
            <div className="w-full h-full bg-gray-700 rounded-lg flex items-center justify-center text-xl">
              Let's generate
            </div>
          )}
          {generatedImage && (
            <div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition-all duration-300 flex items-center justify-center opacity-0 group-hover:opacity-100">
              <button
                onClick={handleSaveImage}
                className="bg-white bg-opacity-20 backdrop-filter backdrop-blur-sm p-4 rounded-full shadow-md hover:bg-opacity-30 transition-all duration-300"
              >
                ★
              </button>
            </div>
          )}
        </div>
      </div>

      <h2 className="text-3xl font-bold mb-6 flex items-center max-w-xl mx-auto">
        Your Imagination Gallery
      </h2>
      <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 max-w-xl mx-auto">
        {imageList.map((img, index) => (
          <div
            key={index}
            className="relative group aspect-square overflow-hidden rounded-lg shadow-lg transition-all duration-300 hover:scale-105"
          >
            <img
              src={img}
              alt={`Gallery ${index}`}
              className="w-full h-full object-cover"
            />
            <div className="absolute inset-0 bg-gradient-to-t from-black to-transparent opacity-0 group-hover:opacity-70 transition-opacity duration-300" />
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/32363cdf-7447-74aa-4f47-4eb7e2f581c3.png)

![Peek 2024-09-06 11-28.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/95db907e-7077-d9c3-c3ff-2b0e086b20b8.gif)

# このあとの開発

余力がある方は以下の開発にも挑戦してみてください！

1. お気に入りから削除する機能
2. お気に入りをしたらギャラリーに反映される

# おわりに

Supabaseを利用することで簡単に画像ジェネレーターを作ることができました！
ストレージ機能を利用できると個人開発の幅も広がっておすすめです。
ぜひとも今回の内容を活用してみてください！

今回の内容は以下の動画でも学べますのでより詳しく知りたい方はぜひご覧ください

<iframe width="560" height="315" src="https://www.youtube.com/embed/n6WVA5zDEug?si=BGAXCm--ZTqIW_dc" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

https://youtu.be/n6WVA5zDEug

ここまで読んでいただけた方はいいねとストックよろしくお願いします。
@Sicut_study をフォローいただけるととてもうれしく思います。

普段はTwitterでエンジニアに関する情報を発信していますのでよければ友達になってください👇

https://x.com/Sicut_study

また明日の記事でお会いしましょう！

# JISOUのメンバー募集中！
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか？

興味のある方は、ぜひホームページからお気軽にご連絡ください！
▼▼▼

https://projisou.jp