defmodule GitGudWeb.AvatarUpload do
@moduledoc """
The avatar section of a settings page, for any owner an avatar can
belong to.
<.live_component
module={GitGudWeb.AvatarUpload}
id="avatar"
owner={@user}
can_edit?={true}
/>
A `LiveComponent` rather than three copies, because users,
organizations and repositories want exactly the same three things —
see what you have, replace it, remove it — and `GitGud.Avatars` is
already polymorphic underneath.
Removing is not destructive in the way it usually is: the identicon
comes back, which is what was there before anyone uploaded anything.
The wording says so, since "Remove" next to a picture usually means
a blank where the picture was.
## Why the client-side limits repeat the server's
`accept` and `max_file_size` here only make the browser refuse
early. `Avatars.put/2` re-checks the size and sniffs the actual magic
bytes, because a file picker's idea of a PNG is a filename extension
and nothing more.
"""
use GitGudWeb, :live_component
alias GitGud.Avatars
@impl true
def mount(socket) do
{:ok,
socket
|> assign(:error, nil)
|> allow_upload(:avatar,
accept: ~w(.png .jpg .jpeg .gif .webp),
max_entries: 1,
max_file_size: Avatars.max_bytes(),
auto_upload: true,
progress: &handle_progress/3
)}
end
@impl true
def update(assigns, socket) do
{:ok, socket |> assign(assigns) |> assign_new(:error, fn -> nil end)}
end
# `auto_upload: true` with a progress callback: the file is consumed
# the moment it finishes arriving, so there is no second "now press
# Save" step for what is a single decision.
defp handle_progress(:avatar, entry, socket) do
if entry.done? do
body =
consume_uploaded_entry(socket, entry, fn %{path: path} -> {:ok, File.read!(path)} end)
case Avatars.put(socket.assigns.owner, body) do
{:ok, _avatar} ->
notify_parent()
{:noreply, assign(socket, :error, nil)}
{:error, reason} ->
{:noreply, assign(socket, :error, message(reason))}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("remove", _params, socket) do
:ok = Avatars.delete(socket.assigns.owner)
notify_parent()
{:noreply, assign(socket, :error, nil)}
end
def handle_event("validate", _params, socket), do: {:noreply, socket}
# The parent owns the page's own assigns — a profile header showing
# the avatar has to re-read it.
defp notify_parent, do: send(self(), :avatar_changed)
defp message(:too_large),
do: "That image is larger than #{div(Avatars.max_bytes(), 1024 * 1024)} MB."
defp message(:empty), do: "That file is empty."
defp message(:unsupported_type),
do: "That doesn't look like a PNG, JPEG, GIF or WebP image."
defp message(%Ecto.Changeset{} = changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {m, _} -> m end)
|> Enum.map_join("; ", fn {k, v} -> "#{k} #{Enum.join(v, ", ")}" end)
end
defp message(other), do: "Couldn't save that image (#{inspect(other)})."
# Client-side rejections, which never reach the server.
defp upload_error_message(:too_large),
do: "That image is larger than #{div(Avatars.max_bytes(), 1024 * 1024)} MB."
defp upload_error_message(:not_accepted), do: "Pick a PNG, JPEG, GIF or WebP image."
defp upload_error_message(:too_many_files), do: "One image at a time."
defp upload_error_message(other), do: "Upload failed (#{inspect(other)})."
@impl true
def render(assigns) do
assigns = assign(assigns, :uploaded?, Avatars.get(assigns.owner) != nil)
~H"""
<section class="space-y-3">
<h2 class="font-semibold">Avatar</h2>
<div class="flex flex-wrap items-center gap-4">
<img
src={Avatars.url(@owner)}
alt=""
width="96"
height="96"
class="size-24 rounded-full bg-base-200 object-cover shrink-0"
/>
<div class="space-y-2">
<p :if={!@uploaded?} class="text-xs opacity-70">
Generated from the name. Upload an image to replace it.
</p>
<p :if={@uploaded?} class="text-xs opacity-70">
PNG, JPEG, GIF or WebP, up to {div(Avatars.max_bytes(), 1024 * 1024)} MB.
</p>
<%= if @can_edit? do %>
<form phx-change="validate" phx-target={@myself} id={"#{@id}-form"}>
<div class="flex items-center gap-2">
<label class="btn btn-sm btn-primary">
<.live_file_input upload={@uploads.avatar} class="hidden" />
{if @uploaded?, do: "Replace", else: "Upload"}
</label>
<button
:if={@uploaded?}
type="button"
phx-click="remove"
phx-target={@myself}
data-confirm="Remove this avatar? The generated one comes back."
class="btn btn-sm btn-ghost"
>
Remove
</button>
</div>
</form>
<% else %>
<p class="text-xs opacity-60">You don't have permission to change this.</p>
<% end %>
<p :for={entry <- @uploads.avatar.entries} class="text-xs opacity-60">
<span :if={!entry.done?}>Uploading… {entry.progress}%</span>
<span :for={err <- upload_errors(@uploads.avatar, entry)} class="text-error">
{upload_error_message(err)}
</span>
</p>
<p :for={err <- upload_errors(@uploads.avatar)} class="text-xs text-error">
{upload_error_message(err)}
</p>
<p :if={@error} class="text-xs text-error">{@error}</p>
</div>
</div>
</section>
"""
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
5.8 KiB · text
History
1d76240