5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ElixirDesktopで作るブログアプリ アプリ bucketによるファイルアップロード

Posted at

はじめに

この記事はElixirアドベントカレンダー2025シリーズ2の24日目の記事です。

前回はファイルアップロードAPIを実装したので、今回は実際にアップロードするアプリ側を作っていきます

ファイルインプットを実装

こちらを参考に実装します

アップロードするボタンをラベルにしてタップしてアップロードするファイルを選択するようにします
ファイルが選択されたら一覧表示します
xをおすとcancel_uploadが実行されアップロード対象から除外します

lib/blog_app_web/live/post_live/form.ex
  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app page_title={@page_title} flash={@flash} current_scope={@current_scope}>
      <:back>
        <.link navigate={return_path(@current_scope, @return_to, @post)}>Back</.link>
      </:back>

      <.form for={@form} class id="post-form" phx-change="validate" phx-submit="save">
        <.input field={@form[:text]} type="text" label="Text" />
+       <div class="p-4">
+         <div class="text-sm text-gray-600 text-center">
+           <label
+             for={@uploads.images.ref}
+             class="bg-white text-primary"
+           >
+             <.icon name="hero-photo" class="w-[20vw] h-[10vh] text-primary" />
+             <.live_file_input upload={@uploads.images} class="hidden" />
+             <span>画像をアップロード</span>
+           </label>
+         </div>
+       </div>
+
+       <section phx-drop-target={@uploads.images.ref}>
+         <article :for={entry <- @uploads.images.entries} class="upload-entry flex">
+           <div class="relative w-1/2">
+             <figure>
+               <.live_img_preview entry={entry} />
+               <figcaption>{entry.client_name}</figcaption>
+             </figure>
+
+             <button
+               class="absolute -top-2 -right-2 bg-white border-1 rounded-full"
+               type="button"
+               phx-click="cancel-upload"
+               phx-value-ref={entry.ref}
+               aria-label="cancel"
+             >
+               <.icon name="hero-x-mark" class="w-6 h-6" />
+             </button>
+
+           </div>
+         </article>
+       </section>
        <footer>
          <.button phx-disable-with="Saving..." variant="primary">Save Post</.button>
          <.button navigate={return_path(@current_scope, @return_to, @post)}>Cancel</.button>
        </footer>
      </.form>
    </Layouts.app>
    """
  end

submit時の処理をこちらを参考に実装します

アップロードしたデータを参照できるconsume_uploaded_entryを使って、リクエストに必要な情報を取得します

lib/blog_app_web/live/post_live/form.ex
  @impl true
  def mount(params, _session, socket) do
    {:ok,
     socket
+    |> assign(:uploaded_files, [])
+    |> allow_upload(:images, accept: ~w(.jpg .jpeg .png), max_entries: 2)
     |> assign(:return_to, return_to(params["return_to"]))
     |> apply_action(socket.assigns.live_action, params)}
  end

  ...

  @impl true
  def handle_event("validate", %{"post" => post_params}, socket) do
    changeset = Posts.change_post(socket.assigns.current_scope, socket.assigns.post, post_params)
    {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
  end

  def handle_event("save", %{"post" => post_params}, socket) do
    save_post(socket, socket.assigns.live_action, post_params)
  end

+ def handle_event("cancel-upload", %{"ref" => ref}, socket) do
+   {:noreply, cancel_upload(socket, :images, ref)}
+ end

  defp save_post(socket, :edit, post_params) do
    case Posts.update_post(socket.assigns.current_scope, socket.assigns.post, post_params) do
      {:ok, post} ->
        {:noreply,
         socket
         |> put_flash(:info, "Post updated successfully")
+        |> handle_uploaded_entries(post)
         |> push_navigate(
           to: return_path(socket.assigns.current_scope, socket.assigns.return_to, post)
         )}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

  defp save_post(socket, :new, post_params) do
    case Posts.create_post(socket.assigns.current_scope, post_params) do
      {:ok, post} ->
        {:noreply,
         socket
         |> put_flash(:info, "Post created successfully")
+        |> handle_uploaded_entries(post)
         |> push_navigate(
           to: return_path(socket.assigns.current_scope, socket.assigns.return_to, post)
         )}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

+ defp handle_uploaded_entries(socket, post) do
+   case uploaded_entries(socket, :images) do
+     {[_ | _] = entries, []} ->
+       for entry <- entries do
+         consume_uploaded_entry(socket, entry, fn %{path: path} ->
+           Posts.upload(post.id, path, entry.client_name, entry.client_type)
+         end)
+       end
+
+       socket
+     _ ->
+       socket
+   end
+ end

  defp return_path(_scope, "index", _post), do: ~p"/posts"
  defp return_path(_scope, "show", post), do: ~p"/posts/#{post}"
 end

APIリクエスト upload

uploadするAPIリクエストを実装します
multipartに必要な情報をいれて postしています、リレーション先に設定するpostのIDはURLパラメータとして渡してます

lib/blog_app/posts.ex
  def upload(post_id, path, filename, content_type) do
    options =
      [
        url: "/upload/#{post_id}",
        form_multipart: [file: {File.read!(path), filename: filename, content_type: content_type}]
      ]

    {:ok, resp} = Req.post(Api.client(), options)

    case resp.status do
      200 ->
        {:ok, :created}

      _ ->
        {:error, :fail}
    end
  end

レスポンス修正

スキーマにimagesを追加します

lib/blog_app/posts/post.ex
defmodule BlogApp.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, Ecto.ULID, autogenerate: true}
  @foreign_key_type Ecto.ULID

  schema "posts" do
    field :text, :string
+   field :images, {:array, :string}
    belongs_to :user, BlogApp.Accounts.User

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(post, attrs, user_scope) do
    post
    |> cast(attrs, [:text])
    |> validate_required([:text])
    |> put_change(:user_id, user_scope.user.id)
  end
end

文字列のリストなので、わざわざput assocまでするほどではないためレスポンスの変換としてimagesをputする

lib/blog_app/posts.ex
  def convert_post(params, scope) do
    Post.changeset(%Post{id: params["id"]}, params, scope)
    |> Ecto.Changeset.apply_changes()
+   |> Map.put(:images, params["images"])
  end

画像を表示します

以下のようなレスポンス返ってくるのでサーバー+バケット+object_pathの組み合わせで表示できます

%Req.Response{
  status: 200,
  headers: %{
    "cache-control" => ["max-age=0, private, must-revalidate"],
    "content-type" => ["application/json; charset=utf-8"],
    "date" => ["Fri, 26 Dec 2025 17:50:48 GMT"],
    "vary" => ["accept-encoding"],
    "x-request-id" => ["GITVRZEK7_Pfy3gAAAOF"]
  },
  body: %{
    "data" => [
      %{"id" => "01KDDTKC7EXEVKNF8ZBG00FBY2", "images" => [], "text" => "Test"},
      %{"id" => "01KDDV26FJY7KH12HXZRWTGYD1", "images" => [], "text" => "Test"},
      %{
        "id" => "01KDDWC1Q8KSKDSTZQBRNSP284",
        "images" => ["c6bcb4ab-ee0c-4fa7-a260-4a9e736de2e9/DSC_0035-9.jpg"],
        "text" => "T"
      },
      %{
        "id" => "01KDDWHPKKT3S7FCKPPQPVWS97",
        "images" => ["99fbb9aa-c8f1-4ea0-8e59-f8e39d6ff89b/DSC_0195.jpg"],
        "text" => "A"
      }
    ]
  },
  trailers: %{},
  private: %{}
}

lib/blog_app_web/live/post_live/index.ex
  <.table
    id="posts"
    rows={@streams.posts}
    row_click={fn {_id, post} -> JS.navigate(~p"/posts/#{post}") end}
  >
    <:col :let={{_id, post}} label="Text">
      {post.text}
+     <%= for image <- post.images do %>
+       <img src={"http://localhost:4000/images/uploads/#{image}"} />
+     <% end %>
    </:col>
    ...
  </.table>

こんな感じで表示されます

スクリーンショット 2025-12-27 3.35.49.png

動作確認

29517bc913367598a6801eac1374787d.gif

ファイルがアップロードできることが確認できました

最後

Bucketライブラリを使ってファイルアップロードが楽にできるようになりました
gcsとか使う場合は長らくメンテされていないライブラリか認証部分だけライブラリ使って、残りは自分で書くという事が必要だったので色々楽に実装できるかなと思います

これで 2025のアドカレの25記事が終了になります

本記事は以上になりますありがとうございました

全コードはこちらになります

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?