Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve image upload component #536

Merged
merged 27 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
469357b
feat: add height and width for component
AfonsoMartins26 Oct 1, 2024
fdb3bf3
feat: trying multiple image_uploader in the same page
AfonsoMartins26 Oct 4, 2024
db121fe
feat: add max_size for file
AfonsoMartins26 Oct 4, 2024
2c85587
code format
AfonsoMartins26 Oct 4, 2024
bbc6074
feat: still trying to make the component use 2 times fo the same page
AfonsoMartins26 Oct 6, 2024
be2063e
code format
AfonsoMartins26 Oct 6, 2024
eb08129
feat: restore files because nothing works
AfonsoMartins26 Oct 6, 2024
865d575
code format
AfonsoMartins26 Oct 6, 2024
16a1792
feat: add max_size for component
AfonsoMartins26 Oct 6, 2024
2492aa6
feat: multiple live_component in the same page
AfonsoMartins26 Oct 9, 2024
709243b
feat: add size_file to component
AfonsoMartins26 Oct 9, 2024
04762ad
feat: add size_file to component
AfonsoMartins26 Oct 9, 2024
1dde479
format code
AfonsoMartins26 Oct 9, 2024
09ab959
fix: image uploader
AfonsoMartins26 Nov 27, 2024
ff1d30b
Merge branch 'develop' into am/improveimageupload
AfonsoMartins26 Dec 4, 2024
96791c3
format code
AfonsoMartins26 Jan 9, 2025
9fda7cb
fix: same name and arity grouped together
AfonsoMartins26 Jan 14, 2025
1612061
feat: resolve requested changes
AfonsoMartins26 Feb 20, 2025
7040609
feat: error in the UI but updating too
AfonsoMartins26 Feb 22, 2025
94346a1
feat: more customizavel component
AfonsoMartins26 Feb 25, 2025
8a3fd5b
Merge branch 'develop' into am/improveimageupload
AfonsoMartins26 Feb 26, 2025
b894b2d
feat: requested changes
AfonsoMartins26 Feb 27, 2025
ddc65e1
format code
AfonsoMartins26 Feb 27, 2025
4b02b15
feat: extensions in component
AfonsoMartins26 Feb 27, 2025
176dd92
feat: requested changes and add type to size of the file
AfonsoMartins26 Mar 3, 2025
21620c5
fix: bad english
AfonsoMartins26 Mar 3, 2025
4d45f3d
fix: change func name
AfonsoMartins26 Mar 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/atomic/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ defmodule Atomic.Accounts do
{:error, %Ecto.Changeset{}}

"""
def update_user(%User{} = user, attrs \\ %{}, _after_save \\ &{:ok, &1}) do
def update_user(%User{} = user, attrs \\ %{}) do
user
|> User.changeset(attrs)
|> Repo.update()
Expand Down
20 changes: 18 additions & 2 deletions lib/atomic/uploader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,32 @@ defmodule Atomic.Uploader do

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

case Enum.member?(extension_whitelist(), file_extension) do
true -> :ok
false -> {:error, "invalid file extension"}
true ->
if size <= max_size() do
:ok
else
{:error, "file size exceeds maximum allowed size"}
end

false ->
{:error, "invalid file extension"}
end
end

def extension_whitelist do
Keyword.get(unquote(opts), :extensions, [])
end

def max_size do
Keyword.get(unquote(opts), :max_file_size, 100_000_000)
end

def file_size(%Waffle.File{} = file) do
File.stat!(file.path) |> Map.get(:size)
end
end
end
end
94 changes: 59 additions & 35 deletions lib/atomic_web/components/image_uploader.ex
Original file line number Diff line number Diff line change
@@ -1,60 +1,54 @@
defmodule AtomicWeb.Components.ImageUploader do
@moduledoc """
An image uploader component that allows you to upload an image.
The component attributes are:
@uploads - the uploads object
@target - the target to send the event to

