lib/oban_chore_web/live/dashboard_live.ex

defmodule ObanChoreWeb.DashboardLive do
  use Phoenix.LiveView
  import ObanChoreWeb.CoreComponents

  @impl true
  def render(assigns) do
    ~H"""
    <div class="flex h-screen bg-gray-50 overflow-hidden">
      <!-- Sidebar -->
      <div class="w-64 bg-white border-r border-gray-200 flex flex-col">
        <div class="p-6 border-b border-gray-200 flex items-center gap-2">
          <div class="w-8 h-8 bg-brand rounded-lg flex items-center justify-center text-white font-bold">
            O
          </div>
          <h1 class="text-xl font-bold text-gray-900 tracking-tight">ObanChore</h1>
        </div>

        <nav class="flex-1 overflow-y-auto p-4 space-y-1">
          <div class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 px-2">
            Available Chores
          </div>
          <%= for chore <- @chores do %>
            <button
              phx-click="select_chore"
              phx-value-module={to_string(chore.module)}
              class={[
                "w-full text-left px-3 py-2 rounded-md text-sm font-medium transition-colors",
                if(@selected_chore && @selected_chore.module == chore.module,
                  do: "bg-brand/10 text-brand",
                  else: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
                )
              ]}
            >
              <%= chore.name %>
            </button>
          <% end %>
        </nav>
      </div>

      <!-- Main Content -->
      <main class="flex-1 overflow-y-auto p-8">
        <%= if @selected_chore do %>
          <div class="max-w-4xl mx-auto space-y-8">
            <div class="border-b border-gray-200 pb-5">
              <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
                <%= @selected_chore.name %>
              </h2>
              <p class="mt-2 text-sm text-gray-500">
                Configure and execute this chore.
              </p>
            </div>

            <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
              <!-- Form Section -->
              <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-1">
                <div class="px-4 py-6 sm:p-8">
                  <.form for={@form} phx-change="validate" phx-submit="execute" class="space-y-6">
                    <%= for {field, opts} <- @selected_chore.fields do %>
                      <.input
                        field={@form[field]}
                        label={Keyword.get(opts, :label, field)}
                        type={to_string(Keyword.get(opts, :type, "text"))}
                        default={Keyword.get(opts, :default)}
                        options={Keyword.get(opts, :options, [])}
                        prompt={Keyword.get(opts, :prompt)}
                      />
                    <% end %>

                    <div class="flex items-center justify-end gap-x-6 border-t border-gray-900/10 pt-6">
                      <button
                        type="submit"
                        class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
                      >
                        Execute Chore
                      </button>
                    </div>
                  </.form>
                </div>
              </div>

              <!-- Logs Section -->
              <div class="space-y-4">
                <h3 class="text-sm font-semibold text-gray-900">Execution Logs</h3>
                <div class={[
                  "bg-slate-900 rounded-lg p-4 font-mono text-xs overflow-y-auto h-[400px] border border-slate-800 shadow-inner",
                  if(@logs == [], do: "flex items-center justify-center text-slate-500 italic", else: "text-slate-300")
                ]}>
                  <%= if @logs == [] do %>
                    <%= if @active_job_id do %>
                      Waiting for logs...
                    <% else %>
                      No active execution.
                    <% end %>
                  <% else %>
                    <div class="space-y-1">
                      <%= for log <- Enum.reverse(@logs) do %>
                        <div class="flex gap-2">
                          <span class="text-slate-600 select-none">$</span>
                          <span><%= log %></span>
                        </div>
                      <% end %>
                    </div>
                  <% end %>
                </div>
              </div>
            </div>
          </div>
        <% else %>
          <div class="h-full flex flex-col items-center justify-center text-center">
            <div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
              <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-gray-400">
                <path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" />
              </svg>
            </div>
            <h3 class="text-sm font-semibold text-gray-900">No chore selected</h3>
            <p class="mt-1 text-sm text-gray-500">Select a chore from the sidebar to get started.</p>
          </div>
        <% end %>
      </main>
    </div>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    chores = ObanChore.Plugin.get_chores()

    {:ok,
     assign(socket,
       chores: chores,
       selected_chore: nil,
       form: to_form(%{}, as: :args),
       logs: [],
       active_job_id: nil
     )}
  end

  @impl true
  def handle_event("select_chore", %{"module" => module_str}, socket) do
    IO.inspect(module_str, label: "Selected chore module")
    module = String.to_existing_atom(module_str)
    chore = Enum.find(socket.assigns.chores, fn c -> c.module == module end)

    # Initialize form with defaults
    defaults =
      Enum.into(chore.fields, %{}, fn {name, opts} ->
        {to_string(name), Keyword.get(opts, :default)}
      end)

    {:noreply,
     assign(socket,
       selected_chore: chore,
       form: to_form(chore.module.changeset(defaults), as: :args),
       logs: [],
       active_job_id: nil
     )}
  end

  @impl true
  def handle_event("validate", %{"args" => params}, socket) do
    changeset =
      socket.assigns.selected_chore.module.changeset(params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, form: to_form(changeset, as: :args))}
  end

  @impl true
  def handle_event("execute", %{"args" => params}, socket) do
    chore = socket.assigns.selected_chore
    changeset = chore.module.changeset(params)

    if changeset.valid? do
      casted_args = Ecto.Changeset.apply_changes(changeset)

      case Oban.insert(chore.module.new(casted_args)) do
        {:ok, job} ->
          if pubsub = ObanChore.pubsub_server() do
            Phoenix.PubSub.subscribe(pubsub, "oban_chore:logs:#{job.id}")
          end

          {:noreply,
           socket
           |> put_flash(:info, "Successfully enqueued #{chore.name}")
           |> assign(active_job_id: job.id, logs: [])}

        {:error, _reason} ->
          {:noreply, put_flash(socket, :error, "Failed to enqueue #{chore.name}")}
      end
    else
      {:noreply, assign(socket, form: to_form(Map.put(changeset, :action, :insert), as: :args))}
    end
  end

  @impl true
  def handle_info({:oban_chore_log, job_id, message}, socket) do
    if job_id == socket.assigns.active_job_id do
      {:noreply, assign(socket, logs: [message | socket.assigns.logs])}
    else
      {:noreply, socket}
    end
  end
end