570 lines
21 KiB
Elixir
570 lines
21 KiB
Elixir
defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|
@moduledoc false
|
|
|
|
use Phoenix.Component
|
|
|
|
import Ecto.Query
|
|
|
|
alias BDS.Desktop.{FilePicker, ShellData}
|
|
alias BDS.{AI, I18n, Media, Repo}
|
|
alias BDS.Media.Media, as: MediaRecord
|
|
alias BDS.Media.Translation
|
|
alias BDS.Posts.Post
|
|
alias BDS.UI.Workbench
|
|
|
|
embed_templates "media_editor_html/*"
|
|
|
|
@post_picker_limit 10
|
|
|
|
def assign_socket(socket) do
|
|
assign(socket, :media_editor, build(socket.assigns))
|
|
end
|
|
|
|
def update(socket, params, reload) do
|
|
case socket.assigns.current_tab do
|
|
%{type: :media, id: media_id} ->
|
|
case Repo.get(MediaRecord, media_id) do
|
|
nil ->
|
|
socket
|
|
|
|
%MediaRecord{} = media ->
|
|
draft = normalize_params(params)
|
|
socket |> reconcile_draft(media, draft) |> reload_with_assigned_workbench(reload)
|
|
end
|
|
|
|
_other ->
|
|
socket
|
|
end
|
|
end
|
|
|
|
def persist_socket(socket, media_id, reload, append_output) do
|
|
case Repo.get(MediaRecord, media_id) do
|
|
nil ->
|
|
socket
|
|
|
|
%MediaRecord{} = media ->
|
|
draft = current_draft(socket.assigns, media)
|
|
|
|
case persist(media, draft) do
|
|
{:ok, updated_media} ->
|
|
workbench = Workbench.clear_dirty(socket.assigns.workbench, :media, media_id)
|
|
|
|
socket
|
|
|> assign(:workbench, workbench)
|
|
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|
|
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved))
|
|
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media)))
|
|
|> reload.(workbench)
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Media"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
end
|
|
end
|
|
|
|
def toggle_quick_actions(socket, media_id, reload) do
|
|
workbench = socket.assigns.workbench
|
|
|
|
socket
|
|
|> assign(:media_editor_quick_actions_open, Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1)))
|
|
|> reload.(workbench)
|
|
end
|
|
|
|
def replace_file(socket, media_id, reload, append_output) do
|
|
case FilePicker.choose_file(translated("Replace Media File")) do
|
|
{:ok, source_path} ->
|
|
case Media.replace_media_file(media_id, source_path) do
|
|
{:ok, %MediaRecord{} = updated_media} ->
|
|
workbench = Workbench.clear_dirty(socket.assigns.workbench, :media, media_id)
|
|
|
|
socket
|
|
|> assign(:workbench, workbench)
|
|
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|
|
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved))
|
|
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media)))
|
|
|> reload.(workbench)
|
|
|
|
{:ok, nil} ->
|
|
socket |> reload.(socket.assigns.workbench)
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Replace File"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
|
|
:cancel ->
|
|
socket
|
|
|
|
{:error, %{message: message}} ->
|
|
socket
|
|
|> append_output.(translated("Replace File"), message, nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
end
|
|
|
|
def detect_language(socket, media_id, reload, append_output) do
|
|
if Map.get(socket.assigns, :offline_mode, true) do
|
|
socket
|
|
|> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
|
|> reload.(socket.assigns.workbench)
|
|
else
|
|
case Repo.get(MediaRecord, media_id) do
|
|
nil ->
|
|
socket
|
|
|
|
%MediaRecord{} = media ->
|
|
draft = current_draft(socket.assigns, media)
|
|
text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "alt", ""), Map.get(draft, "caption", "")], "\n\n")
|
|
|
|
case AI.detect_language(text) do
|
|
{:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" ->
|
|
normalized = normalize_language(language_code)
|
|
|
|
case Media.update_media(media.id, %{language: normalized}) do
|
|
{:ok, updated_media} ->
|
|
updated_draft = Map.put(current_draft(socket.assigns, media), "language", normalized)
|
|
|
|
socket
|
|
|> reconcile_draft(updated_media, updated_draft)
|
|
|> reload_with_assigned_workbench(reload)
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Detect Language"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Detect Language"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
|
|
_other ->
|
|
socket
|
|
|> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def translate(socket, media_id, language, reload, append_output) do
|
|
if Map.get(socket.assigns, :offline_mode, true) do
|
|
socket
|
|
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
|
|> reload.(socket.assigns.workbench)
|
|
else
|
|
normalized_language = normalize_language(language)
|
|
|
|
case AI.translate_media(media_id, normalized_language) do
|
|
{:ok, translation} ->
|
|
case Media.upsert_media_translation(media_id, normalized_language, translation) do
|
|
{:ok, _saved_translation} ->
|
|
socket
|
|
|> assign(:media_editor_quick_actions_open, Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false))
|
|
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
|
|> reload.(socket.assigns.workbench)
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Translate"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Translate"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
end
|
|
end
|
|
|
|
def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do
|
|
try do
|
|
case Repo.get(MediaRecord, media_id) do
|
|
nil ->
|
|
socket
|
|
|
|
%MediaRecord{} = media ->
|
|
attrs =
|
|
Enum.reduce(fields, current_draft(socket.assigns, media), fn field, acc ->
|
|
case field.key do
|
|
"title" -> Map.put(acc, "title", field.suggested_value)
|
|
"alt" -> Map.put(acc, "alt", field.suggested_value)
|
|
"caption" -> Map.put(acc, "caption", field.suggested_value)
|
|
_other -> acc
|
|
end
|
|
end)
|
|
|
|
socket
|
|
|> assign(:shell_overlay, nil)
|
|
|> reconcile_draft(media, attrs)
|
|
|> reload_with_assigned_workbench(reload)
|
|
end
|
|
rescue
|
|
error ->
|
|
socket
|
|
|> append_output.(translated("AI Suggestions"), inspect(error), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
end
|
|
|
|
def delete_socket(socket, media_id, reload, append_output) do
|
|
case Media.delete_media(media_id) do
|
|
{:ok, :deleted} ->
|
|
workbench = Workbench.close_tab(socket.assigns.workbench, :media, media_id)
|
|
|
|
socket
|
|
|> assign(:workbench, workbench)
|
|
|> assign(:shell_overlay, nil)
|
|
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|
|
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|
|
|> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id))
|
|
|> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id))
|
|
|> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id))
|
|
|> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id))
|
|
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
|
|> reload.(workbench)
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Delete Media"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
end
|
|
|
|
def toggle_post_picker(socket, media_id, reload) do
|
|
workbench = socket.assigns.workbench
|
|
|
|
socket
|
|
|> assign(:media_editor_post_pickers_open, Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1)))
|
|
|> reload.(workbench)
|
|
end
|
|
|
|
def set_post_picker_query(socket, media_id, query, reload) do
|
|
workbench = socket.assigns.workbench
|
|
|
|
socket
|
|
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, to_string(query || "")))
|
|
|> reload.(workbench)
|
|
end
|
|
|
|
def link_post(socket, media_id, post_id, reload, append_output) do
|
|
case Media.link_media_to_post(media_id, post_id) do
|
|
{:ok, _linked} ->
|
|
socket
|
|
|> assign(:media_editor_post_pickers_open, Map.put(socket.assigns.media_editor_post_pickers_open, media_id, false))
|
|
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, ""))
|
|
|> reload.(socket.assigns.workbench)
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Link to Post"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
end
|
|
|
|
def unlink_post(socket, media_id, post_id, reload, append_output) do
|
|
case Media.unlink_media_from_post(media_id, post_id) do
|
|
{:ok, _unlinked} ->
|
|
socket |> reload.(socket.assigns.workbench)
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Unlink from Post"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
end
|
|
|
|
def edit_translation(socket, media_id, language, reload) do
|
|
workbench = socket.assigns.workbench
|
|
|
|
translation = Repo.get_by(Translation, translation_for: media_id, language: language)
|
|
|
|
form = %{
|
|
"language" => language,
|
|
"title" => translation && translation.title || "",
|
|
"alt" => translation && translation.alt || "",
|
|
"caption" => translation && translation.caption || ""
|
|
}
|
|
|
|
socket
|
|
|> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form))
|
|
|> reload.(workbench)
|
|
end
|
|
|
|
def update_translation(socket, media_id, params, reload) do
|
|
workbench = socket.assigns.workbench
|
|
|
|
form = %{
|
|
"language" => Map.get(params, "language", ""),
|
|
"title" => Map.get(params, "title", ""),
|
|
"alt" => Map.get(params, "alt", ""),
|
|
"caption" => Map.get(params, "caption", "")
|
|
}
|
|
|
|
socket
|
|
|> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form))
|
|
|> reload.(workbench)
|
|
end
|
|
|
|
def save_translation(socket, media_id, reload, append_output) do
|
|
case Map.get(socket.assigns.media_editor_translation_forms, media_id) do
|
|
%{"language" => language} = form when language not in [nil, ""] ->
|
|
case Media.upsert_media_translation(media_id, language, %{
|
|
title: blank_to_nil(Map.get(form, "title")),
|
|
alt: blank_to_nil(Map.get(form, "alt")),
|
|
caption: blank_to_nil(Map.get(form, "caption"))
|
|
}) do
|
|
{:ok, _translation} ->
|
|
socket
|
|
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
|
|> reload.(socket.assigns.workbench)
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Save Translation"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
|
|
_other ->
|
|
socket
|
|
end
|
|
end
|
|
|
|
def refresh_translation(socket, media_id, language, reload, append_output) do
|
|
if Map.get(socket.assigns, :offline_mode, true) do
|
|
socket
|
|
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
|
|> reload.(socket.assigns.workbench)
|
|
else
|
|
case AI.translate_media(media_id, normalize_language(language)) do
|
|
{:ok, translation} ->
|
|
case Media.upsert_media_translation(media_id, language, translation) do
|
|
{:ok, _saved_translation} -> socket |> reload.(socket.assigns.workbench)
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Refresh Translation"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Refresh Translation"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
end
|
|
end
|
|
|
|
def delete_translation(socket, media_id, language, reload, append_output) do
|
|
case Media.delete_media_translation(media_id, language) do
|
|
{:ok, _deleted?} ->
|
|
socket
|
|
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
|
|> reload.(socket.assigns.workbench)
|
|
|
|
{:error, reason} ->
|
|
socket
|
|
|> append_output.(translated("Delete Translation"), inspect(reason), nil, "error")
|
|
|> reload.(socket.assigns.workbench)
|
|
end
|
|
end
|
|
|
|
def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do
|
|
case Repo.get(MediaRecord, media_id) do
|
|
nil ->
|
|
nil
|
|
|
|
%MediaRecord{} = media ->
|
|
linked_posts = Media.list_linked_posts(media.id)
|
|
translations = Media.list_media_translations(media.id)
|
|
form = current_draft(assigns, media)
|
|
picker_query = Map.get(assigns.media_editor_post_picker_queries, media.id, "")
|
|
{picker_results, picker_overflow_count} = post_picker_results(media, linked_posts, picker_query)
|
|
|
|
%{
|
|
id: media.id,
|
|
display_title: display_title(media),
|
|
original_name: media.original_name || media.filename || media.id,
|
|
mime_type: media.mime_type || "application/octet-stream",
|
|
file_size: format_file_size(media.size),
|
|
dimensions: dimensions_label(media),
|
|
is_image: image?(media),
|
|
preview_url: preview_url(media),
|
|
dirty?: Workbench.dirty?(assigns.workbench, :media, media.id),
|
|
save_state: Map.get(assigns.media_editor_save_states, media.id, :idle),
|
|
quick_actions_open?: Map.get(assigns.media_editor_quick_actions_open, media.id, false),
|
|
post_picker_open?: Map.get(assigns.media_editor_post_pickers_open, media.id, false),
|
|
post_picker_query: picker_query,
|
|
post_picker_results: picker_results,
|
|
post_picker_overflow_count: picker_overflow_count,
|
|
form: form,
|
|
languages: language_codes(),
|
|
translations: Enum.map(translations, &translation_view/1),
|
|
editing_translation: Map.get(assigns.media_editor_translation_forms, media.id),
|
|
linked_posts: linked_posts,
|
|
can_detect_language?: detect_language_enabled?(form),
|
|
can_translate?: form["language"] not in [nil, ""]
|
|
}
|
|
end
|
|
end
|
|
|
|
def build(_assigns), do: nil
|
|
|
|
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
|
|
|
def media_editor_save_state_label(:dirty), do: translated("Unsaved")
|
|
def media_editor_save_state_label(:saved), do: translated("Saved")
|
|
def media_editor_save_state_label(_state), do: translated("Idle")
|
|
|
|
def language_label(code) do
|
|
code
|
|
|> to_string()
|
|
|> String.upcase()
|
|
end
|
|
|
|
def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase()
|
|
|
|
def persist(%MediaRecord{} = media, draft) do
|
|
Media.update_media(media.id, %{
|
|
title: blank_to_nil(Map.get(draft, "title")),
|
|
alt: blank_to_nil(Map.get(draft, "alt")),
|
|
caption: blank_to_nil(Map.get(draft, "caption")),
|
|
author: blank_to_nil(Map.get(draft, "author")),
|
|
language: blank_to_nil(Map.get(draft, "language")),
|
|
tags: csv_to_list(Map.get(draft, "tags"))
|
|
})
|
|
end
|
|
|
|
defp reconcile_draft(socket, %MediaRecord{} = media, draft) do
|
|
persisted = persisted_form(media)
|
|
dirty? = draft != persisted
|
|
workbench = if dirty?, do: Workbench.mark_dirty(socket.assigns.workbench, :media, media.id), else: Workbench.clear_dirty(socket.assigns.workbench, :media, media.id)
|
|
|
|
drafts =
|
|
if dirty? do
|
|
Map.put(socket.assigns.media_editor_drafts, media.id, draft)
|
|
else
|
|
Map.delete(socket.assigns.media_editor_drafts, media.id)
|
|
end
|
|
|
|
socket
|
|
|> assign(:workbench, workbench)
|
|
|> assign(:media_editor_drafts, drafts)
|
|
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media.id, if(dirty?, do: :dirty, else: :idle)))
|
|
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media.id}, %{title: blank_to_nil(Map.get(draft, "title")) || display_title(media), subtitle: media.original_name || media.mime_type || ""}))
|
|
end
|
|
|
|
defp current_draft(assigns, %MediaRecord{} = media) do
|
|
Map.get(assigns.media_editor_drafts, media.id, persisted_form(media))
|
|
end
|
|
|
|
defp persisted_form(%MediaRecord{} = media) do
|
|
%{
|
|
"title" => media.title || "",
|
|
"alt" => media.alt || "",
|
|
"caption" => media.caption || "",
|
|
"tags" => Enum.join(media.tags || [], ", "),
|
|
"author" => media.author || "",
|
|
"language" => media.language || ""
|
|
}
|
|
end
|
|
|
|
defp normalize_params(params) do
|
|
%{
|
|
"title" => Map.get(params, "title", ""),
|
|
"alt" => Map.get(params, "alt", ""),
|
|
"caption" => Map.get(params, "caption", ""),
|
|
"tags" => Map.get(params, "tags", ""),
|
|
"author" => Map.get(params, "author", ""),
|
|
"language" => Map.get(params, "language", "")
|
|
}
|
|
end
|
|
|
|
defp translation_view(%Translation{} = translation) do
|
|
%{
|
|
language: translation.language,
|
|
flag: I18n.flag(translation.language),
|
|
title: translation.title,
|
|
alt: translation.alt,
|
|
caption: translation.caption
|
|
}
|
|
end
|
|
|
|
defp post_picker_results(%MediaRecord{} = media, linked_posts, query) do
|
|
linked_ids = MapSet.new(Enum.map(linked_posts, & &1.post_id))
|
|
normalized_query = normalize_query(query)
|
|
|
|
posts =
|
|
Repo.all(
|
|
from post in Post,
|
|
where: post.project_id == ^media.project_id,
|
|
order_by: [desc: post.updated_at, desc: post.created_at],
|
|
select: %{post_id: post.id, title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id)}
|
|
)
|
|
|> Enum.reject(&MapSet.member?(linked_ids, &1.post_id))
|
|
|> Enum.filter(fn post -> normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query) end)
|
|
|
|
{Enum.take(posts, @post_picker_limit), max(length(posts) - @post_picker_limit, 0)}
|
|
end
|
|
|
|
defp tab_meta(%MediaRecord{} = media) do
|
|
%{title: display_title(media), subtitle: media.original_name || media.mime_type || ""}
|
|
end
|
|
|
|
defp preview_url(%MediaRecord{} = media) do
|
|
if image?(media), do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}", else: nil
|
|
end
|
|
|
|
defp image?(%MediaRecord{} = media), do: String.starts_with?(to_string(media.mime_type || ""), "image/")
|
|
|
|
defp display_title(%MediaRecord{} = media), do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id
|
|
|
|
defp dimensions_label(%MediaRecord{width: width, height: height}) when is_integer(width) and is_integer(height), do: "#{width} x #{height}"
|
|
defp dimensions_label(_media), do: nil
|
|
|
|
defp format_file_size(size) when is_integer(size) and size >= 1_048_576, do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB"
|
|
defp format_file_size(size) when is_integer(size), do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
|
|
defp format_file_size(_size), do: "0.0 KB"
|
|
|
|
defp detect_language_enabled?(form) do
|
|
[Map.get(form, "title"), Map.get(form, "alt"), Map.get(form, "caption")]
|
|
|> Enum.any?(&(blank_to_nil(&1) != nil))
|
|
end
|
|
|
|
defp language_codes do
|
|
I18n.supported_languages()
|
|
|> Enum.map(& &1.code)
|
|
end
|
|
|
|
defp normalize_query(value) do
|
|
value
|
|
|> to_string()
|
|
|> String.trim()
|
|
|> String.downcase()
|
|
end
|
|
|
|
defp csv_to_list(value) do
|
|
value
|
|
|> to_string()
|
|
|> String.split(",")
|
|
|> Enum.map(&String.trim/1)
|
|
|> Enum.reject(&(&1 == ""))
|
|
end
|
|
|
|
defp blank_to_nil(value) do
|
|
value
|
|
|> to_string()
|
|
|> String.trim()
|
|
|> case do
|
|
"" -> nil
|
|
trimmed -> trimmed
|
|
end
|
|
end
|
|
|
|
defp reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench)
|
|
end |