😀

Next.js を使った画像アップロード機能を読み取った。

2024/04/30に公開

動機

現在、検討しているアプリがあるが、どのようにすればそもそも画像を投稿できるのかわからなかったので、Next.jsとsupabaseを用いて画像投稿アプリを別の方の記事を参照して作ってみた。自分なりにわからなかったところをメモしていく。

事前準備

ローカル

terminal
npx create-next-app -e with-supabase

プロジェクト名を聞かれるので任意の名前にしましょう。(例:my-image)
これを試すとNext.jsにプラスしてsupabaseも準備できます。Next.js入れただけの時と画面が違うので、注意してください。次のコードを試して以下のような画面が出れば正解です。

terminal
npm run dev

次に、env.exampleというファイルの名前をenv.localに書き換えます。

supabase

https://supabase.com/
上記のサイトにとび、Start your projectでアカウントを作成してください。(個人的にはgithub連携が手軽でおすすめです。)

Create a new projectで新しいプロジェクトを以下のように作成します。(free planの方は2個までsupabaseのactiveなプロジェクトを作れます。注意してください。)
プロジェクト名とデータベースのパスワードは任意ですので、自由にしてください。(パスワードを忘れないようにしましょう)

それでは、APIを取得します。
Settings→APIの順で移動し。移動し。

ローカルに移り、上記のProject URLとProject API keysのanonと書いてあるものを=の次にコピーしてきます。

env.local
+NEXT_PUBLIC_SUPABASE_URL=自分のproject URL
+NEXT_PUBLIC_SUPABASE_ANON_KEY=自分のproject API key

これで事前準備は完成です。

Supabaseでストレージを作成

Storage

ストレージとはデータを保存していく箱のようなものを指す。ここに画像をアップロードするためには受け入れる箱を作る必要があります。

次の順番でストレージを作っていく。



これでストレージができました。ポリシーを作成する必要があるのでポリシーを作成します。

Policies

次のような手順でポリシーをセッティングしていきます。


「Other policies under storage.objects」と「Policies under storage.buckets
」は次、2枚の画像と全く同じ内容で作ってください。


デフォルトのSELECTからALLにチェックを変更して、WITHC CHECK expressionにもtrueを入れます。

この内容であっていたら「Review」を押します。

Next.jsファイル実装。

以下の通りに、Next.jsのファイルを整理します。

消すファイルは

  • app/auth以下
  • app/login以下
  • app/protected以下
  • components/.tstutorial以下
  • components/AuthButton.tsx
  • components/DeployButton.tsx
  • components/Header.tsx
  • components/NextLogo.tsx
  • components/SupabaseLogo.tsx
  • utils/supabase/client.ts
  • utils/supabase/middleware.ts
  • utils/supabase/server.ts
  • middleware.ts
    です。

追加するのは

  • components/header.tsx
  • components/imageApp.tsx
  • utils/supabase/supabase.ts

実装するための下準備

Supabaseにアクセスするためのクライアントを作成します。

utils/supabase/supabase.ts
import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);

globals.cssもいじっておきます。(コピペ)

app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

ファイル名をランダム生成するために以下のコードを2つターミナルで実行します。

terminal
npm i uuid
npm i --save-dev @types/uuid

Next.jsでの実装

コピペで以下のコードを実装していきます。

app/layout.tsx
import Header from "@/components/header";
import "./globals.css";

const defaultUrl = process.env.VERCEL_URL
  ? `https://${process.env.VERCEL_URL}`
  : "http://localhost:3000";

export const metadata = {
  metadataBase: new URL(defaultUrl),
  title: "Next.js and Supabase Starter Kit",
  description: "The fastest way to build apps with Next.js and Supabase",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className="bg-background text-foreground">
        <Header></Header>
        <main className="min-h-screen flex flex-col items-center px-2">
          {children}
        </main>
      </body>
    </html>
  );
}
app/page.tsx
import ImageApp from "@/components/imageApp";

export default function Private() {
  return (
    <>
      <h1 className="mb-4 pt-28 text-4xl">画像投稿アプリ</h1>
      <p className="mb-4">
        ※表示された画像は5分でリンクが利用不可になります。<br />
        再度アクセスしたい時はリロードボタンを押してください。
      </p>
      <div className="flex-1 w-full flex flex-col items-center">
        <ImageApp />
      </div>
    </>

  );
}
components/header.tsx
import Link from 'next/link'

