Packages
phoenix_kit
1.7.131
1.7.210
1.7.209
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_web/components/ai_translate.ex
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=<current_lang>` (single)
Host's `handle_event(event, %{"lang" => lang}, socket)` branches on `"*"`,
`"**"`, or a concrete code.
## Placement
The modal contains its own `<form phx-change>` selectors — HTML forbids
nested forms, so render `<.ai_translate_modal>` **outside** (after) the
host's outer `</.form>`. 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 %>
<button
type="button"
class="btn btn-ghost btn-xs gap-2"
phx-click={toggle_event_name(@ai_translate)}
aria-haspopup="dialog"
aria-expanded={if modal_open?(@ai_translate), do: "true", else: "false"}
>
<Icon.icon name="hero-language" class="w-4 h-4 text-primary" />
<span>{gettext("AI Translate")}</span>
<%= if has_in_flight?(@ai_translate) do %>
<span class="loading loading-spinner loading-xs"></span>
<% end %>
</button>
<% 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 %>
<dialog id="ai-translation-modal" class={["modal", modal_open?(@ai_translate) && "modal-open"]}>
<div class="modal-box max-w-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg flex items-center gap-2">
<Icon.icon name="hero-language" class="w-5 h-5 text-primary" />
{gettext("AI Translation")}
</h3>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
phx-click={toggle_event_name(@ai_translate)}
aria-label={gettext("Close")}
>
<Icon.icon name="hero-x-mark" class="w-4 h-4" />
</button>
</div>
<div class="space-y-4">
<p class="text-sm text-base-content/70">
{gettext(
"Source: %{lang}. Each translation runs as a background job — you can keep editing while it finishes.",
lang: source_lang_label(@ai_translate)
)}
</p>
<div class="space-y-1">
<form phx-change={get(@ai_translate, :select_endpoint_event)}>
<label class="select select-sm w-full">
<select name="endpoint_uuid">
<option value="">{gettext("Select an endpoint...")}</option>
<%= for {id, name} <- ai_endpoints(@ai_translate) do %>
<option value={id} selected={get(@ai_translate, :selected_endpoint_uuid) == id}>
{name}
</option>
<% end %>
</select>
</label>
</form>
</div>
<div class="space-y-1">
<form phx-change={get(@ai_translate, :select_prompt_event)}>
<label class="select select-sm w-full">
<select name="prompt_uuid">
<option value="">{gettext("Select a prompt...")}</option>
<%= for {id, name} <- ai_prompts(@ai_translate) do %>
<option value={id} selected={get(@ai_translate, :selected_prompt_uuid) == id}>
{name}
</option>
<% end %>
</select>
</label>
</form>
<%= unless get(@ai_translate, :default_prompt_exists) == true do %>
<button
type="button"
class="btn btn-outline btn-xs gap-1"
phx-click={get(@ai_translate, :generate_prompt_event)}
>
<Icon.icon name="hero-sparkles" class="w-3 h-3" />
{gettext("Generate Default Prompt")}
</button>
<% end %>
</div>
<%= if has_in_flight?(@ai_translate) do %>
<div class="alert alert-info py-2 text-xs gap-2">
<span class="loading loading-spinner loading-xs"></span>
<span>
{gettext("Translating to %{langs}…",
langs:
@ai_translate |> normalized_in_flight() |> Enum.map_join(", ", &String.upcase/1)
)}
</span>
</div>
<% end %>
<fieldset class="space-y-2">
<legend class="text-sm font-medium">{gettext("Translate")}</legend>
<%= for {value, label, disabled} <- scope_options(@ai_translate) do %>
<label class={[
"flex items-start gap-2 cursor-pointer p-2 rounded hover:bg-base-200",
disabled && "opacity-50 cursor-not-allowed pointer-events-none"
]}>
<input
type="radio"
name="scope"
value={value}
class="radio radio-sm radio-primary mt-0.5"
checked={Atom.to_string(current_scope(@ai_translate)) == value}
disabled={disabled}
phx-click={if disabled, do: nil, else: get(@ai_translate, :select_scope_event)}
phx-value-scope={value}
/>
<span class="text-sm leading-tight">{label}</span>
</label>
<% end %>
</fieldset>
<%= if current_scope(@ai_translate) == :all do %>
<div class="alert alert-warning py-2 text-xs gap-2">
<Icon.icon name="hero-exclamation-triangle" class="w-4 h-4 shrink-0" />
<span>
{gettext(
"Existing translations in every non-primary language will be overwritten on completion."
)}
</span>
</div>
<% end %>
<div class="flex flex-wrap gap-3">
<button
type="button"
class={[
"btn btn-primary btn-sm gap-1",
action_disabled?(@ai_translate) && "btn-disabled"
]}
phx-click={event_name(@ai_translate)}
phx-value-lang={scope_target(@ai_translate)}
phx-disable-with={gettext("Starting…")}
disabled={action_disabled?(@ai_translate)}
>
<Icon.icon name="hero-sparkles" class="w-4 h-4" />
{action_label(@ai_translate)}
</button>
</div>
</div>
</div>
<div class="modal-backdrop" phx-click={toggle_event_name(@ai_translate)}></div>
</dialog>
<% 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 %>
<div class={@wrapper_class}>
<progress
class={[
@class,
translation_status(@ai_translate) == :completed && "progress-success",
translation_status(@ai_translate) != :completed && "progress-primary"
]}
value={translation_progress(@ai_translate)}
max={max(translation_total(@ai_translate), 1)}
>
</progress>
</div>
<% 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 %>
<div class="flex items-start gap-1.5 text-xs text-base-content/70 mt-1">
<Icon.icon name="hero-exclamation-triangle" class="w-4 h-4 text-warning shrink-0 mt-px" />
<span>
{gettext(
"This is taking longer than usual. Translations keep running in the background — you can safely leave this page."
)}<span
class="tooltip tooltip-top cursor-help align-middle ml-1 [--tooltip-max-width:20rem]"
data-tip={
gettext(
"Each language is saved automatically the moment it finishes, so it's fine to navigate away or keep editing other fields — nothing will be lost."
)
}
><Icon.icon
name="hero-information-circle"
class="inline-block w-4 h-4 text-base-content/50"
/></span>
</span>
</div>
<% 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