見出し画像

Next.js14で画像アップロード機能を作ってみる

はじめに

はじめまして。くふう AI スタジオの佐藤といいます。GitHubは`@itumo-arigatone`です。
普段はサーバーサイドエンジニアとしてRuby on Railsを使用して業務をしています。

今回はNext.js14のServer Actionsという機能を使って画像のアップロード機能を作ってみようと思います。
けっこう新しい機能ということもあり、ChatGPT君もなかなか教えてくれなかったのでブログのネタにしてみました。
趣味ということもあり、なんとかなれー!って気持ちで雑に書いている部分もありますが許してください。
あくまで自分なりに考えついた実装の紹介なので数ある例の一つだと思って眺めてください。

作りたい物の概要

単純なブログの投稿、再表示機能を作ります。(編集までやると長いので再表示)
また、画像はAWS S3に保存したいので、MinIOにアップロードする形の実装にします。

使うもの

  • Next.js14

    • App Router

      • 何も考えなかったり、理解が間違えていたりしたのでめちゃくちゃになりました。ファイル構造は参考にしないでください。

    • Server Actions

      • 14から安定版として公開された機能のようです。

  • TipTap

    • Markdownで書けるテキストエディター

  • Prisma

  • PostgreSQL

  • MinIO

    • ローカルでS3の代わりになるもの

      • MinIOの設定は各自でなんとかしてください。

環境構築

他記事でも紹介されているような構成のため、詳しい中身は省略します

実装でやること

処理の流れは以下のようになります。

  1. 画像を選択

  2. 画像のデータをFormDataに保持

  3. プレビュー用に画像をBase64に変更

  4. エディタにBase64のimgタグを設定

  5. imgタグのsrcに書かれたbase64を消す

  6. DBにブログの内容と画像の名前を保存

  7. FormDataにある画像をS3にアップロード

  8. 再表示

Chu! わかりづらくてご・め・ん

0. ブログ新規作成ページを作成

ブログの新規作成ページを作成します。

// app/console/blogs/new/page.tsx

'use client'

import { redirect } from 'next/navigation'
import { PrismaClient } from '@prisma/client'
import { parse, HTMLElement } from 'node-html-parser'
import { BlogEditor } from '@/app/(components)/BlogEditor'
import { uploadImages } from '@/lib/uploadImages'
import { syncKeyAndFile } from '@/lib/syncKeyAndFile'
import { postBlog } from '@/lib/postBlog' // Server Actions
import TipTap from "@/app/(components)/Tiptap";


export default function Page() {
  const [image, setImage] = useState<File[]>([])

  const handleSubmit = async (event: any) => {
    event.preventDefault();
    const formData = new FormData(event.target);
    image.forEach(img => {
      formData.append('imageData', img)
    })
    postBlog(formData)
  }

  return (
    <form onSubmit={handleSubmit} className="blog-editor">
      <input type='text' name='title' className='title' />
      <TipTap setImage={setImage} />
      <div className="bottom-button-area bg-sub">
        <button type="submit">登録</button>
      </div>
    </form>
  )
}

よくある例で

export default function Page() {
  async function createInvoice(formData: FormData) {
    'use server'

    const rawFormData = {
      customerId: formData.get('customerId'),
      amount: formData.get('amount'),
      status: formData.get('status'),
    }

    // DBに保存処理
  }

  return <form action={createInvoice}>...</form>
}

`<form action={createInvoice}>...</form>`でFormDataを取得してServerActionsを実行するのですが、
今回は画像をFormDataに別途仕込みたいため、onSubmitでFormDataを新規に作成して画像を処理します。
また、onSubmitにした影響でClient Componentにする必要があるため、Server Actionsの処理はhandleSubmit内で呼び出します。

1. 画像を選択 -> プレビュー

画像をブラウザにアップしてプレビューさせます。

まずはTipTapエディターを使ってテキストの入力エリアとファイルアップロードボタンを用意します。

// app/(components)/Tiptap.tsx

'use client'

import { useState, useRef } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import TiptapMenuBar from '@/app/(components)/TiptapMenuBar'
import { createImagePreviewUrl } from '@/lib/createImagePreviewUrl'

interface Param {
  blog?: Blog;
  setImage?: any;
}

interface Blog {
  id: number;
  title: string;
  content: string;
  created_at: Date;
}