export default function Header() {
  return (
    <header className="p-4 border-b-2 border-gray-300 fixed w-full bg-white">
      <ul className="w-full max-w-3xl m-auto flex font-medium flex-row">
        <li className=' pr-4'>
          <Link className="text-gray-700 hover:text-blue-700" href="/">Home</Link>
        </li>
        <li>
          <Link className="text-gray-700 hover:text-blue-700" href="/private">画像投稿アプリ(プライベート画像)</Link>
        </li>
      </ul>
    </header>
  )
}
components/imageApp.tsx
"use client"
import { supabase } from "@/utils/supabase/supabase"
import { useEffect, useState } from "react"
import { v4 as uuidv4 } from 'uuid'

export default function ImageApp() {
  const [urlList, setUrlList] = useState<string[]>([])
  const [loadingState, setLoadingState] = useState("hidden")
  const listAllImage = async () => {
    const tempUrlList: string[] = []
    setLoadingState("flex justify-center")
    const { data, error } = await supabase
      .storage
      .from('private-image-bucket')  //str
      .list("img", {
        limit: 100,
        offset: 0,
        sortBy: { column: 'created_at', order: 'desc' },
      })
    if (error) {
      console.log(error)
      return
    }
    const fileList = data

    for (let index = 0; index < fileList.length; index++) {
      if (fileList[index].name != ".emptyFolderPlaceholder") {
        const filePath = `img/${fileList[index].name}`
        const { data, error } = await supabase.storage.from('private-image-bucket').createSignedUrl(filePath, 300)
        if (error) {
          console.log(error)
          return
        }
        tempUrlList.push(data.signedUrl)
      }
    }
    setUrlList(tempUrlList)
    setLoadingState("hidden")
  }

  useEffect(() => {
    (async () => {
      await listAllImage()
    })()
  }, [])


  const [file, setFile] = useState<File>()
  const handleChangeFile = (e: any) => {
    if (e.target.files.length !== 0) {
      setFile(e.target.files[0]);
    }

  };
  const onSubmit = async (
    event: any
  ) => {
    event.preventDefault();

    if (file!!.type.match("image.*")) {
      const fileExtension = file!!.name.split(".").pop()
      const { error } = await supabase.storage
        .from('private-image-bucket')
        .upload(`img/${uuidv4()}.${fileExtension}`, file!!)
      if (error) {
        alert("エラーが発生しました:" + error.message)
        return
      }
      setFile(undefined)

      await listAllImage()
    } else {
      alert("画像ファイル以外はアップロード出来ません。")
    }

  }
  return (
    <>
      <form className="mb-4 text-center" onSubmit={onSubmit}>
        <input
          className="relative mb-4 block w-full min-w-0 flex-auto rounded border border-solid border-neutral-300 bg-clip-padding px-3 py-[0.32rem] text-base font-normal text-neutral-700 transition duration-300 ease-in-out file:-mx-3 file:-my-[0.32rem] file:overflow-hidden file:rounded-none file:border-0 file:border-solid file:border-inherit file:bg-neutral-100 file:px-3 file:py-[0.32rem] file:text-neutral-700 file:transition file:duration-150 file:ease-in-out file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem] hover:file:bg-neutral-200 focus:border-primary focus:text-neutral-700 focus:shadow-te-primary focus:outline-none"
          type="file"
          id="formFile"
          accept="image/*"
          onChange={(e) => { handleChangeFile(e) }}
        />
        <button type="submit" disabled={file == undefined} className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center disabled:opacity-25">
          送信
        </button>
      </form>
      <div className="w-full max-w-3xl">
        <button onClick={listAllImage} className="py-2.5 px-5 me-2 mb-2 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 ">リロード</button>
        <div className={loadingState} aria-label="読み込み中">
          <div className="animate-spin h-10 w-10 border-4 border-blue-500 rounded-full border-t-transparent"></div>
        </div>
        <ul className="flex flex-wrap w-full">
          {urlList.map((item, index) => (
            <li className="w-1/4 h-auto p-1" key={item}>
              <a className="hover:opacity-50" href={item} target="_blank">
                <img className="object-cover max-h-32 w-full" src={item} />
              </a>
            </li>
          ))}
        </ul>
      </div>
    </>

  )

}