The component events the parent component should define are:
cancel-image - cancels the upload of an image. This event should be defined in the component that you passed in the @target attribute.
"""

use AtomicWeb, :live_component

def render(assigns) do
~H"""
<div>
<div id={@id}>
<div class="shrink-0 1.5xl:shrink-0">
<.live_file_input upload={@uploads.image} class="hidden" />
<.live_file_input upload={@upload} class="hidden" />
<div class={
"#{if length(@uploads.image.entries) != 0 do
"#{if length(@upload.entries) != 0 do
"hidden"
end} border-2 border-zinc-300 border-dashed rounded-md"
} phx-drop-target={@uploads.image.ref}>
<div class="mx-auto sm:col-span-6 lg:w-full">
<div class="my-[140px] flex justify-center px-6">
<div class="space-y-1 text-center">
<svg class="size-12 mx-auto text-zinc-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
<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" />
</svg>
<div class="flex text-sm text-zinc-600">
<label for="file-upload" class="text-primary-500 relative cursor-pointer rounded-md font-medium hover:text-red-800">
<a onclick={"document.getElementById('#{@uploads.image.ref}').click()"}>
Upload a file
</a>
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-zinc-500">
PNG, JPG, GIF up to 10MB
</p>
end} #{@class} border-2 border-gray-300 border-dashed rounded-md"
} phx-drop-target={@upload.ref}>
<div class="flex h-full items-center justify-center px-6">
<div class="flex flex-col items-center justify-center space-y-1">
<.icon name={@icon} class="size-8 text-zinc-400" />
<div class="flex flex-col items-center text-sm text-zinc-600">
<label for="file-upload" class="relative cursor-pointer rounded-md font-medium text-orange-500 hover:text-red-800">
<a onclick={"document.getElementById('#{@upload.ref}').click()"}>Upload a file</a>
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500">
<%= extensions_to_string(@upload.accept) %> up to <%= assigns.size_file %> <%= @type %>
</p>
</div>
</div>
</div>
<section>
<%= for entry <- @uploads.image.entries do %>
<%= for err <- upload_errors(@uploads.image, entry) do %>
<p class="alert alert-danger"><%= Phoenix.Naming.humanize(err) %></p>
<%= for entry <- @upload.entries do %>
<%= for err <- upload_errors(@upload, entry) do %>
<div class="alert alert-danger relative rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700" role="alert">
<span class="block sm:inline"><%= Phoenix.Naming.humanize(err) %></span>
<span class="absolute top-0 right-0 bottom-0 px-4 py-3">
<title>Close</title>
</span>
</div>
<% end %>
<article class="upload-entry">
<figure class="w-[400px]">
<.live_img_preview entry={entry} />
<figure class="w-[100px]">
<.live_img_preview entry={entry} id={"preview-#{entry.ref}"} class="rounded-lg shadow-lg" />
<div class="flex">
<figcaption>
<%= if String.length(entry.client_name) < 30 do %>
<% entry.client_name %>
<%= entry.client_name %>
<% else %>
<% String.slice(entry.client_name, 0..30) <> "... " %>
<%= String.slice(entry.client_name, 0..30) <> "... " %>
<% end %>
</figcaption>
<button type="button" phx-click="cancel-image" phx-target={@target} phx-value-ref={entry.ref} aria-label="cancel" class="pl-4">
Expand All @@ -69,4 +63,34 @@ defmodule AtomicWeb.Components.ImageUploader do
</div>
"""
end

def update(assigns, socket) do
max_size = assigns.upload.max_file_size
type = assigns[:type]

size_file = convert_size(max_size, type)

{:ok,
socket
|> assign(assigns)
|> assign(:size_file, size_file)}
end

defp convert_size(size_in_bytes, type) do
size_in_bytes_float = size_in_bytes * 1.0

case type do
"kB" -> Float.round(size_in_bytes_float / 1_000, 2)
"MB" -> Float.round(size_in_bytes_float / 1_000_000, 2)
"GB" -> Float.round(size_in_bytes_float / 1_000_000_000, 2)
"TB" -> Float.round(size_in_bytes_float / 1_000_000_000_000, 2)
_ -> size_in_bytes_float
end
end

def extensions_to_string(extensions) do
extensions
|> String.split(",")
|> Enum.map_join(", ", fn ext -> String.trim_leading(ext, ".") |> String.upcase() end)
end
end
95 changes: 69 additions & 26 deletions lib/atomic_web/live/profile_live/form_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@ defmodule AtomicWeb.ProfileLive.FormComponent do
use AtomicWeb, :live_component

alias Atomic.Accounts
alias AtomicWeb.Components.ImageUploader

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

@impl true
def mount(socket) do
{:ok,
socket
|> allow_upload(:picture, accept: @extensions_whitelist, max_entries: 1)}
|> allow_upload(:profile_picture,
accept: @extensions_whitelist,
max_entries: 1,
max_file_size: 10_000_000
)
|> allow_upload(:image_2,
accept: @extensions_whitelist,
max_entries: 1,
max_file_size: 100_000_000
)}
end

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

{:noreply, assign(socket, :changeset, changeset)}
{:noreply,
socket
|> assign(:changeset, changeset)}
end

def handle_event("cancel-image", %{"ref" => ref}, socket) do
uploads = [:profile_picture, :image_2]

socket =
Enum.reduce(uploads, socket, fn key, acc ->
if Enum.any?(Map.get(acc.assigns.uploads, key, %{entries: []}).entries, &(&1.ref == ref)) do
cancel_upload(acc, key, ref)
else
acc
end
end)

{:noreply, socket}
end

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

case Accounts.update_user(
user,
Map.put(user_params, "email", user.email),
&consume_image_data(socket, &1)
) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:success, flash_text)
|> push_navigate(to: ~p"/profile/#{user_params["slug"]}")}

{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
case Accounts.update_user(user, Map.put(user_params, "email", user.email)) do
{:ok, user} ->
case consume_image_data(socket, user) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:success, flash_text)
|> push_navigate(to: ~p"/profile/#{user_params["slug"]}")}

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

defp consume_image_data(socket, user) do
consume_uploaded_entries(socket, :image, fn %{path: path}, entry ->
Accounts.update_user(user, %{
"image" => %Plug.Upload{
content_type: entry.client_type,
filename: entry.client_name,
path: path
}
})
consume_uploaded_entries(socket, :profile_picture, fn %{path: path}, entry ->
handle_image_upload(user, path, entry, :profile_picture)
end)

consume_uploaded_entries(socket, :image_2, fn %{path: path}, entry ->
handle_image_upload(user, path, entry, :image_2)
end)

{:ok, user}
end

defp handle_image_upload(user, path, entry, field) do
Accounts.update_user_picture(user, %{
"#{field}" => %Plug.Upload{
content_type: entry.client_type,
filename: entry.client_name,
path: path
}
})
|> case do
[{:ok, user}] ->
{:ok, user} ->
{:ok, user}

_errors ->
{:ok, user}
{:error, changeset} ->
if changeset.errors[field] do
{:postpone, "File size exceeds maximum allowed size"}
else
{:error, changeset}
end

{:errors, _changeset} ->
{:error, "An error occurred while updating the user."}
end
end
end
27 changes: 3 additions & 24 deletions lib/atomic_web/live/profile_live/form_component.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -47,30 +47,9 @@
<div class="flex flex-col text-sm w-full sm:w-96 text-red-600"><%= error_tag(f, :phone_number) %></div>
</div>
</div>
<.live_file_input upload={@uploads.picture} class="hidden" />
<a onclick={"document.getElementById('#{@uploads.picture.ref}').click()"}>
<div class={
"#{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"}>
<div class="flex absolute justify-center items-center w-full h-full">
<.icon name="hero-camera" class="mx-auto w-12 h-12 sm:w-20 sm:h-20 text-white group-hover:text-opacity-70" />
</div>
</div>
<section>
<%= for entry <- @uploads.picture.entries do %>
<%= for err <- upload_errors(@uploads.picture, entry) do %>
<p class="alert alert-danger"><%= Phoenix.Naming.humanize(err) %></p>
<% end %>
<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">
<div class="flex absolute z-10 justify-center items-center w-full h-full rounded-full">
<.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" />
</div>
<figure class="flex justify-center items-center w-full h-full rounded-full group-hover:opacity-80">
<.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" />
</figure>
</article>
<% end %>
</section>
</a>
<%= label(f, :name, "Profile Picture", class: "mt-3 mb-1 text-sm font-medium text-zinc-700") %>
<.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" />
<.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" />
</div>
<div class="w-full flex flex-row-reverse mt-8">
<%= submit do %>
Expand Down
7 changes: 5 additions & 2 deletions lib/atomic_web/live/profile_live/show.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
</dd>
</div>
<% end %>

<%= if @user.phone_number do %>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-zinc-500">Phone</dt>
Expand All @@ -32,7 +31,11 @@
<% end %>
</div>
</div>
<.avatar class="sm:w-44 sm:h-44 sm:text-6xl" name={@user.name} size={:xl} color={:light_zinc} />
<%= if [email protected]_picture do %>
<.avatar class="sm:w-44 sm:h-44 sm:text-6xl" name={@user.name} size={:xl} color={:light_zinc} />
<% else %>
<.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)} />
<% end %>
</div>
<!-- Divider -->
<div class="py-6 mb-2 border-b border-zinc-200"></div>
Expand Down