const Tiptap = (param: Param) => {
  const [content, setContent] = useState(param.blog?.content || null)
  const inputFileRef = useRef<HTMLInputElement>(null)

  const editor = useEditor({
    extensions: [
      Image.configure({
        HTMLAttributes: {
          allowBase64: true,
          class: 'uploaded-image',
        },
      })
    ],
    content: content,
    onUpdate({ editor }) {
      setContent(editor.getHTML())
    },
  })


  const handleFileChange = async (e: any) => {
    const file = e.target.files[0]
    if (!file) {
      return
    }

    const src = await createImagePreviewUrl(file)
    param.setImage((prevImages: File[]) => [...prevImages, file])

    // ここでimgタグを生成する。
    editor.chain().focus().setImage({
      src: src,
      alt: file.name // altの値をS3のキーに使うので重要。本当はdata-keyみたいな属性を設定したいけどカスタムのTipTapの機能作らなければいけないため妥協。
    }).run()
  };


  return (
    <>
      <input ref={inputFileRef} type="file" id="image" name="file" onChange={handleFileChange} />
      <EditorContent editor={editor} />
      <input type='hidden' name='content' defaultValue={content || ''} />
    </>
  )
}

export default Tiptap

ここでポイントなのが`createImagePreviewUrl()`です。`createImagePreviewUrl()`で画像のデータをBase64に変換してsrcの中に入れてプレビューできるようにしています。

// lib/createImagePreviewUrl.ts

export function createImagePreviewUrl(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    if (!file) {
      resolve('')
      return
    }

    let reader: FileReader | null = new FileReader();
    reader.onloadend = () => {
      // base64のimageUrlを生成する。
      const base64 = reader && reader.result

      if (base64 && typeof base64 === 'string') {
        resolve(base64)
      } else {
        resolve('')
      }
    };

    reader.onerror = () => {
      reject(new Error('Failed to read file'))
    };

    reader.readAsDataURL(file)
  });
}

どこかのサイトのコードを参考にしたのですがリンク見つかりませんでした。ごめんなさい。ひげそーりー🥸

そして、Base64に変換したら

editor.chain().focus().setImage({
    src: src,
    alt: file.name
}).run()

でsrcにBase64を入れたimgタグを生成します。ここの処理はTipTapの処理ですね。これでアップした画像が表示されるはず!
TipTapはClient Componentである必要があるので、'use client'は忘れないようにしてください。

2. ブログの内容をServer ActionsでDBに保存する

まずはコード

// app/(components)/postBlog.ts

import { redirect } from 'next/navigation'
import { PrismaClient } from '@prisma/client'
import { parse, HTMLElement } from 'node-html-parser'
import { BlogEditor } from '@/app/(components)/BlogEditor'
import { replaceImgSrc } from '@/lib/replaceImgSrc'
import { uploadImages } from '@/lib/uploadImages'
import { syncKeyAndFile } from '@/lib/syncKeyAndFile'
import { convertToFiles } from '@/lib/convertToFiles'

async function postBlog(data: FormData) {
  'use server'

  const title = data.get('title')?.toString()
  const content = data.get('content')?.toString()
  const imageFiles = data.getAll('imageData')

  if (!title || !content) {
    return;
  }

  const prisma = new PrismaClient()
  const domContent = parse(content)

  // タグからキーを取得する
  const imageKeys: string[] = [];
  domContent.querySelectorAll('img.uploaded-image').forEach((img: HTMLElement) => {
    // どの画像をS3にアップロードするかを選択するために、altからファイル名を取得する(本来は別の属性をつけてファイル名を保持したかったところ)
    const key = img.getAttribute('alt')
    if (key) {
      imageKeys.push(key)
    }
  })

  // DBに保存する
  const result = await prisma.post.create({
    data: {
      title: title,
      content: replaceImgSrc(domContent, {}), // 保存するときにimgタグのbase64を消してimageKeyだけ残しておく。
      images: {
        create: imageKeys?.map((key: string) => ({
          key: key
        }))
      },
    },
  })

  if (result) {
    let newKeys = { already: {}, new: imageKeys }
    // FormDataから取ってきたデータはFile型以外の型の可能性もあるため確実にFile型になるように変換する。
    let files = convertToFiles(imageFiles)
    // contentにあるimgタグとFormDataにあるファイルを同期させる。
    // 例えばアップロードした後に、画像を消すとFormDataの中に画像は存在するのに
    // imgタグは無くなっている現象が発生して無駄に画像をアップロードしてしまうため削除する。
    const syncedFiles = syncKeyAndFile(newKeys, files)
    // s3にアップロード
    await uploadImages(`blog/${result.id}/`, syncedFiles)
    redirect('/console/blogs')
  }
}