以下のような形で画像がアップロードできる機能が搭載できました。

各種機能の解説

1.初期化と画像リストの読み込み

Supabaseのストレージから画像のリストを取得し、それぞれの画像に対して署名付きURLを生成する関数listAllImageを定義します。

まず、useStateフックを使用して、アップロードされた画像のURLリスト(urlList)とローディング状態(loadingState)を管理します。

機能の該当部分
components/imageApp.tsx
const [urlList, setUrlList] = useState<string[]>([])
const [loadingState, setLoadingState] = useState("hidden")

listAllImage関数は、空の一時リスト(tempUrlList)を作成します。
ローディング状態を表示するためにsetloadingStateを表します。

機能の該当部分
components/imageApp.tsx
    const tempUrlList: string[] = []
    setLoadingState("flex justify-center")

次に、Supabaseのストレージから'private-image-bucket'というバケットの'img'ディレクトリにあるファイルのリストを取得します。取得するファイルの上限は最新から最大100枚まで表示して、作成日時の最新から古いものにソートします。

機能の該当部分
components/imageApp.tsx
const { data, error } = await supabase
      .storage
      .from('private-image-bucket')
      .list("img", {
        limit: 100,
        offset: 0,
        sortBy: { column: 'created_at', order: 'desc' },
      })
    if (error) {
      console.log(error)
      return
    }
    const fileList = data

取得したデータ(おそらくファイルのリスト)をループして、各ファイルの名前を一時リスト(tempUrlList)に追加しています。ただし、ファイルの名前が".emptyFolderPlaceholder"(空のフォルダ)でない場合のみ追加します。

機能の該当部分
components/imageApp.tsx
for (let index = 0; index < data.length; index++) {
      if (data[index].name != ".emptyFolderPlaceholder") {
        tempUrlList.push(data[index].name)
      }
    }

署名付きURLの生成には、再びSupabaseのストレージAPIを使用します。ファイルパスはimg/${fileList[index].name}で、有効期限は300秒です。生成した署名付きURLは一時リスト(tempUrlList)に追加されます。

すべてのファイルに対して署名付きURLを生成したら、urlListステートを更新し、ローディング状態を非表示("hidden")に設定します。

機能の該当部分
components/imageApp.tsx
        const { data, error } = await supabase.storage.from('private-image-bucket').createSignedUrl(filePath, 300)
        if (error) {
          console.log(error)
          return
        }
        tempUrlList.push(data.signedUrl)
      }
    }
    setUrlList(tempUrlList)
    setLoadingState("hidden")
  }

コンポーネントがマウントされたときに一度だけ画像のリストを取得します。

機能の該当部分
components/imageApp.tsx
useEffect(() => {
    (async () => {
      await listAllImage()
    })()
  }, [])

2. 画像ファイルの選択

ユーザーがファイルを選択すると、そのファイルがステートに保存され、後続の処理(例えば、ファイルのアップロード)で使用できるようになります。

ファイル選択イベント(例えば、ファイル選択ダイアログからファイルが選択されたとき)が発生したときに呼び出されます。

関数はイベントオブジェクト(e)を引数に取ります。このイベントオブジェクトから、選択されたファイルのリストを取得します(e.target.files)。選択されたファイルが1つ以上ある場合(e.target.files.length !== 0)、最初のファイル(e.target.files[0])をsetFile関数を使用してステートに保存します。

setFileは、useStateから返される関数で、ファイルのステートを更新するために使用されます。

機能の該当部分
components/imageApp.tsx
const handleChangeFile = (e: any) => {
  if (e.target.files.length !== 0) {
    setFile(e.target.files[0]);
  }
};

3. 画像のアップロード

関数はイベントオブジェクト(event)を引数に取ります。最初に、event.preventDefault()を呼び出して、フォームのデフォルトの送信動作をキャンセルします。

次に、選択されたファイル(file)のタイプが画像であるかどうかをチェックします。これは、file.type.match("image.*")を使用して行います。これは正規表現を使用して、ファイルタイプが"image"で始まる(つまり、画像ファイルである)かどうかをチェックします。

ファイルが画像である場合、ファイルの拡張子を取得し、そのファイルをSupabaseのストレージにアップロードします。アップロードするファイルのパスは、UUIDとファイルの拡張子を使用して生成されます。これにより、各アップロードされたファイルに一意の名前が付けられます。

