PhoenixKitProjects.Web.Components.AITranslateBar (PhoenixKitProjects v0.5.1)

Copy Markdown View Source

AI-translation affordance for project / template / task forms.

Provides two surfaces:

  • <.ai_translate_button> — a single compact button rendered above the multilang tabs. Click toggles the modal below. Shows a "(N missing)" badge so the user knows there's something to do. Spinner badge while any job is in-flight.

  • <.ai_translate_modal> — a daisyUI dialog modal containing endpoint + prompt selectors, a "Generate Default Prompt" button when none is provisioned, in-flight status, and two action buttons:

    • "Translate Missing Only" — enqueues a job per missing lang
    • "Translate to Current Language" — enqueues a single job for the active tab's language (when not on the primary)

This is the publishing-style replacement for the earlier 40-button inline bar, which became unusable on apps with many enabled languages.

Host contract

Pass ai_translate: %{...}:

%{
  enabled: true,                  # boolean — gates render
  event: "translate_lang",        # phx-click event name
  toggle_event: "toggle_ai",      # opens/closes the modal
  select_endpoint_event: "...",   # endpoint dropdown change
  select_prompt_event: "...",     # prompt dropdown change
  select_scope_event: "...",      # scope radio change
  generate_prompt_event: "...",   # generate-default-prompt
  missing: ["es", "de"],          # langs still to translate
  all_langs: ["es", "de", "fr"],  # every non-primary enabled lang
  in_flight: ["es"],              # jobs running now
  modal_open: false,              # is the modal visible?
  endpoints: [{uuid, name}, ...], # AI endpoints list
  prompts: [{uuid, name}, ...],   # AI prompts list
  selected_endpoint_uuid: "...",  # current endpoint choice
  selected_prompt_uuid: "...",    # current prompt choice
  scope: :missing,                # :missing | :all | :current
  default_prompt_exists: true,    # hides the generate-button
  current_lang: "es",             # active multilang tab
  primary_lang: "en",             # source for translations + disables :current on primary
  primary_lang_name: "English"    # friendly label; falls back to upcased code, then a generic string
}

Action contract

The modal renders a single "Translate" button driven by scope:

  • :missing (default) — bulk: phx-value-lang="*". Worker only enqueues langs without translations.
  • :all — bulk with overwrite warning: phx-value-lang="**". Enqueues every non-primary lang (existing translations get overwritten on completion via the host's same-target merge).
  • :current — single lang: phx-value-lang=<current_lang>.

Host's handle_event(@ai_translate.event, %{"lang" => lang}, socket) branches on the value: "*" → missing-only path; "**" → all path; concrete code → single-lang path.

Host embedding contract — render placement matters

The modal contains its own <form phx-change> elements for the endpoint / prompt selectors. HTML forbids nested <form> and the browser silently flattens them, which makes the selectors' change events fire against the outer form's phx-change handler. Render the modal outside your outer form, after </.form>:

<.form for={@form} phx-change="validate" phx-submit="save" ...>
  <.ai_translate_button ai_translate={...} />
  <%!-- rest of the form fields here --%>
</.form>

<%!-- Modal AFTER the form close, not inside it. --%>
<.ai_translate_modal ai_translate={...} />

Both components accept the same ai_translate map, so a host can compute it once per render and pass it to both.

LiveComponent hosts

If the host renders the button from inside a Phoenix.LiveComponent (e.g. a translation tab strip rendered via <.live_component>), the modal's selector events still need to land on the parent LiveView — modal placement at the LV root is the simplest path. Embed the modal at the LV's outer render template, not inside the LiveComponent's HEEx, and have the LiveComponent emit a send(self(), {:ai_translate, ...}) (or call the host event via phx-target={@myself} on the button only) so the modal's PubSub / handle_event consumers stay on the LV process.

If the modal MUST render from inside a LiveComponent (rare — e.g. embedded analytics panel that wants the picker self-contained), pass phx-target={@myself} on every <.form> / event-emitting element inside the modal so the LiveComponent receives the events instead of routing them up. The current <.ai_translate_modal> does not plumb a target attr; in that case render a thin wrapper inside the LiveComponent and replicate the modal markup.

Summary

Functions

Compact trigger button. Shows a small spinner glyph while any translation is in flight (no count, no missing badge — the sibling <.ai_translate_progress> carries the per-session status, and the modal's scope picker shows the missing count if the user needs it).

Modal dialog with endpoint/prompt selectors + action buttons.

Slim inline progress bar — designed to sit on the same row as <.ai_translate_button>. No text, no counter, no language list: the bar's fill level is the only signal. Color flips to progress-success when the session reaches :completed.

Functions

ai_translate_button(assigns)

Compact trigger button. Shows a small spinner glyph while any translation is in flight (no count, no missing badge — the sibling <.ai_translate_progress> carries the per-session status, and the modal's scope picker shows the missing count if the user needs it).

Hidden when ai_translate.enabled != true or the toggle event is blank. Stays visible after all langs are translated so the user can re-translate at any time.

Attributes

  • ai_translate (:map) - Configuration map — see moduledoc for the full shape. Defaults to nil.

ai_translate_modal(assigns)

Modal dialog with endpoint/prompt selectors + action buttons.

Renders nothing when ai_translate.enabled != true — the gate matches ai_translate_button/1 so a host can always render both components and only the relevant one shows.

ai_translate_progress(assigns)

Slim inline progress bar — designed to sit on the same row as <.ai_translate_button>. No text, no counter, no language list: the bar's fill level is the only signal. Color flips to progress-success when the session reaches :completed.

The wrapper is flex-1 min-w-0 so the bar fills the remaining horizontal space in its parent flex container without bleeding past it; the inner <progress> is w-full so its daisyUI default width: 100% resolves against the wrapper, not the row.

Renders nothing until the host has dispatched at least one translation in the session (translation_status flips to :in_progress). The bar persists in the :completed state until the next dispatch resets it.

Attributes

  • ai_translate (:map) (required) - Same ai_translate config map the button + modal accept. Reads :translation_status, :translation_progress, and :translation_total keys for the bar fill state.
  • wrapper_class (:string) - Defaults to "flex-1 min-w-0".
  • class (:string) - Defaults to "progress h-2 w-full block".