TipTapで入力したブログの内容はDOMの文字列になっているので簡単にDBに保存できます。
ただ、imgタグのsrcにはBase64のながーーーーい値が入っているので保存するときに消したいです。
そこで、srcを置き換えるために、文字列のDOMをquerySelectorAll()を使えるオブジェクトに変換します。
しかし、今までの感覚でnew DOMParser(content);みたいなことするとServer Actionsの中では動かなかったので、
node-html-parserのparse()を使ってquerySelectorAll()を使えるようにします。

imgタグを抽出できるようにしたらsrcの部分にBase64の文字列を入れます。
`replaceImgSrc()`というのを作ってそこで入れています。コードはのちほど。

置き換えた後はprisma.post.create()でDBに保存しています。
また、以下の部分でS3から画像を見つけだす用に画像のファイル名を1対多の関係になるように保存しています。

images: {
  create: imageKeys?.map((key: string) => ({
    key: key
  }))
},

//      /- PostImage1
// Post -- PostImage2
//      \_ PostImage3

// こんなDBのイメージ
// ツタワレ


おまけreplaceImgSrc.ts

// lib/replaceImgSrc.ts

import { HTMLElement } from 'node-html-parser'

interface ImgSrc {
  [key: string]: string;
}

export function replaceImgSrc(doc: HTMLElement, srcs: ImgSrc) {

  // 特定のクラスを持つ<img>タグを検索する
  const images = doc.querySelectorAll('img.uploaded-image')

  if (Object.keys(srcs).length > 0) {
    images.forEach(img => {
      const key = img.getAttribute('alt')
      if (key) {
        img.setAttribute('src', srcs[key])
      }
    })
  } else {
    // 各<img>タグのsrc属性を置き換える
    images.forEach(img => {
      img.setAttribute('src', '/not_found.svg')
    })
  }

  // 置き換えた後のHTML文字列を取得する
  return doc.innerHTML
}

置き換えたDOMを文字列にして返しています。

3. S3(MinIO)にアップロードする

DBにブログの内容を保存したら
前述の

await uploadImages(`blog/${result.id}/`, syncedFiles)

の部分でS3に画像をアップロードしているワケですが、中身は以下のようになっています。

import { s3Client } from '@/lib/s3Client'
import { PutObjectCommand } from '@aws-sdk/client-s3'

const Bucket = process.env.AMPLIFY_BUCKET

// endpoint to upload a file to the bucket
export async function uploadImages(path: string, files: File[]) {

  const responses = await Promise.all(
    files.map(async (file) => {
      const Key = `${path}${file.name}`
      const Body = await file.arrayBuffer() as Buffer

      // Upload the file to S3
      await s3Client().send(new PutObjectCommand({ Bucket, Key, Body }))
    })
  )
}

s3Clientだけ共通化しましたが、
ほとんど、AWSのデベロッパーガイドと一緒です。
`blog/:blog_id/file_name`みたいな感じでブログIDごとに画像を置いています。

これでDBに保存とS3にアップロードができると思います。s3Clientの部分で問題があるのでもうちょっと後でコード見せます。

4. 保存したデータを再表示

ここからは保存したデータを表示します。
まずは表示するページを作ります。

// app/console/blogs/[id]/page.tsx

import { use } from 'react'
import TipTap from '@/app/(components)/Tiptap'
import { PrismaClient } from '@prisma/client'
import { redirect } from 'next/navigation'
import { parse, HTMLElement } from 'node-html-parser'
import { viewS3Client } from '@/lib/viewS3Client'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { GetObjectCommand } from '@aws-sdk/client-s3'
import { replaceImgSrc } from '@/lib/replaceImgSrc'

interface Blog {
  id: number;
  title: string;
  content: string;
  created_at: Date;
}

interface ImgSrc {
  [src: string]: string;
}


async function GetBlog(id: string) {
  'use server'

  const prisma = new PrismaClient()
  const Bucket = process.env.AMPLIFY_BUCKET

  if (!id) {
    return null
  }

  const post = await prisma.post.findUnique({
    where: { id: parseInt(id) },
    include: {
      images: true,
    },
  })

  if (!post) {
    return null
  }

  const domContent = parse(post.content)

  // タグからキーを取得する
  let imgSrc: ImgSrc = {}
  const imgElements = domContent.querySelectorAll('img.uploaded-image')

  for (const img of imgElements) {
    const key = img.getAttribute('alt')

    if (key) {
      const command = new GetObjectCommand({ Bucket, Key: `blog/${id}/${key}` })
      imgSrc[key] = await getSignedUrl(viewS3Client(), command, { expiresIn: 3600 })
    }
  }

  post.content = replaceImgSrc(domContent, imgSrc)

  return post;
}