アップロードにエラーが発生した場合、エラーメッセージをアラートとして表示し、関数の実行を終了します。エラーがなければ、setFile関数を使用してファイルのステートをundefinedに設定し、listAllImage関数を呼び出して画像のリストを更新します。

ファイルが画像でない場合、アラートを表示してユーザーに通知します。

機能の該当部分
components/imageApp.tsx
const onSubmit = async (
    event: any
  ) => {
    event.preventDefault();

    if (file!!.type.match("image.*")) {
      const fileExtension = file!!.name.split(".").pop()
      const { error } = await supabase.storage
        .from('private-image-bucket')
        .upload(`img/${uuidv4()}.${fileExtension}`, file!!)
      if (error) {
        alert("エラーが発生しました:" + error.message)
        return
      }
      setFile(undefined)

      await listAllImage()
    } else {
      alert("画像ファイル以外はアップロード出来ません。")
    }

  }

4. UI コンポーネント

この関数は、フォームが送信されると呼び出され、選択されたファイルをアップロードします。
まず、<form>タグが定義されており、フォームを形成しています。
<input>タグは、ユーザーが画像ファイルを選択できるようにするためのもので、onChangeイベントハンドラーとしてhandleChangeFile関数が設定されています。この関数は、ユーザーがファイルを選択すると呼び出され、選択されたファイルをステートに保存(コンポーネント内で管理される状態(state)にデータを保持)します。
<button>タグは、フォームの送信をトリガーするためのもので、onSubmitイベントハンドラーとしてonSubmit関数が設定されています。

次に、もう一つの<button>タグは、画像のリストを再読み込みするためのものです。onClickイベントハンドラーとしてlistAllImage関数が設定されています。この関数は、ボタンがクリックされると呼び出され、画像のリストを更新します。

その下には、読み込み状態を表示するための<div>タグがあります。これにより読み込み状態に応じて表示が切り替わります。

最後に、<ul>タグがあり、これはアップロードされた画像のリストを表示するためのものです。urlListという名前のステートがmap関数を使用してリストの各項目に変換され、各項目は<li>タグとして表示されます。各<li>タグの中には、画像のURLをhref属性に持つ<a>タグと、そのURLをsrc属性に持つ<img>タグが含まれています。これにより、アップロードされた画像がリストとして表示され、各画像をクリックすると新しいタブで開くことができます。

機能の該当部分
components/imageApp.tsx
<>
      <form className="mb-4 text-center" onSubmit={onSubmit}>
        <input
          className="relative mb-4 block w-full min-w-0 flex-auto rounded border border-solid border-neutral-300 bg-clip-padding px-3 py-[0.32rem] text-base font-normal text-neutral-700 transition duration-300 ease-in-out file:-mx-3 file:-my-[0.32rem] file:overflow-hidden file:rounded-none file:border-0 file:border-solid file:border-inherit file:bg-neutral-100 file:px-3 file:py-[0.32rem] file:text-neutral-700 file:transition file:duration-150 file:ease-in-out file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem] hover:file:bg-neutral-200 focus:border-primary focus:text-neutral-700 focus:shadow-te-primary focus:outline-none"
          type="file"
          id="formFile"
          accept="image/*"
          onChange={(e) => { handleChangeFile(e) }}
        />
        <button type="submit" disabled={file == undefined} className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center disabled:opacity-25">
          送信
        </button>
      </form>
      <div className="w-full max-w-3xl">
        <button onClick={listAllImage} className="py-2.5 px-5 me-2 mb-2 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 ">リロード</button>
        <div className={loadingState} aria-label="読み込み中">
          <div className="animate-spin h-10 w-10 border-4 border-blue-500 rounded-full border-t-transparent"></div>
        </div>
        <ul className="flex flex-wrap w-full">
          {urlList.map((item, index) => (
            <li className="w-1/4 h-auto p-1" key={item}>
              <a className="hover:opacity-50" href={item} target="_blank">
                <img className="object-cover max-h-32 w-full" src={item} />
              </a>
            </li>
          ))}
        </ul>
      </div>
    </>

最後に

一つ一つ調べると、今までやったことの集合のようなコンポーネントだった。初期化と画像リストの読み込みは参考にしたい。
PlanetScaleが有料になったので参考にしたい。

参考文献:
https://note.com/libproc/n/nad28ec41ccd1

Discussion