はじめに
この記事はElixirアドベントカレンダー2025シリーズ2の24日目の記事です。
前回はファイルアップロードAPIを実装したので、今回は実際にアップロードするアプリ側を作っていきます
ファイルインプットを実装
こちらを参考に実装します
アップロードするボタンをラベルにしてタップしてアップロードするファイルを選択するようにします
ファイルが選択されたら一覧表示します
xをおすとcancel_uploadが実行されアップロード対象から除外します
@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を使って、リクエストに必要な情報を取得します
@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パラメータとして渡してます
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を追加します
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する
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: %{}
}
<.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>
こんな感じで表示されます
動作確認
ファイルがアップロードできることが確認できました
最後
Bucketライブラリを使ってファイルアップロードが楽にできるようになりました
gcsとか使う場合は長らくメンテされていないライブラリか認証部分だけライブラリ使って、残りは自分で書くという事が必要だったので色々楽に実装できるかなと思います
これで 2025のアドカレの25記事が終了になります
本記事は以上になりますありがとうございました
全コードはこちらになります