async function PatchBlog(data: FormData) {
  'use server'
...省略!
}


export default function Page({ params }: { params: { id: string } }) {
  const blog = use(GetBlog(params.id)) as Blog

  return (
    <form action={PatchBlog} className="blog-editor">
      <input type='hidden' name='id' value={blog.id.toString()} />
      <input type='text' name='title' defaultValue={blog.title} className='title' />
      <TipTap blog={blog} />
      <div className="bottom-button-area bg-sub">
        <button type="submit">登録</button>
      </div>
    </form>
  );
};

GetBlogでブログの内容を取得してimgタグのsrcに認証した画像のURLを入れています。

`use(GetBlog(params.id))`って使っちゃっているのですが、何かの条件でビルドのときにバグることあるので、できればserver actionの部分は別ファイルにしてPageはClientComponentにした方がいいかもしれないです。ClientComponentにしたらuseEffectで初期表示時にGetBlogを呼ぶみたいな感じです。

どんなエラーかバグった時のエラー文忘れちゃった。解決策がどこにも載ってなかったんだよね。

5. S3から認証済みの画像のURLを取得

S3から認証済みの画像のURLを取得するのが以下の部分です。

const command = new GetObjectCommand({ Bucket, Key: `blog/${id}/${key}` })
imgSrc[key] = await getSignedUrl(viewS3Client(), command, { expiresIn: 3600 })

aws-sdkを使っているってワケ
ここで注意したいのがS3Clientを`アップロードする時``表示する時`で使い分けているということです。
これは開発中しか問題にならないかもしれないのですが、docker composeを使っているとアップロードする時のエンドポイントと表示する時のエンドポイントが変えなきゃいけないんです。(いけないってこともないのかもしれないけれど)
なのでエンドポイントが異なるS3Clientを準備するようにしました。

import { S3Client } from '@aws-sdk/client-s3'

export function s3Client(): S3Client {
  let s3Client = new S3Client({
    region: 'ap-northeast-1',
    credentials: {
      accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin' as string,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin' as string,
    },
    // アップロードするときはDockerのアプリ?の名前にする。このエンドポイントをs3Client.tsとした
    endpoint: 'http://minio:9000',

    // 表示するときはアプリ?の名前にすると画像が見れないのでIPアドレスにする。このエンドポイントをviewS3Client.tsとした
    // endpoint: 'http://127.0.0.1:9000',
    forcePathStyle: true,
  })
  return s3Client
}

他に方法はあるのかもしれないのですがとりあえずこれで!

6. srcの置き換え

認証済みの画像のURLを取得したら
imgタグにそれをぶち込めばいいです。
保存の時にも使った`replaceImgSrc()`を使って置き換えて、そのデータをTipTapに渡してあげれば再表示できるはずです!

最後に

長くなりましたがこれで投稿→再表示まで出来たと思います。
このnoteを書いている途中でコードが長くて省略したりと動くものを書き換えてしまったのでコピペする場合は調整が必要かと思うのでご了承ください。あと、型もちょっとサボってます。「じゃあ、JSで書けよ」とか言わないでください。TypeScript触りたかったんです🥹

くふうAIスタジオでは、採用活動を行っています。

当社は「AX で 暮らしに ひらめきを」をビジョンに、2023年7月に設立されました。
(AX=AI eXperience(UI/UX における AI/AX)とAI Transformation(DX におけるAX)の意味を持つ当社が唱えた造語) くふうカンパニーグループのサービスの企画開発運用を主な事業とし、非エンジニアさえも当たり前にAIを使いこなせるよう、積極的なAI利活用を推進しています。 (サービスの一例:累計DL数1,000万以上の家計簿アプリ「Zaim」、月間利用者数1,600万人のチラシアプリ「トクバイ」等) AXを活用した未来を一緒に作っていく仲間を募集中です。
ご興味がございましたら、以下からカジュアル面談のお申込みやご応募等お気軽にお問合せください。
https://open.talentio.com/r/1/c/kufu-ai-studio/homes/3849


この記事が気に入ったらサポートをしてみませんか?