From 5bc2b4a3387f797a226665dd6ad60787944ed968 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 3 May 2026 13:09:27 +0200 Subject: [PATCH] chore: convert media to live component --- lib/bds/desktop/shell_live.ex | 203 ++-- lib/bds/desktop/shell_live/cli_sync.ex | 21 - lib/bds/desktop/shell_live/index.html.heex | 4 +- lib/bds/desktop/shell_live/media_editor.ex | 878 +++++++++--------- .../media_editor_html/media_editor.html.heex | 34 +- test/bds/desktop/shell_live_test.exs | 21 +- test/bds/ui/shell_test.exs | 4 +- 7 files changed, 528 insertions(+), 637 deletions(-) diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 33c3d21..3d9efa2 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -161,13 +161,7 @@ defmodule BDS.Desktop.ShellLive do |> assign(:tab_meta, %{}) |> assign(:project_menu_open, false) |> assign(:sidebar_filters_by_view, %{}) - |> assign(:sidebar_filter_panels, %{}) - |> assign(:media_editor_drafts, %{}) - |> assign(:media_editor_quick_actions_open, %{}) - |> assign(:media_editor_post_pickers_open, %{}) - |> assign(:media_editor_post_picker_queries, %{}) - |> assign(:media_editor_save_states, %{}) - |> assign(:media_editor_translation_forms, %{}) + |> assign(:sidebar_filter_panels, %{}) |> assign(:chat_editor_inputs, %{}) |> assign(:chat_model_selectors_open, %{}) |> assign(:chat_editor_requests, %{}) @@ -331,116 +325,6 @@ defmodule BDS.Desktop.ShellLive do {:noreply, reload_shell(socket, workbench)} end - def handle_event("change_media_editor", %{"media_editor" => params}, socket) do - {:noreply, MediaEditor.update(socket, params, &reload_shell/2)} - end - - def handle_event("save_media_editor", %{"id" => media_id}, socket) do - {:noreply, - MediaEditor.persist_socket(socket, media_id, &reload_shell/2, &append_output_entry/5)} - end - - def handle_event("toggle_media_editor_quick_actions", %{"id" => media_id}, socket) do - {:noreply, MediaEditor.toggle_quick_actions(socket, media_id, &reload_shell/2)} - end - - def handle_event("replace_media_editor_file", %{"id" => media_id}, socket) do - {:noreply, - MediaEditor.replace_file(socket, media_id, &reload_shell/2, &append_output_entry/5)} - end - - def handle_event("detect_media_editor_language", %{"id" => media_id}, socket) do - {:noreply, - MediaEditor.detect_language(socket, media_id, &reload_shell/2, &append_output_entry/5)} - end - - def handle_event("toggle_media_post_picker", %{"id" => media_id}, socket) do - {:noreply, MediaEditor.toggle_post_picker(socket, media_id, &reload_shell/2)} - end - - def handle_event( - "change_media_post_picker", - %{"id" => media_id, "media_post_picker" => %{"query" => query}}, - socket - ) do - {:noreply, MediaEditor.set_post_picker_query(socket, media_id, query, &reload_shell/2)} - end - - def handle_event("link_media_to_post", %{"id" => media_id, "post-id" => post_id}, socket) do - {:noreply, - MediaEditor.link_post(socket, media_id, post_id, &reload_shell/2, &append_output_entry/5)} - end - - def handle_event("unlink_media_from_post", %{"id" => media_id, "post-id" => post_id}, socket) do - {:noreply, - MediaEditor.unlink_post(socket, media_id, post_id, &reload_shell/2, &append_output_entry/5)} - end - - def handle_event("edit_media_translation", %{"id" => media_id, "language" => language}, socket) do - {:noreply, MediaEditor.edit_translation(socket, media_id, language, &reload_shell/2)} - end - - def handle_event("change_media_translation", %{"media_translation" => params}, socket) do - case socket.assigns.current_tab do - %{type: :media, id: media_id} -> - {:noreply, MediaEditor.update_translation(socket, media_id, params, &reload_shell/2)} - - _other -> - {:noreply, socket} - end - end - - def handle_event("save_media_translation", %{"id" => media_id}, socket) do - {:noreply, - MediaEditor.save_translation(socket, media_id, &reload_shell/2, &append_output_entry/5)} - end - - def handle_event( - "refresh_media_translation", - %{"id" => media_id, "language" => language}, - socket - ) do - {:noreply, - MediaEditor.refresh_translation( - socket, - media_id, - language, - &reload_shell/2, - &append_output_entry/5 - )} - end - - def handle_event( - "delete_media_translation", - %{"id" => media_id, "language" => language}, - socket - ) do - {:noreply, - MediaEditor.delete_translation( - socket, - media_id, - language, - &reload_shell/2, - &append_output_entry/5 - )} - end - - def handle_event("close_media_translation_editor", _params, socket) do - case socket.assigns.current_tab do - %{type: :media, id: media_id} -> - {:noreply, - socket - |> assign( - :media_editor_translation_forms, - Map.delete(socket.assigns.media_editor_translation_forms, media_id) - ) - |> reload_shell(socket.assigns.workbench)} - - _other -> - {:noreply, socket} - end - end - def handle_event("settings_shell_command", %{"action" => action}, socket) do {:noreply, apply_shell_command(socket, action)} end @@ -693,11 +577,8 @@ defmodule BDS.Desktop.ShellLive do %{type: :media, id: media_id} when kind in ["ai_suggestions", "language_picker", "confirm_delete"] -> - assign( - socket, - :media_editor_quick_actions_open, - Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false) - ) + send_update(__MODULE__.MediaEditor, id: "media-editor-#{media_id}", action: :close_quick_actions) + socket _other -> socket @@ -868,7 +749,8 @@ defmodule BDS.Desktop.ShellLive do socket {%{kind: :language_picker}, %{type: :media, id: media_id}} -> - MediaEditor.translate(socket, media_id, code, &reload_shell/2, &append_output_entry/5) + send_update(MediaEditor, id: "media-editor-#{media_id}", action: :translate, language: code) + socket _other -> socket @@ -890,16 +772,29 @@ defmodule BDS.Desktop.ShellLive do socket {%{kind: :ai_suggestions} = overlay, %{type: :media, id: media_id}} -> - MediaEditor.apply_ai_suggestions( - socket, - media_id, - Overlay.selected_ai_fields(overlay), - &reload_shell/2, - &append_output_entry/5 + send_update(MediaEditor, id: "media-editor-#{media_id}", + action: :apply_ai_suggestions, + fields: Overlay.selected_ai_fields(overlay) ) + socket + {%{kind: :confirm_delete}, %{type: :media, id: media_id}} -> - MediaEditor.delete_socket(socket, media_id, &reload_shell/2, &append_output_entry/5) + case Media.delete_media(media_id) do + {:ok, :deleted} -> + workbench = Workbench.close_tab(socket.assigns.workbench, :media, media_id) + + socket + |> assign(:shell_overlay, nil) + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id})) + |> reload_shell(workbench) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> append_output_entry(translated("Delete Media"), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench) + end {%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} -> close_overlay_with_output(socket, title, entity_name) @@ -1304,6 +1199,28 @@ defmodule BDS.Desktop.ShellLive do {:noreply, socket} end + def handle_info({:media_editor_output, title, message, level}, socket) do + {:noreply, append_output_entry(socket, title, message, nil, level)} + end + + def handle_info({:media_editor_dirty, media_id, dirty?}, socket) do + workbench = + if dirty? do + Workbench.mark_dirty(socket.assigns.workbench, :media, media_id) + else + Workbench.clear_dirty(socket.assigns.workbench, :media, media_id) + end + + {:noreply, assign(socket, :workbench, workbench)} + end + + def handle_info({:media_editor_tab_meta, media_id, title, subtitle}, socket) do + tab_meta = + Map.put(socket.assigns.tab_meta, {:media, media_id}, %{title: title, subtitle: subtitle}) + + {:noreply, assign(socket, :tab_meta, tab_meta)} + end + def handle_info(:reload_shell, socket) do {:noreply, reload_shell(socket, socket.assigns.workbench)} end @@ -1380,7 +1297,6 @@ defmodule BDS.Desktop.ShellLive do |> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups()) |> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index]) |> assign(:current_tab, current_tab(workbench)) - |> assign_media_editor() |> assign_chat_editor() |> assign_import_editor() |> assign_misc_editor() @@ -1425,10 +1341,6 @@ defmodule BDS.Desktop.ShellLive do Enum.find(tabs, &(&1.type == type and &1.id == id)) end - defp assign_media_editor(socket) do - MediaEditor.assign_socket(socket) - end - defp assign_chat_editor(socket) do ChatEditor.assign_socket(socket) end @@ -1594,7 +1506,8 @@ defmodule BDS.Desktop.ShellLive do end defp save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do - MediaEditor.persist_socket(socket, media_id, &reload_shell/2, &append_output_entry/5) + send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save) + socket end defp save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do @@ -1740,9 +1653,21 @@ defmodule BDS.Desktop.ShellLive do end "media" -> - socket - |> assign(:shell_overlay, nil) - |> MediaEditor.delete_socket(id, &reload_shell/2, &append_output_entry/5) + case Media.delete_media(id) do + {:ok, :deleted} -> + workbench = Workbench.close_tab(socket.assigns.workbench, :media, id) + + socket + |> assign(:shell_overlay, nil) + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, id})) + |> reload_shell(workbench) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> append_output_entry(translated("Delete Media"), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench) + end "scripts" -> delete_sidebar_script(socket, id) diff --git a/lib/bds/desktop/shell_live/cli_sync.ex b/lib/bds/desktop/shell_live/cli_sync.ex index 26a3046..63dbbe8 100644 --- a/lib/bds/desktop/shell_live/cli_sync.ex +++ b/lib/bds/desktop/shell_live/cli_sync.ex @@ -58,27 +58,6 @@ defmodule BDS.Desktop.ShellLive.CliSync do |> 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) - ) {socket, workbench} end diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index edd7083..db488b6 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -385,8 +385,8 @@ <% @current_tab.type == :post -> %> <.live_component module={PostEditor} id={"post-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} /> - <% @current_tab.type == :media and @media_editor -> %> - + <% @current_tab.type == :media -> %> + <.live_component module={MediaEditor} id={"media-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} /> <% @current_tab.type in [:settings, :style] and @current_project -> %> <.live_component module={SettingsEditor} id="settings-editor" diff --git a/lib/bds/desktop/shell_live/media_editor.ex b/lib/bds/desktop/shell_live/media_editor.ex index 6290908..0dfdc44 100644 --- a/lib/bds/desktop/shell_live/media_editor.ex +++ b/lib/bds/desktop/shell_live/media_editor.ex @@ -1,7 +1,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do @moduledoc false - use Phoenix.Component + use Phoenix.LiveComponent import Ecto.Query @@ -11,373 +11,256 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do alias BDS.Media.Translation alias BDS.Posts.Post alias BDS.Repo - alias BDS.UI.Workbench embed_templates("media_editor_html/*") @post_picker_limit 10 - @spec assign_socket(term()) :: term() - def assign_socket(socket) do - assign(socket, :media_editor, build(socket.assigns)) + @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()} + @impl true + def update(%{action: :save} = assigns, socket) do + socket = + socket + |> assign(Map.drop(assigns, [:action])) + |> do_save() + + {:ok, socket} end - @spec update(term(), term(), term()) :: term() - def update(socket, params, reload) do - case socket.assigns.current_tab do - %{type: :media, id: media_id} -> - case Media.get_media(media_id) do - nil -> - socket + def update(%{action: :close_quick_actions} = assigns, socket) do + socket = + socket + |> assign(Map.drop(assigns, [:action])) + |> assign(:quick_actions_open?, false) + |> build_data() - %MediaRecord{} = media -> - draft = normalize_params(params) - socket |> reconcile_draft(media, draft) |> reload_with_assigned_workbench(reload) - end + {:ok, socket} + end - _other -> - socket + def update(%{action: :apply_ai_suggestions, fields: fields} = assigns, socket) do + socket = + socket + |> assign(Map.drop(assigns, [:action, :fields])) + |> do_apply_ai_suggestions(fields) + + {:ok, socket} + end + + def update(%{action: :translate, language: language} = assigns, socket) do + socket = + socket + |> assign(Map.drop(assigns, [:action, :language])) + |> do_translate(language) + + {:ok, socket} + end + + def update(assigns, socket) do + socket = + socket + |> assign(assigns) + |> ensure_state() + |> build_data() + + {:ok, socket} + end + + @spec render(map()) :: Phoenix.LiveView.Rendered.t() + @impl true + def render(%{media_editor: nil} = assigns), do: ~H"
" + + def render(assigns) do + media_editor(assigns) + end + + @spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) :: + {:noreply, Phoenix.LiveView.Socket.t()} + @impl true + def handle_event("change_media_editor", %{"media_editor" => params}, socket) do + media = socket.assigns.media + draft = normalize_params(params) + dirty? = draft != persisted_form(media) + was_dirty? = socket.assigns.dirty? + + socket = + socket + |> assign(:draft, draft) + |> assign(:dirty?, dirty?) + |> build_data() + + if dirty? != was_dirty? do + notify_parent({:media_editor_dirty, socket.assigns.media_id, dirty?}) end + + {:noreply, socket} end - @spec persist_socket(term(), term(), term(), term()) :: term() - def persist_socket(socket, media_id, reload, append_output) do - case Media.get_media(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 + def handle_event("save_media_editor", _params, socket) do + {:noreply, do_save(socket)} end - @spec toggle_quick_actions(term(), term(), term()) :: term() - def toggle_quick_actions(socket, media_id, reload) do - workbench = socket.assigns.workbench + def handle_event("toggle_media_editor_quick_actions", _params, socket) do + socket = + socket + |> assign(:quick_actions_open?, not socket.assigns.quick_actions_open?) + |> build_data() - socket - |> assign( - :media_editor_quick_actions_open, - Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1)) - ) - |> reload.(workbench) + {:noreply, socket} end - @spec replace_file(term(), term(), term(), term()) :: term() - def replace_file(socket, media_id, reload, append_output) do + def handle_event("replace_media_editor_file", _params, socket) do + media = socket.assigns.media + case FilePicker.choose_file(translated("Replace Media File")) do {:ok, source_path} -> - case Media.replace_media_file(media_id, source_path) do + 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 = + socket + |> assign(:media, updated_media) + |> assign(:draft, persisted_form(updated_media)) + |> assign(:dirty?, false) + |> build_data() - 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) + notify_parent({:media_editor_dirty, media.id, false}) + notify_parent({:media_editor_tab_meta, media.id, display_title(updated_media), updated_media.original_name || updated_media.mime_type || ""}) + {:noreply, socket} {:ok, nil} -> - socket |> reload.(socket.assigns.workbench) + {:noreply, build_data(socket)} {:error, reason} -> - socket - |> append_output.(translated("Replace File"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) + notify_output(socket, translated("Replace File"), inspect(reason), "error") + {:noreply, build_data(socket)} end :cancel -> - socket + {:noreply, socket} {:error, %{message: message}} -> - socket - |> append_output.(translated("Replace File"), message, nil, "error") - |> reload.(socket.assigns.workbench) + notify_output(socket, translated("Replace File"), message, "error") + {:noreply, build_data(socket)} end end - @spec detect_language(term(), term(), term(), term()) :: term() - def detect_language(socket, media_id, reload, append_output) do - if Map.get(socket.assigns, :offline_mode, true) do - socket - |> append_output.( + def handle_event("detect_media_editor_language", _params, socket) do + if socket.assigns.offline_mode do + notify_output( + socket, translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), - nil, "info" ) - |> reload.(socket.assigns.workbench) + + {:noreply, build_data(socket)} else - case Media.get_media(media_id) do - nil -> - socket + media = socket.assigns.media + draft = socket.assigns.draft - %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" + ) - 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 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(draft, "language", normalized) - case Media.update_media(media.id, %{language: normalized}) do - {:ok, updated_media} -> - updated_draft = - Map.put(current_draft(socket.assigns, media), "language", normalized) + socket = + socket + |> assign(:media, updated_media) + |> assign(:draft, updated_draft) + |> assign(:dirty?, updated_draft != persisted_form(updated_media)) + |> build_data() - 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 + {:noreply, socket} {: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 - - @spec translate(term(), term(), term(), term(), term()) :: term() - 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) + notify_output(socket, translated("Detect Language"), inspect(reason), "error") + {:noreply, build_data(socket)} end {:error, reason} -> - socket - |> append_output.(translated("Translate"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) + notify_output(socket, translated("Detect Language"), inspect(reason), "error") + {:noreply, build_data(socket)} + + _other -> + notify_output( + socket, + translated("Detect Language"), + translated("Language detection failed."), + "error" + ) + + {:noreply, build_data(socket)} end end end - @spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term() - def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do - try do - case Media.get_media(media_id) do - nil -> - socket + def handle_event("toggle_media_post_picker", _params, socket) do + socket = + socket + |> assign(:post_picker_open?, not socket.assigns.post_picker_open?) + |> assign(:post_picker_query, "") + |> build_data() - %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 + {:noreply, socket} end - @spec delete_socket(term(), term(), term(), term()) :: term() - 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) + def handle_event("change_media_post_picker", %{"media_post_picker" => %{"query" => query}}, socket) do + socket = + socket + |> assign(:post_picker_query, to_string(query || "")) + |> build_data() - 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 + {:noreply, socket} end - @spec toggle_post_picker(term(), term(), term()) :: term() - def toggle_post_picker(socket, media_id, reload) do - workbench = socket.assigns.workbench + def handle_event("link_media_to_post", %{"post-id" => post_id}, socket) do + media = socket.assigns.media - socket - |> assign( - :media_editor_post_pickers_open, - Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1)) - ) - |> reload.(workbench) - end - - @spec set_post_picker_query(term(), term(), term(), term()) :: term() - 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 - - @spec link_post(term(), term(), term(), term(), term()) :: term() - def link_post(socket, media_id, post_id, reload, append_output) do - case Media.link_media_to_post(media_id, post_id) 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) + socket = + socket + |> assign(:post_picker_open?, false) + |> assign(:post_picker_query, "") + |> build_data() + + {:noreply, socket} {:error, reason} -> - socket - |> append_output.(translated("Link to Post"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) + notify_output(socket, translated("Link to Post"), inspect(reason), "error") + {:noreply, build_data(socket)} end end - @spec unlink_post(term(), term(), term(), term(), term()) :: term() - def unlink_post(socket, media_id, post_id, reload, append_output) do - case Media.unlink_media_from_post(media_id, post_id) do + def handle_event("unlink_media_from_post", %{"post-id" => post_id}, socket) do + media = socket.assigns.media + + case Media.unlink_media_from_post(media.id, post_id) do {:ok, _unlinked} -> - socket |> reload.(socket.assigns.workbench) + {:noreply, build_data(socket)} {:error, reason} -> - socket - |> append_output.(translated("Unlink from Post"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) + notify_output(socket, translated("Unlink from Post"), inspect(reason), "error") + {:noreply, build_data(socket)} end end - @spec edit_translation(term(), term(), term(), term()) :: term() - def edit_translation(socket, media_id, language, reload) do - workbench = socket.assigns.workbench - - translation = Repo.get_by(Translation, translation_for: media_id, language: language) + def handle_event("edit_media_translation", %{"language" => language}, socket) do + media = socket.assigns.media + translation = Repo.get_by(Translation, translation_for: media.id, language: language) form = %{ "language" => language, @@ -386,18 +269,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do "caption" => (translation && translation.caption) || "" } - socket - |> assign( - :media_editor_translation_forms, - Map.put(socket.assigns.media_editor_translation_forms, media_id, form) - ) - |> reload.(workbench) + socket = + socket + |> assign(:editing_translation, form) + |> build_data() + + {:noreply, socket} end - @spec update_translation(term(), term(), term(), term()) :: term() - def update_translation(socket, media_id, params, reload) do - workbench = socket.assigns.workbench - + def handle_event("change_media_translation", %{"media_translation" => params}, socket) do form = %{ "language" => Map.get(params, "language", ""), "title" => Map.get(params, "title", ""), @@ -405,108 +285,140 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do "caption" => Map.get(params, "caption", "") } - socket - |> assign( - :media_editor_translation_forms, - Map.put(socket.assigns.media_editor_translation_forms, media_id, form) - ) - |> reload.(workbench) + socket = + socket + |> assign(:editing_translation, form) + |> build_data() + + {:noreply, socket} end - @spec save_translation(term(), term(), term(), term()) :: term() - def save_translation(socket, media_id, reload, append_output) do - case Map.get(socket.assigns.media_editor_translation_forms, media_id) do + def handle_event("save_media_translation", _params, socket) do + media = socket.assigns.media + + case socket.assigns.editing_translation do %{"language" => language} = form when language not in [nil, ""] -> - case Media.upsert_media_translation(media_id, language, %{ + 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) + socket = + socket + |> assign(:editing_translation, nil) + |> build_data() + + {:noreply, socket} {:error, reason} -> - socket - |> append_output.(translated("Save Translation"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) + notify_output(socket, translated("Save Translation"), inspect(reason), "error") + {:noreply, build_data(socket)} end _other -> - socket + {:noreply, socket} end end - @spec refresh_translation(term(), term(), term(), term(), term()) :: term() - def refresh_translation(socket, media_id, language, reload, append_output) do - if Map.get(socket.assigns, :offline_mode, true) do + def handle_event("close_media_translation_editor", _params, socket) do + socket = socket - |> append_output.( + |> assign(:editing_translation, nil) + |> build_data() + + {:noreply, socket} + end + + def handle_event("refresh_media_translation", %{"language" => language}, socket) do + media = socket.assigns.media + + if socket.assigns.offline_mode do + notify_output( + socket, translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), - nil, "info" ) - |> reload.(socket.assigns.workbench) + + {:noreply, build_data(socket)} else - case AI.translate_media(media_id, normalize_language(language)) do + case AI.translate_media(media.id, normalize_language(language)) do {:ok, translation} -> - case Media.upsert_media_translation(media_id, language, translation) do + case Media.upsert_media_translation(media.id, language, translation) do {:ok, _saved_translation} -> - socket |> reload.(socket.assigns.workbench) + {:noreply, build_data(socket)} {:error, reason} -> - socket - |> append_output.(translated("Refresh Translation"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) + notify_output(socket, translated("Refresh Translation"), inspect(reason), "error") + {:noreply, build_data(socket)} end {:error, reason} -> - socket - |> append_output.(translated("Refresh Translation"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) + notify_output(socket, translated("Refresh Translation"), inspect(reason), "error") + {:noreply, build_data(socket)} end end end - @spec delete_translation(term(), term(), term(), term(), term()) :: term() - def delete_translation(socket, media_id, language, reload, append_output) do - case Media.delete_media_translation(media_id, language) do + def handle_event("delete_media_translation", %{"language" => language}, socket) do + media = socket.assigns.media + + 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) + socket = + socket + |> assign(:editing_translation, nil) + |> build_data() + + {:noreply, socket} {:error, reason} -> - socket - |> append_output.(translated("Delete Translation"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) + notify_output(socket, translated("Delete Translation"), inspect(reason), "error") + {:noreply, build_data(socket)} end end - @spec build(term()) :: term() - def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do - case Media.get_media(media_id) do + defp ensure_state(socket) do + media_id = socket.assigns.current_tab.id + media = Media.get_media(media_id) + + defaults = %{ + media_id: media_id, + media: media, + draft: if(media, do: persisted_form(media), else: %{}), + quick_actions_open?: false, + post_picker_open?: false, + post_picker_query: "", + editing_translation: nil, + dirty?: false, + save_state: :idle + } + + Enum.reduce(defaults, socket, fn {key, default}, acc -> + if is_nil(Map.get(acc.assigns, key)) do + assign(acc, key, default) + else + acc + end + end) + end + + defp build_data(socket) do + case socket.assigns.media do nil -> - nil + assign(socket, :media_editor, 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, "") + draft = socket.assigns.draft + picker_query = socket.assigns.post_picker_query {picker_results, picker_overflow_count} = post_picker_results(media, linked_posts, picker_query) - %{ + data = %{ id: media.id, display_title: display_title(media), original_name: media.original_name || media.filename || media.id, @@ -515,44 +427,134 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do 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), + dirty?: socket.assigns.dirty?, + save_state: socket.assigns.save_state, + quick_actions_open?: socket.assigns.quick_actions_open?, + post_picker_open?: socket.assigns.post_picker_open?, post_picker_query: picker_query, post_picker_results: picker_results, post_picker_overflow_count: picker_overflow_count, - form: form, + form: draft, languages: language_codes(), translations: Enum.map(translations, &translation_view/1), - editing_translation: Map.get(assigns.media_editor_translation_forms, media.id), + editing_translation: socket.assigns.editing_translation, linked_posts: linked_posts, - can_detect_language?: detect_language_enabled?(form), - can_translate?: form["language"] not in [nil, ""] + can_detect_language?: detect_language_enabled?(draft), + can_translate?: draft["language"] not in [nil, ""] } + + assign(socket, :media_editor, data) end end - def build(_assigns), do: nil + defp do_save(socket) do + media = socket.assigns.media - @spec translated(term(), term()) :: term() - def translated(text, bindings \\ %{}), - do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + case media do + nil -> + socket - @spec media_editor_save_state_label(term()) :: term() - 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") + %MediaRecord{} = media -> + draft = socket.assigns.draft - @spec language_label(term()) :: term() - def language_label(code) do - code - |> to_string() - |> String.upcase() + case persist(media, draft) do + {:ok, updated_media} -> + socket = + socket + |> assign(:media, updated_media) + |> assign(:draft, persisted_form(updated_media)) + |> assign(:dirty?, false) + |> assign(:save_state, :saved) + |> build_data() + + notify_parent({:media_editor_dirty, media.id, false}) + notify_parent({:media_editor_tab_meta, media.id, display_title(updated_media), updated_media.original_name || updated_media.mime_type || ""}) + notify_output(socket, translated("Media"), translated("Media saved")) + socket + + {:error, reason} -> + notify_output(socket, translated("Media"), inspect(reason), "error") + |> build_data() + end + end end - @spec normalize_language(term()) :: term() - def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase() + defp do_apply_ai_suggestions(socket, fields) do + media = socket.assigns.media + + case media do + nil -> + socket + + %MediaRecord{} = _media -> + updated_draft = + Enum.reduce(fields, socket.assigns.draft, 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) + + dirty? = updated_draft != persisted_form(media) + + socket = + socket + |> assign(:draft, updated_draft) + |> assign(:dirty?, dirty?) + |> assign(:save_state, :dirty) + |> assign(:quick_actions_open?, false) + |> build_data() + + notify_parent({:media_editor_dirty, media.id, dirty?}) + socket + end + end + + defp do_translate(socket, language) do + if socket.assigns.offline_mode do + notify_output( + socket, + translated("Translate"), + translated("Automatic AI actions stay gated by airplane mode."), + "info" + ) + + build_data(socket) + else + media = socket.assigns.media + 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(:quick_actions_open?, false) + |> assign(:editing_translation, nil) + |> build_data() + + {:error, reason} -> + notify_output(socket, translated("Translate"), inspect(reason), "error") + |> build_data() + end + + {:error, reason} -> + notify_output(socket, translated("Translate"), inspect(reason), "error") + |> build_data() + end + end + end + + defp notify_parent(message) do + send(self(), message) + end + + defp notify_output(socket, title, message, level \\ "info") do + send(self(), {:media_editor_output, title, message, level}) + socket + end @spec persist(term(), term()) :: term() def persist(%MediaRecord{} = media, draft) do @@ -566,68 +568,6 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do }) 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, @@ -660,10 +600,6 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do {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}", @@ -699,6 +635,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do |> Enum.map(& &1.code) 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 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_query(value) do value |> to_string() @@ -724,6 +682,22 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end - defp reload_with_assigned_workbench(socket, reload), - do: reload.(socket, socket.assigns.workbench) + @spec translated(term(), term()) :: term() + def translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + + @spec media_editor_save_state_label(term()) :: term() + 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") + + @spec language_label(term()) :: term() + def language_label(code) do + code + |> to_string() + |> String.upcase() + end + + @spec normalize_language(term()) :: term() + def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase() end diff --git a/lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex b/lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex index 455eed8..af6f7ee 100644 --- a/lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex +++ b/lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex @@ -23,7 +23,7 @@ class="secondary quick-actions-btn" type="button" phx-click="toggle_media_editor_quick_actions" - phx-value-id={@media_editor.id} + phx-target={@myself} > <%= translated("Quick Actions") %> @@ -53,7 +53,7 @@ class="quick-action-item" type="button" phx-click="detect_media_editor_language" - phx-value-id={@media_editor.id} + phx-target={@myself} disabled={not @media_editor.can_detect_language?} > @@ -83,10 +83,10 @@ <% end %> - - - - + <% end %> @@ -212,7 +212,7 @@
@@ -226,7 +226,7 @@ value={@media_editor.post_picker_query} placeholder={translated("Search posts")} phx-change="change_media_post_picker" - phx-value-id={@media_editor.id} + phx-target={@myself} />
@@ -235,7 +235,7 @@ <% else %>
<%= for result <- @media_editor.post_picker_results do %> - <% end %> @@ -264,7 +264,7 @@ > 📄 <%= linked_post.title %> - +
<% end %> @@ -278,9 +278,9 @@

<%= translated("Edit Translation") %>

- +
- +
@@ -296,10 +296,10 @@
<% end %> - \ No newline at end of file + diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 22d6e2c..00327ca 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -2444,7 +2444,11 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-testid="media-editor") - html = render_click(view, "toggle_media_editor_quick_actions", %{"id" => media.id}) + html = + view + |> element("[data-testid='media-editor'] .quick-actions-btn") + |> render_click() + assert html =~ "quick-actions-menu" html = @@ -2705,7 +2709,10 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Titelbild" refute html =~ "Desktop workbench content routed through the Elixir shell." - html = render_click(view, "toggle_media_editor_quick_actions", %{"id" => media.id}) + html = + view + |> element("[data-testid='media-editor'] .quick-actions-btn") + |> render_click() assert html =~ "quick-actions-menu" assert html =~ "Detect Language" @@ -2727,7 +2734,10 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Updated Cover" - _html = render_click(view, "save_media_editor", %{"id" => media.id}) + _html = + view + |> element("[data-testid='media-save-button']") + |> render_click() saved_media = Repo.get!(BDS.Media.Media, media.id) assert saved_media.title == "Updated Cover" @@ -2791,7 +2801,10 @@ defmodule BDS.Desktop.ShellLiveTest do "[data-testid='media-editor'] .editor-content.media-editor .media-details .linked-posts-section" ) - html = render_click(view, "edit_media_translation", %{"id" => media.id, "language" => "de"}) + html = + view + |> element("[phx-click='edit_media_translation'][phx-value-language='de']") + |> render_click() assert html =~ ~s(class="translation-modal-backdrop") assert html =~ ~s(class="translation-modal") diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index 1bc3116..86a002b 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -401,8 +401,8 @@ defmodule BDS.UI.ShellTest do media_editor_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/media_editor.ex") - assert template =~ "