Skip to content

Commit 876db45

Browse files
feat: improve image upload component (#536)
1 parent 1d2d754 commit 876db45

File tree

6 files changed

+155
-90
lines changed

6 files changed

+155
-90
lines changed

lib/atomic/accounts.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ defmodule Atomic.Accounts do
491491
{:error, %Ecto.Changeset{}}
492492
493493
"""
494-
def update_user(%User{} = user, attrs \\ %{}, _after_save \\ &{:ok, &1}) do
494+
def update_user(%User{} = user, attrs \\ %{}) do
495495
user
496496
|> User.changeset(attrs)
497497
|> Repo.update()

lib/atomic/uploader.ex

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,32 @@ defmodule Atomic.Uploader do
1111

1212
def validate({file, _}) do
1313
file_extension = file.file_name |> Path.extname() |> String.downcase()
14+
size = file_size(file)
1415

1516
case Enum.member?(extension_whitelist(), file_extension) do
16-
true -> :ok
17-
false -> {:error, "invalid file extension"}
17+
true ->
18+
if size <= max_size() do
19+
:ok
20+
else
21+
{:error, "file size exceeds maximum allowed size"}
22+
end
23+
24+
false ->
25+
{:error, "invalid file extension"}
1826
end
1927
end
2028

2129
def extension_whitelist do
2230
Keyword.get(unquote(opts), :extensions, [])
2331
end
32+
33+
def max_size do
34+
Keyword.get(unquote(opts), :max_file_size, 100_000_000)
35+
end
36+
37+
def file_size(%Waffle.File{} = file) do
38+
File.stat!(file.path) |> Map.get(:size)
39+
end
2440
end
2541
end
2642
end
Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,54 @@
11
defmodule AtomicWeb.Components.ImageUploader do
22
@moduledoc """
33
An image uploader component that allows you to upload an image.
4-
The component attributes are:
5-
@uploads - the uploads object
6-
@target - the target to send the event to
7-
8-
The component events the parent component should define are:
9-
cancel-image - cancels the upload of an image. This event should be defined in the component that you passed in the @target attribute.
104
"""
5+
116
use AtomicWeb, :live_component
127

138
def render(assigns) do
149
~H"""
15-
<div>
10+
<div id={@id}>
1611
<div class="shrink-0 1.5xl:shrink-0">
17-
<.live_file_input upload={@uploads.image} class="hidden" />
12+
<.live_file_input upload={@upload} class="hidden" />
1813
<div class={
19-
"#{if length(@uploads.image.entries) != 0 do
14+
"#{if length(@upload.entries) != 0 do
2015
"hidden"
21-
end} border-2 border-zinc-300 border-dashed rounded-md"
22-
} phx-drop-target={@uploads.image.ref}>
23-
<div class="mx-auto sm:col-span-6 lg:w-full">
24-
<div class="my-[140px] flex justify-center px-6">
25-
<div class="space-y-1 text-center">
26-
<svg class="size-12 mx-auto text-zinc-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
27-
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
28-
</svg>
29-
<div class="flex text-sm text-zinc-600">
30-
<label for="file-upload" class="text-primary-500 relative cursor-pointer rounded-md font-medium hover:text-red-800">
31-
<a onclick={"document.getElementById('#{@uploads.image.ref}').click()"}>
32-
Upload a file
33-
</a>
34-
</label>
35-
<p class="pl-1">or drag and drop</p>
36-
</div>
37-
<p class="text-xs text-zinc-500">
38-
PNG, JPG, GIF up to 10MB
39-
</p>
16+
end} #{@class} border-2 border-gray-300 border-dashed rounded-md"
17+
} phx-drop-target={@upload.ref}>
18+
<div class="flex h-full items-center justify-center px-6">
19+
<div class="flex flex-col items-center justify-center space-y-1">
20+
<.icon name={@icon} class="size-8 text-zinc-400" />
21+
<div class="flex flex-col items-center text-sm text-zinc-600">
22+
<label for="file-upload" class="relative cursor-pointer rounded-md font-medium text-orange-500 hover:text-red-800">
23+
<a onclick={"document.getElementById('#{@upload.ref}').click()"}>Upload a file</a>
24+
</label>
25+
<p class="pl-1">or drag and drop</p>
4026
</div>
27+
<p class="text-xs text-gray-500">
28+
<%= extensions_to_string(@upload.accept) %> up to <%= assigns.size_file %> <%= @type %>
29+
</p>
4130
</div>
4231
</div>
4332
</div>
4433
<section>
45-
<%= for entry <- @uploads.image.entries do %>
46-
<%= for err <- upload_errors(@uploads.image, entry) do %>
47-
<p class="alert alert-danger"><%= Phoenix.Naming.humanize(err) %></p>
34+
<%= for entry <- @upload.entries do %>
35+
<%= for err <- upload_errors(@upload, entry) do %>
36+
<div class="alert alert-danger relative rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700" role="alert">
37+
<span class="block sm:inline"><%= Phoenix.Naming.humanize(err) %></span>
38+
<span class="absolute top-0 right-0 bottom-0 px-4 py-3">
39+
<title>Close</title>
40+
</span>
41+
</div>
4842
<% end %>
4943
<article class="upload-entry">
50-
<figure class="w-[400px]">
51-
<.live_img_preview entry={entry} />
44+
<figure class="w-[100px]">
45+
<.live_img_preview entry={entry} id={"preview-#{entry.ref}"} class="rounded-lg shadow-lg" />
5246
<div class="flex">
5347
<figcaption>
5448
<%= if String.length(entry.client_name) < 30 do %>
55-
<% entry.client_name %>
49+
<%= entry.client_name %>
5650
<% else %>
57-
<% String.slice(entry.client_name, 0..30) <> "... " %>
51+
<%= String.slice(entry.client_name, 0..30) <> "... " %>
5852
<% end %>
5953
</figcaption>
6054
<button type="button" phx-click="cancel-image" phx-target={@target} phx-value-ref={entry.ref} aria-label="cancel" class="pl-4">
@@ -69,4 +63,34 @@ defmodule AtomicWeb.Components.ImageUploader do
6963
</div>
7064
"""
7165
end
66+
67+
def update(assigns, socket) do
68+
max_size = assigns.upload.max_file_size
69+
type = assigns[:type]
70+
71+
size_file = convert_size(max_size, type)
72+
73+
{:ok,
74+
socket
75+
|> assign(assigns)
76+
|> assign(:size_file, size_file)}
77+
end
78+
79+
defp convert_size(size_in_bytes, type) do
80+
size_in_bytes_float = size_in_bytes * 1.0
81+
82+
case type do
83+
"kB" -> Float.round(size_in_bytes_float / 1_000, 2)
84+
"MB" -> Float.round(size_in_bytes_float / 1_000_000, 2)
85+
"GB" -> Float.round(size_in_bytes_float / 1_000_000_000, 2)
86+
"TB" -> Float.round(size_in_bytes_float / 1_000_000_000_000, 2)
87+
_ -> size_in_bytes_float
88+
end
89+
end
90+
91+
def extensions_to_string(extensions) do
92+
extensions
93+
|> String.split(",")
94+
|> Enum.map_join(", ", fn ext -> String.trim_leading(ext, ".") |> String.upcase() end)
95+
end
7296
end

lib/atomic_web/live/profile_live/form_component.ex

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,24 @@ defmodule AtomicWeb.ProfileLive.FormComponent do
22
use AtomicWeb, :live_component
33

44
alias Atomic.Accounts
5+
alias AtomicWeb.Components.ImageUploader
56

67
@extensions_whitelist ~w(.jpg .jpeg .gif .png)
78

89
@impl true
910
def mount(socket) do
1011
{:ok,
1112
socket
12-
|> allow_upload(:picture, accept: @extensions_whitelist, max_entries: 1)}
13+
|> allow_upload(:profile_picture,
14+
accept: @extensions_whitelist,
15+
max_entries: 1,
16+
max_file_size: 10_000_000
17+
)
18+
|> allow_upload(:image_2,
19+
accept: @extensions_whitelist,
20+
max_entries: 1,
21+
max_file_size: 100_000_000
22+
)}
1323
end
1424

1525
@impl true
@@ -29,7 +39,24 @@ defmodule AtomicWeb.ProfileLive.FormComponent do
2939
|> Accounts.change_user(user_params)
3040
|> Map.put(:action, :validate)
3141

32-
{:noreply, assign(socket, :changeset, changeset)}
42+
{:noreply,
43+
socket
44+
|> assign(:changeset, changeset)}
45+
end
46+
47+
def handle_event("cancel-image", %{"ref" => ref}, socket) do
48+
uploads = [:profile_picture, :image_2]
49+
50+
socket =
51+
Enum.reduce(uploads, socket, fn key, acc ->
52+
if Enum.any?(Map.get(acc.assigns.uploads, key, %{entries: []}).entries, &(&1.ref == ref)) do
53+
cancel_upload(acc, key, ref)
54+
else
55+
acc
56+
end
57+
end)
58+
59+
{:noreply, socket}
3360
end
3461

3562
def handle_event("save", %{"user" => user_params}, socket) do
@@ -51,38 +78,54 @@ defmodule AtomicWeb.ProfileLive.FormComponent do
5178
"Profile updated successfully."
5279
end
5380

54-
case Accounts.update_user(
55-
user,
56-
Map.put(user_params, "email", user.email),
57-
&consume_image_data(socket, &1)
58-
) do
59-
{:ok, _user} ->
60-
{:noreply,
61-
socket
62-
|> put_flash(:success, flash_text)
63-
|> push_navigate(to: ~p"/profile/#{user_params["slug"]}")}
64-
65-
{:error, %Ecto.Changeset{} = changeset} ->
66-
{:noreply, assign(socket, :changeset, changeset)}
81+
case Accounts.update_user(user, Map.put(user_params, "email", user.email)) do
82+
{:ok, user} ->
83+
case consume_image_data(socket, user) do
84+
{:ok, _user} ->
85+
{:noreply,
86+
socket
87+
|> put_flash(:success, flash_text)
88+
|> push_navigate(to: ~p"/profile/#{user_params["slug"]}")}
89+
90+
{:error, %Ecto.Changeset{} = changeset} ->
91+
{:noreply, assign(socket, :changeset, changeset)}
92+
end
6793
end
6894
end
6995

7096
defp consume_image_data(socket, user) do
71-
consume_uploaded_entries(socket, :image, fn %{path: path}, entry ->
72-
Accounts.update_user(user, %{
73-
"image" => %Plug.Upload{
74-
content_type: entry.client_type,
75-
filename: entry.client_name,
76-
path: path
77-
}
78-
})
97+
consume_uploaded_entries(socket, :profile_picture, fn %{path: path}, entry ->
98+
handle_image_upload(user, path, entry, :profile_picture)
7999
end)
100+
101+
consume_uploaded_entries(socket, :image_2, fn %{path: path}, entry ->
102+
handle_image_upload(user, path, entry, :image_2)
103+
end)
104+
105+
{:ok, user}
106+
end
107+
108+
defp handle_image_upload(user, path, entry, field) do
109+
Accounts.update_user_picture(user, %{
110+
"#{field}" => %Plug.Upload{
111+
content_type: entry.client_type,
112+
filename: entry.client_name,
113+
path: path
114+
}
115+
})
80116
|> case do
81-
[{:ok, user}] ->
117+
{:ok, user} ->
82118
{:ok, user}
83119

84-
_errors ->
85-
{:ok, user}
120+
{:error, changeset} ->
121+
if changeset.errors[field] do
122+
{:postpone, "File size exceeds maximum allowed size"}
123+
else
124+
{:error, changeset}
125+
end
126+
127+
{:errors, _changeset} ->
128+
{:error, "An error occurred while updating the user."}
86129
end
87130
end
88131
end

lib/atomic_web/live/profile_live/form_component.html.heex

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,30 +47,9 @@
4747
<div class="flex flex-col text-sm w-full sm:w-96 text-red-600"><%= error_tag(f, :phone_number) %></div>
4848
</div>
4949
</div>
50-
<.live_file_input upload={@uploads.picture} class="hidden" />
51-
<a onclick={"document.getElementById('#{@uploads.picture.ref}').click()"}>
52-
<div class={
53-
"#{if length(@uploads.picture.entries) != 0 do "hidden" end} relative w-40 h-40 ring-2 ring-zinc-300 rounded-full cursor-pointer bg-zinc-400 sm:w-48 group sm:h-48 hover:bg-tertiary"}>
54-
<div class="flex absolute justify-center items-center w-full h-full">
55-
<.icon name="hero-camera" class="mx-auto w-12 h-12 sm:w-20 sm:h-20 text-white group-hover:text-opacity-70" />
56-
</div>
57-
</div>
58-
<section>
59-
<%= for entry <- @uploads.picture.entries do %>
60-
<%= for err <- upload_errors(@uploads.picture, entry) do %>
61-
<p class="alert alert-danger"><%= Phoenix.Naming.humanize(err) %></p>
62-
<% end %>
63-
<article class="flex relative items-center w-40 h-40 sm:w-48 sm:h-48 bg-white rounded-full cursor-pointer upload-entry group">
64-
<div class="flex absolute z-10 justify-center items-center w-full h-full rounded-full">
65-
<.icon name="hero-camera" class="mx-auto w-12 h-12 sm:w-20 sm:h-20 text-white text-opacity-0 rounded-full group-hover:text-opacity-100" />
66-
</div>
67-
<figure class="flex justify-center items-center w-full h-full rounded-full group-hover:opacity-80">
68-
<.live_img_preview entry={entry} class="object-cover object-center rounded-full w-40 h-40 sm:w-48 sm:h-48 border-4 border-white" />
69-
</figure>
70-
</article>
71-
<% end %>
72-
</section>
73-
</a>
50+
<%= label(f, :name, "Profile Picture", class: "mt-3 mb-1 text-sm font-medium text-zinc-700") %>
51+
<.live_component module={ImageUploader} icon="hero-camera" id="uploader-profile-picture_1" upload={@uploads.profile_picture} target={@myself} class="h-100px w-100px" type="MB" />
52+
<.live_component module={ImageUploader} icon="hero-photo" id="uploader-profile-picture_2" upload={@uploads.image_2} target={@myself} class="h-100px w-100px" type="GB" />
7453
</div>
7554
<div class="w-full flex flex-row-reverse mt-8">
7655
<%= submit do %>

lib/atomic_web/live/profile_live/show.html.heex

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
</dd>
2020
</div>
2121
<% end %>
22-
2322
<%= if @user.phone_number do %>
2423
<div class="sm:col-span-1">
2524
<dt class="text-sm font-medium text-zinc-500">Phone</dt>
@@ -32,7 +31,11 @@
3231
<% end %>
3332
</div>
3433
</div>
35-
<.avatar class="sm:w-44 sm:h-44 sm:text-6xl" name={@user.name} size={:xl} color={:light_zinc} />
34+
<%= if !@user.profile_picture do %>
35+
<.avatar class="sm:w-44 sm:h-44 sm:text-6xl" name={@user.name} size={:xl} color={:light_zinc} />
36+
<% else %>
37+
<.avatar name={@user.name} color={:light} class="h-36 w-36 text-4xl rounded-full border-4 border-white" type={:user} src={Uploaders.ProfilePicture.url({@user.profile_picture, @user}, :original)} />
38+
<% end %>
3639
</div>
3740
<!-- Divider -->
3841
<div class="py-6 mb-2 border-b border-zinc-200"></div>

0 commit comments

Comments
 (0)