lib/openagents_web/live/artifact_catalog_live.ex

436d37facc96 · 6 KB

defmodule OpenAgentsWeb.ArtifactCatalogLive do
  @moduledoc "Authenticated read-only catalog for verified traces and datasets."

  use OpenAgentsWeb, :live_view

  alias OpenAgents.ArtifactCatalog

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Artifact catalog · OpenAgents")
     |> assign(:form, to_form(%{"q" => "", "artifact_type" => ""}, as: :filters))
     |> assign(:listings, [])}
  end

  @impl true
  def handle_params(params, _uri, socket) do
    filters = %{
      "q" => Map.get(params, "q", ""),
      "artifact_type" => Map.get(params, "artifact_type", "")
    }

    {:noreply,
     socket
     |> assign(:form, to_form(filters, as: :filters))
     |> assign(:listings, ArtifactCatalog.list_public_listings(filters))}
  end

  @impl true
  def handle_event("search", %{"filters" => filters}, socket) do
    query =
      filters
      |> Map.take(["q", "artifact_type"])
      |> Enum.reject(fn {_key, value} -> value == "" end)
      |> Map.new()

    {:noreply, push_patch(socket, to: ~p"/artifact-catalog?#{query}")}
  end

  defp stamp(%DateTime{} = at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M UTC")

  defp size_text(bytes) when bytes < 1_000, do: "#{bytes} B"
  defp size_text(bytes) when bytes < 1_000_000, do: "#{Float.round(bytes / 1_000, 1)} kB"
  defp size_text(bytes), do: "#{Float.round(bytes / 1_000_000, 1)} MB"

  defp digest(digest), do: "#{binary_part(digest, 0, 12)}…"

  defp price_text(price) when map_size(price) == 0, do: "Not priced"

  defp price_text(price) do
    amount = Map.get(price, "amount")
    currency = Map.get(price, "currency")
    unit = Map.get(price, "unit")

    [amount, currency, unit]
    |> Enum.reject(&is_nil/1)
    |> Enum.map_join(" ", &to_string/1)
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="Artifact catalog"
      subtitle="Verified traces and datasets"
    >
      <section id="artifact-catalog" class="mx-auto w-full max-w-7xl space-y-6">
        <.header>
          Licensed artifacts
          <:subtitle>
            Discover compatible artifacts without exposing private source metadata.
          </:subtitle>
        </.header>

        <.card id="artifact-catalog-search" class="p-4">
          <.form
            for={@form}
            id="artifact-catalog-filter-form"
            phx-submit="search"
            class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem_auto]"
          >
            <.input
              field={@form[:q]}
              type="search"
              label="Search"
              placeholder="Description, digest, or buyer"
            />
            <.input
              field={@form[:artifact_type]}
              type="select"
              label="Artifact type"
              prompt="All artifacts"
              options={[{"Dataset", "dataset"}, {"Trace", "trace"}]}
            />
            <.button id="artifact-catalog-search-button" type="submit" class="self-end">
              Search catalog
            </.button>
          </.form>
        </.card>

        <.empty
          :if={@listings == []}
          id="artifact-catalog-empty"
          title="No compatible artifacts"
        >
          Change the search terms or artifact type.
        </.empty>

        <div id="artifact-catalog-listings" class="grid gap-4 lg:grid-cols-2">
          <.card
            :for={listing <- @listings}
            id={"artifact-listing-#{listing.id}"}
            class="group flex h-full flex-col gap-5 p-5 transition hover:-translate-y-0.5 hover:shadow-lg"
          >
            <div class="flex flex-wrap items-start justify-between gap-3">
              <div class="space-y-2">
                <div class="flex flex-wrap items-center gap-2">
                  <.badge variant={:info}>{listing.artifact_type}</.badge>
                  <.badge variant={:success}>licensed</.badge>
                </div>
                <h2 class="text-lg font-semibold text-foreground">
                  {listing.owner_description}
                </h2>
                <p class="text-sm text-muted-foreground">
                  Offered by {listing.owner_ref} for {listing.buyer_name}
                </p>
              </div>
              <p class="text-sm font-medium text-foreground">{price_text(listing.price)}</p>
            </div>

            <dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
              <div>
                <dt class="text-muted-foreground">Artifact digest</dt>
                <dd class="font-mono text-foreground" title={listing.artifact_digest}>
                  {digest(listing.artifact_digest)}
                </dd>
              </div>
              <div>
                <dt class="text-muted-foreground">Provenance digest</dt>
                <dd class="font-mono text-foreground" title={listing.provenance_digest}>
                  {digest(listing.provenance_digest)}
                </dd>
              </div>
              <div>
                <dt class="text-muted-foreground">Size</dt>
                <dd class="text-foreground">{size_text(listing.size_bytes)}</dd>
              </div>
              <div>
                <dt class="text-muted-foreground">Buyer class</dt>
                <dd class="text-foreground">{listing.buyer_class}</dd>
              </div>
              <div>
                <dt class="text-muted-foreground">Schema</dt>
                <dd class="text-foreground">
                  {Map.get(listing.schema, "name", "Documented")} {Map.get(
                    listing.schema,
                    "version",
                    ""
                  )}
                </dd>
              </div>
              <div>
                <dt class="text-muted-foreground">Evidence fresh at</dt>
                <dd class="text-foreground">{stamp(listing.evidence_fresh_at)}</dd>
              </div>
            </dl>

            <div class="mt-auto flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
              <p class="text-xs text-muted-foreground">
                Verification: {Map.get(listing.verification_policy, "method", "policy bound")}
              </p>
              <.button
                id={"artifact-listing-export-#{listing.id}"}
                variant={:outline}
                size={:sm}
                href={~p"/api/artifact-listings/#{listing.id}/export"}
                download
              >
                Export listing
              </.button>
            </div>
          </.card>
        </div>
      </section>
    </Layouts.app>
    """
  end
end