defmodule PhoenixKitWeb.Components.AITranslate do @moduledoc """ Shared AI-translation UI for multilang form LiveViews — the publishing/projects-style trigger button + modal, on top of core's `PhoenixKit.Modules.AI.Translations` pipeline. Render-only function components driven by a single `ai_translate` config map the host LV builds each render; the host owns the state + event handlers (see `PhoenixKit.Modules.AI.Translations` for the backend and any consumer's form LV for the wiring shape). Three surfaces: * `<.ai_translate_button>` — compact "AI Translate" trigger above the multilang tabs; toggles the modal. Spinner while a job is in flight. * `<.ai_translate_modal>` — daisyUI dialog: endpoint + prompt selectors, a "Generate Default Prompt" button when none exists, a scope picker (missing-only / all-overwrite / current tab), in-flight status, and one scope-driven "Translate" action. * `<.ai_translate_progress>` — slim inline progress bar for the session. ## Host contract Pass `ai_translate: %{...}` (string OR atom keys accepted): %{ enabled: true, # gates render event: "translate_lang", # dispatch (phx-value-lang) toggle_event: "toggle_ai", # open/close 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 lacking a translation all_langs: ["es", "de", "fr"], # every non-primary enabled lang in_flight: ["es"], # jobs running now modal_open: false, endpoints: [{uuid, name}, ...], prompts: [{uuid, name}, ...], selected_endpoint_uuid: "...", selected_prompt_uuid: "...", scope: :missing, # :missing | :all | :current default_prompt_exists: true, current_lang: "es", primary_lang: "en", primary_lang_name: "English", # progress bar (optional): translation_status: :in_progress, # nil | :in_progress | :completed translation_progress: 1, translation_total: 3 } ## Action contract The modal's single "Translate" button sends `event` with a `lang` value driven by `scope`: - `:missing` → `phx-value-lang="*"` (bulk, missing only) - `:all` → `phx-value-lang="**"` (bulk, overwrite all non-primary) - `:current` → `phx-value-lang=` (single) Host's `handle_event(event, %{"lang" => lang}, socket)` branches on `"*"`, `"**"`, or a concrete code. ## Placement The modal contains its own `
` selectors — HTML forbids nested forms, so render `<.ai_translate_modal>` **outside** (after) the host's outer ``. The button can live inside the form. Both take the same config map; build it once and pass to both. """ use Phoenix.Component use Gettext, backend: PhoenixKitWeb.Gettext alias PhoenixKitWeb.Components.Core.Icon attr(:ai_translate, :map, default: nil, doc: "Config map — see moduledoc.") @doc "Compact trigger button. Hidden when disabled / no toggle event." def ai_translate_button(assigns) do ~H""" <%= if button_visible?(@ai_translate) do %> <% end %> """ end attr(:ai_translate, :map, default: nil, doc: "Config map — see moduledoc.") @doc "Modal dialog: endpoint/prompt selectors + scope picker + action." def ai_translate_modal(assigns) do ~H""" <%= if modal_renderable?(@ai_translate) do %>
<% end %> """ end attr(:ai_translate, :map, required: true, doc: "Same config map; reads translation_status/progress/total." ) attr(:wrapper_class, :string, default: "flex-1 min-w-0") attr(:class, :string, default: "progress h-2 w-full block") @doc "Slim inline session progress bar. Renders once a dispatch has started." def ai_translate_progress(assigns) do ~H""" <%= if progress_visible?(@ai_translate) do %>
<% end %> """ end attr(:ai_translate, :map, default: nil, doc: "Same config map; reads `:slow` + `:in_flight`.") @doc """ Reassurance line shown when a translation is taking a while (`slow: true` in the config while jobs are still in flight). A leading attention icon flags the line, then the visible message, then a trailing info icon whose tooltip carries the fuller "you can leave, nothing is lost" detail. Renders nothing otherwise. """ def ai_translate_hint(assigns) do ~H""" <%= if hint_visible?(@ai_translate) do %>
{gettext( "This is taking longer than usual. Translations keep running in the background — you can safely leave this page." )}
<% end %> """ end # ─── Visibility / state helpers ──────────────────────────────── defp hint_visible?(cfg) when is_map(cfg), do: enabled?(cfg) and get(cfg, :slow) == true and has_in_flight?(cfg) defp hint_visible?(_), do: false defp button_visible?(cfg) when is_map(cfg), do: enabled?(cfg) and toggle_event_name(cfg) != nil defp button_visible?(_), do: false defp modal_renderable?(cfg) when is_map(cfg), do: enabled?(cfg) and toggle_event_name(cfg) != nil defp modal_renderable?(_), do: false defp modal_open?(cfg), do: get(cfg, :modal_open) == true defp enabled?(cfg), do: get(cfg, :enabled) == true defp actionable_missing(cfg) do in_flight = normalized_in_flight(cfg) Enum.reject(normalized_missing(cfg), &(&1 in in_flight)) end defp has_in_flight?(cfg), do: normalized_in_flight(cfg) != [] defp translation_status(cfg) when is_map(cfg), do: get(cfg, :translation_status) defp translation_status(_), do: nil defp translation_progress(cfg) when is_map(cfg), do: get(cfg, :translation_progress) || 0 defp translation_progress(_), do: 0 defp translation_total(cfg) when is_map(cfg), do: get(cfg, :translation_total) || 0 defp translation_total(_), do: 0 defp progress_visible?(cfg) when is_map(cfg) do enabled?(cfg) and translation_status(cfg) in [:in_progress, :completed] and translation_total(cfg) > 0 end defp progress_visible?(_), do: false defp normalized_missing(cfg), do: cfg |> get(:missing) |> List.wrap() |> Enum.map(&to_lang/1) |> Enum.reject(&is_nil/1) defp normalized_in_flight(cfg), do: cfg |> get(:in_flight) |> List.wrap() |> Enum.map(&to_lang/1) |> Enum.reject(&is_nil/1) defp to_lang(value) when is_binary(value), do: if(String.trim(value) == "", do: nil, else: value) defp to_lang(value) when is_atom(value) and not is_nil(value), do: Atom.to_string(value) defp to_lang(_), do: nil defp ai_endpoints(cfg), do: cfg |> get(:endpoints) |> List.wrap() defp ai_prompts(cfg), do: cfg |> get(:prompts) |> List.wrap() # ─── Scope picker ────────────────────────────────────────────── defp scope_options(cfg) do missing_count = length(actionable_missing(cfg)) all_count = length(all_target_langs(cfg)) current = get(cfg, :current_lang) [ {"missing", gettext("Missing only (%{count} %{plural})", count: missing_count, plural: ngettext_plural(missing_count, "language", "languages") ), missing_count == 0}, {"all", gettext("All non-primary languages (%{count}, overwrites existing)", count: all_count), all_count == 0}, {"current", gettext("Current tab only (%{lang})", lang: if(is_binary(current), do: String.upcase(current), else: "—") ), not current_scope_available?(cfg)} ] end defp source_lang_label(cfg) do name = get(cfg, :primary_lang_name) code = get(cfg, :primary_lang) cond do is_binary(name) and String.trim(name) != "" -> name is_binary(code) and String.trim(code) != "" -> String.upcase(code) true -> gettext("the primary language") end end defp current_scope_available?(cfg) do current = get(cfg, :current_lang) primary = get(cfg, :primary_lang) is_binary(current) and current != "" and current != primary end defp all_target_langs(cfg) do primary = get(cfg, :primary_lang) cfg |> get(:all_langs) |> List.wrap() |> Enum.map(&to_lang/1) |> Enum.reject(&(is_nil(&1) or &1 == primary)) end # The scope to actually act on: the configured one when it's enabled, # otherwise the first enabled option. Without this, the default `:missing` # strands the modal on a disabled radio + a disabled "Translate 0 missing" # button once everything's translated, even though "All non-primary" is # available. Drives the checked radio, the action label/target, and the # enabled state so the modal always offers a usable default. defp current_scope(cfg) do configured = configured_scope(cfg) opts = scope_options(cfg) if scope_disabled?(opts, configured) do first_enabled_scope(opts) || configured else configured end end defp configured_scope(cfg) do case get(cfg, :scope) do s when s in [:missing, "missing"] -> :missing s when s in [:all, "all"] -> :all s when s in [:current, "current"] -> :current _ -> :missing end end defp scope_disabled?(opts, scope) do Enum.any?(opts, fn {value, _label, disabled} -> value == Atom.to_string(scope) and disabled end) end defp first_enabled_scope(opts) do case Enum.find(opts, fn {_value, _label, disabled} -> not disabled end) do {value, _label, _disabled} -> String.to_existing_atom(value) nil -> nil end end defp scope_target(cfg) do case current_scope(cfg) do :missing -> "*" :all -> "**" :current -> get(cfg, :current_lang) || "" end end defp action_label(cfg) do case current_scope(cfg) do :missing -> gettext("Translate %{n} missing", n: length(actionable_missing(cfg))) :all -> gettext("Translate all %{n} languages", n: length(all_target_langs(cfg))) :current -> gettext("Translate to %{lang}", lang: String.upcase(get(cfg, :current_lang) || "")) end end defp action_disabled?(cfg) do blank?(get(cfg, :selected_endpoint_uuid)) or blank?(get(cfg, :selected_prompt_uuid)) or has_in_flight?(cfg) or scope_empty?(cfg) end defp scope_empty?(cfg) do case current_scope(cfg) do :missing -> actionable_missing(cfg) == [] :all -> all_target_langs(cfg) == [] :current -> not current_scope_available?(cfg) end end defp ngettext_plural(1, sing, _plur), do: sing defp ngettext_plural(_, _sing, plur), do: plur defp blank?(nil), do: true defp blank?(""), do: true defp blank?(v) when is_binary(v), do: String.trim(v) == "" defp blank?(_), do: false defp event_name(cfg), do: trimmed_event(cfg, :event) defp toggle_event_name(cfg), do: trimmed_event(cfg, :toggle_event) defp trimmed_event(cfg, key) do case get(cfg, key) do ev when is_binary(ev) -> if String.trim(ev) == "", do: nil, else: ev _ -> nil end end defp get(cfg, key) when is_map(cfg) and is_atom(key) do case Map.fetch(cfg, key) do {:ok, v} -> v :error -> Map.get(cfg, Atom.to_string(key)) end end defp get(_, _), do: nil end