Skip to main content

lib/omni/ui/editor_component.ex

defmodule Omni.UI.EditorComponent do
  @moduledoc """
  A LiveComponent for composing and submitting user messages.

  Provides a textarea for text input, file attachment via click-to-attach and
  drag-and-drop, and a submit button. On submit, builds an `Omni.Message` with
  text and base64-encoded attachments, then sends it to the parent LiveView as
  `{Omni.UI, :new_message, Omni.Message.t()}` via `send/2`.

  ## Upload constraints

    * Accepted types: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.pdf`
    * Max entries: 10
    * Max file size: 20 MB

  ## Slots

    * `:controls` — optional slot rendered in the bottom bar alongside the
      attach button. Used by `editor/1` for model/thinking selectors.
  """

  use Phoenix.LiveComponent
  import Omni.UI.ChatUI, only: [attachment: 1]

  slot :controls

  @impl true
  def render(assigns) do
    ~H"""
    <div
      class={[
        "w-full border-t @lg/chat:border @lg/chat:rounded-xl @lg/chat:shadow-xl",
        "bg-omni-bg border-omni-border-1/75 [&:has(textarea:focus)]:border-omni-accent-1",
        "[&.phx-drop-target-active]:border-omni-accent-1 [&.phx-drop-target-active]:ring-2 [&.phx-drop-target-active]:ring-omni-accent-1/50"
      ]}
      phx-drop-target={@uploads.attachments.ref}
      >
      <form
        id={"#{@id}-form"}
        phx-submit="submit"
        phx-change="change"
        phx-target={@myself}>
        <div
          class={[
            "relative border-b",
            "border-omni-border-1/75 @lg/chat:border-omni-border-2",
            "[&:has(textarea:focus)]:border-omni-accent-1 [&:has(textarea:focus)]:@lg/chat:border-omni-accent-1"
          ]}>
          <div
            class={[
              "absolute inset-0 z-10 bg-omni-bg-2 pointer-events-none items-center justify-center rounded-t-xl",
              "hidden [.phx-drop-target-active_&]:flex"
            ]}>
            <span class="text-sm text-omni-text-3">Drop files here</span>
          </div>

          <textarea
            id={"#{@id}-input"}
            name="input"
            phx-hook="Omni.UI.ChatUI.SubmitOnEnter"
            class={[
              "block w-full max-h-64 p-4 outline-none overflow-y-auto",
              "field-sizing-content resize-none",
              "bg-transparent text-omni-text-3 focus:text-omni-text-1 placeholder-omni-text-4"
            ]}
            placeholder="Type your message here..."
            rows="1">{@input}</textarea>
        </div>

        <div class="bg-omni-bg-1 rounded-b-xl">
          <div
            :if={@uploads.attachments.entries != []}
            class="flex flex-wrap items-center gap-3 px-4 pt-3">
            <.attachment
              :for={entry <- @uploads.attachments.entries}
              name={entry.client_name}
              media_type={entry.client_type}>

              <:image :if={match?("image/" <> _, entry.client_type)}>
                <.live_img_preview entry={entry} />
              </:image>

              <:action>
                <button
                  type="button"
                  phx-click="cancel_upload"
                  phx-value-ref={entry.ref}
                  phx-target={@myself}
                  class={[
                    "absolute -top-1 -right-1 size-5 rounded-full flex items-center justify-center transition-all cursor-pointer",
                    "[@media(hover:hover)]:opacity-0 group-hover:opacity-100",
                    "bg-omni-bg text-omni-text-4 border border-omni-border-2 hover:text-red-500 hover:border-red-500",
                  ]}>
                  <Lucideicons.x class="size-3" />
                </button>
              </:action>

            </.attachment>
          </div>

          <div class="flex items-center gap-4 h-14 p-4">
            <label
              class={[
                "flex items-center gap-1.5 text-sm transition-colors cursor-pointer",
                "text-omni-text-1 hover:text-omni-accent-1"
              ]}>
              <Lucideicons.paperclip class="size-4" />
              <span>Attach</span>
              <.live_file_input upload={@uploads.attachments} class="hidden" />
            </label>

            <div
              class={[
                "flex flex-auto items-center gap-4",
                "[&>*]:before:content=[''] [&>*]:before:w-px [&>*]:before:h-3 [&>*]:before:bg-omni-border-2"
              ]}>
              {render_slot(@controls)}
            </div>

            <button
              type="submit"
              class={[
                "flex items-center justify-center size-8 rounded cursor-pointer",
                "text-omni-text-3 hover:text-omni-accent-1 hover:bg-omni-accent-2/10"
              ]}>
              <Lucideicons.send class="size-5 [:disabled>&]:hidden" />
              <Lucideicons.sparkle class="hidden size-4 text-amber-400 animate-spin [:disabled>&]:block" />
            </button>
          </div>
        </div>
      </form>
    </div>
    """
  end

  @impl true
  def mount(socket) do
    socket =
      socket
      |> assign(input: "")
      |> allow_upload(:attachments,
        accept: ~w(.jpg .jpeg .png .gif .webp .pdf),
        max_entries: 10,
        max_file_size: 20_000_000
      )

    {:ok, socket}
  end

  @impl true
  def handle_event("change", %{"input" => input}, socket) do
    {:noreply, assign(socket, input: input)}
  end

  def handle_event("cancel_upload", %{"ref" => ref}, socket) do
    {:noreply, cancel_upload(socket, :attachments, ref)}
  end

  def handle_event("submit", _, socket) do
    input = String.trim(socket.assigns.input)

    attachments =
      consume_uploaded_entries(socket, :attachments, fn %{path: path}, entry ->
        data = path |> File.read!() |> Base.encode64()

        {:ok,
         %Omni.Content.Attachment{
           source: {:base64, data},
           media_type: entry.client_type
         }}
      end)

    content =
      if(input != "", do: [%Omni.Content.Text{text: input}], else: []) ++ attachments

    if content == [] do
      {:noreply, socket}
    else
      send(self(), {Omni.UI, :new_message, Omni.message(role: :user, content: content)})
      {:noreply, assign(socket, input: "")}
    end
  end
end