5.8 KiB · text History 1d76240
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