diff --git a/lib/bds/desktop/shell_live/overlay_manager.ex b/lib/bds/desktop/shell_live/overlay_manager.ex new file mode 100644 index 0000000..a6391af --- /dev/null +++ b/lib/bds/desktop/shell_live/overlay_manager.ex @@ -0,0 +1,450 @@ +defmodule BDS.Desktop.ShellLive.OverlayManager do + @moduledoc false + + require Logger + + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [send_update: 2] + + alias BDS.{AI, Media, Metadata} + alias BDS.Desktop.{Overlay, ShellData, UILocale} + + alias BDS.Desktop.ShellLive.{ + MediaEditor, + PostEditor, + TabHelpers + } + + alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents + + # ── Event handlers ───────────────────────────────────────────────────────── + + @spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t(), map()) :: + {:noreply, Phoenix.LiveView.Socket.t()} + def handle_event("open_overlay", %{"kind" => kind}, socket, callbacks) do + socket = + case socket.assigns[:current_tab] do + %{type: :post, id: post_id} + when kind in ["ai_suggestions", "language_picker"] -> + send_update(PostEditor, + id: "post-editor-#{post_id}", + action: :close_quick_actions + ) + + socket + + %{type: :media, id: media_id} + when kind in ["ai_suggestions", "language_picker", "confirm_delete"] -> + send_update(MediaEditor, + id: "media-editor-#{media_id}", + action: :close_quick_actions + ) + + socket + + _other -> + socket + end + + overlay = + with overlay_kind when not is_nil(overlay_kind) <- ShellOverlayComponents.kind(kind), + %{type: route} <- socket.assigns[:current_tab] do + tab = socket.assigns.current_tab + title = TabHelpers.tab_title(tab, socket.assigns.tab_meta) + subtitle = TabHelpers.tab_subtitle(tab, socket.assigns.tab_meta) + + Overlay.open( + route, + overlay_kind, + ShellOverlayComponents.context(socket.assigns, title, subtitle) + ) + end + + socket = assign(socket, :shell_overlay, overlay) + + socket = + if kind == "ai_suggestions" and not is_nil(overlay) do + if socket.assigns.offline_mode do + callbacks.append_output.( + socket, + translated("AI Suggestions"), + translated("Automatic AI actions stay gated by airplane mode."), + nil, + "info" + ) + |> assign(:shell_overlay, nil) + else + spawn_ai_suggestions_task(socket) + end + else + socket + end + + {:noreply, socket} + end + + def handle_event("close_overlay", _params, socket, _callbacks) do + {:noreply, assign(socket, :shell_overlay, nil)} + end + + def handle_event("overlay_keydown", %{"key" => key}, socket, _callbacks) do + socket = + case {socket.assigns[:shell_overlay], key} do + {nil, _other} -> + socket + + {_overlay, "Escape"} -> + assign(socket, :shell_overlay, nil) + + {%{kind: :gallery} = overlay, "ArrowLeft"} -> + assign(socket, :shell_overlay, Overlay.lightbox_previous(overlay)) + + {%{kind: :gallery} = overlay, "ArrowRight"} -> + assign(socket, :shell_overlay, Overlay.lightbox_next(overlay)) + + _other -> + socket + end + + {:noreply, socket} + end + + def handle_event("overlay_toggle_ai_field", %{"key" => key}, socket, _callbacks) do + {:noreply, update_shell_overlay(socket, &Overlay.toggle_ai_field(&1, key))} + end + + def handle_event("overlay_set_search", %{"overlay" => %{"query" => query}}, socket, _callbacks) do + {:noreply, update_shell_overlay(socket, &Overlay.set_search_query(&1, query))} + end + + def handle_event("overlay_set_tab", %{"tab" => tab}, socket, _callbacks) do + {:noreply, + update_shell_overlay(socket, &Overlay.set_active_tab(&1, ShellOverlayComponents.tab(tab)))} + end + + def handle_event("overlay_update_form", %{"overlay" => params}, socket, _callbacks) do + socket = + socket + |> update_shell_overlay( + &Overlay.update_form_value(&1, :external_url, Map.get(params, "url", "")) + ) + |> update_shell_overlay( + &Overlay.update_form_value(&1, :external_text, Map.get(params, "text", "")) + ) + + {:noreply, socket} + end + + def handle_event("overlay_select_result", %{"id" => id}, socket, _callbacks) do + overlay = socket.assigns[:shell_overlay] + current_tab = socket.assigns[:current_tab] + + socket = + case {overlay, current_tab} do + {%{kind: :insert_link}, %{type: :post, id: post_id}} -> + case Overlay.insert_link_result(overlay, id) do + nil -> + socket + + result -> + send(self(), {:post_editor_insert_content, post_id, + ShellOverlayComponents.markdown_link(result.title, result.canonical_url)}) + socket + end + + {%{kind: :insert_media}, %{type: :post, id: post_id}} -> + case Overlay.insert_media_result(overlay, id) do + nil -> + socket + + result -> + syntax = + if result.is_image do + "![#{result.title}](bds-media://#{result.media_id})" + else + "[#{result.original_name}](bds-media://#{result.media_id})" + end + + send(self(), {:post_editor_insert_content, post_id, syntax}) + socket + end + + _other -> + socket + end + + {:noreply, socket} + end + + def handle_event("overlay_insert_external", _params, socket, _callbacks) do + current_tab = socket.assigns[:current_tab] + + socket = + case {socket.assigns[:shell_overlay], current_tab} do + {%{kind: :insert_link} = overlay, %{type: :post, id: post_id}} -> + details = + case {overlay.external_url, String.trim(overlay.external_text || "")} do + {"", _text} -> nil + {url, ""} -> url + {url, text} -> ShellOverlayComponents.markdown_link(text, url) + end + + if details do + send(self(), {:post_editor_insert_content, post_id, details}) + end + + socket + + _other -> + socket + end + + {:noreply, socket} + end + + def handle_event("overlay_select_language", %{"code" => code}, socket, _callbacks) do + current_tab = socket.assigns[:current_tab] + + socket = + case {socket.assigns[:shell_overlay], current_tab} do + {%{kind: :language_picker}, %{type: :post, id: post_id}} -> + send(self(), {:post_editor_translate, post_id, code}) + socket + + {%{kind: :language_picker}, %{type: :media, id: media_id}} -> + send_update(MediaEditor, + id: "media-editor-#{media_id}", + action: :translate, + language: code + ) + + socket + + _other -> + socket + end + + {:noreply, socket} + end + + def handle_event("overlay_confirm", _params, socket, callbacks) do + current_tab = socket.assigns[:current_tab] + + socket = + case {socket.assigns[:shell_overlay], current_tab} do + {%{kind: :confirm_delete, delete_action: %{source: :sidebar, route: route, id: id}}, + _tab} -> + callbacks.execute_sidebar_delete.(socket, route, id) + + {%{kind: :ai_suggestions} = overlay, %{type: :post, id: post_id}} -> + send(self(), {:post_editor_apply_ai_suggestions, post_id, + Overlay.selected_ai_fields(overlay)}) + socket + + {%{kind: :ai_suggestions} = overlay, %{type: :media, id: media_id}} -> + 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}} -> + case Media.delete_media(media_id) do + {:ok, :deleted} -> + workbench = BDS.UI.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})) + |> callbacks.reload.(workbench) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> callbacks.append_output.( + translated("Delete Media"), + inspect(reason), + nil, + "error" + ) + |> callbacks.reload.(socket.assigns.workbench) + end + + {%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} -> + close_overlay_with_output(socket, callbacks.append_output, title, entity_name) + + {%{kind: :confirm_dialog, title: title, message: message}, _tab} -> + close_overlay_with_output(socket, callbacks.append_output, title, message) + + _other -> + socket + end + + {:noreply, socket} + end + + def handle_event("overlay_select_gallery_image", %{"id" => id}, socket, _callbacks) do + {:noreply, update_shell_overlay(socket, &Overlay.select_gallery_image(&1, id))} + end + + def handle_event("overlay_close_lightbox", _params, socket, _callbacks) do + {:noreply, update_shell_overlay(socket, &Overlay.close_lightbox/1)} + end + + def handle_event("overlay_lightbox_previous", _params, socket, _callbacks) do + {:noreply, update_shell_overlay(socket, &Overlay.lightbox_previous/1)} + end + + def handle_event("overlay_lightbox_next", _params, socket, _callbacks) do + {:noreply, update_shell_overlay(socket, &Overlay.lightbox_next/1)} + end + + # ── handle_info for async AI suggestions ─────────────────────────────────── + + @spec handle_info(tuple(), Phoenix.LiveView.Socket.t()) :: + {:noreply, Phoenix.LiveView.Socket.t()} + def handle_info({:ai_suggestions_result, type, id, result}, socket) do + socket = + case socket.assigns[:shell_overlay] do + %{kind: :ai_suggestions} = overlay -> + current_tab = socket.assigns.current_tab + + if current_tab && current_tab.type == type && current_tab.id == id do + suggestions = + case type do + :post -> + %{ + "title" => result.title, + "excerpt" => result.excerpt, + "slug" => result.slug + } + + :media -> + %{ + "title" => result.title, + "alt" => result.alt, + "caption" => result.caption + } + end + + assign(socket, :shell_overlay, + Overlay.set_ai_suggestions(overlay, suggestions)) + else + socket + end + + _other -> + socket + end + + {:noreply, socket} + end + + def handle_info({:ai_suggestions_error, type, id, reason}, socket) do + Logger.error("AI suggestions error type=#{type} id=#{id} reason=#{inspect(reason)}") + + socket = + case socket.assigns[:shell_overlay] do + %{kind: :ai_suggestions} = overlay -> + current_tab = socket.assigns.current_tab + + if current_tab && current_tab.type == type && current_tab.id == id do + message = + if is_map(reason) and Map.has_key?(reason, :kind) do + "#{reason.kind}: #{inspect(Map.drop(reason, [:kind]))}" + else + inspect(reason) + end + + assign(socket, :shell_overlay, + Overlay.set_ai_suggestions_error(overlay, message)) + else + socket + end + + _other -> + socket + end + + {:noreply, socket} + end + + # ── Private helpers ──────────────────────────────────────────────────────── + + @spec update_shell_overlay(Phoenix.LiveView.Socket.t(), (map() -> map())) :: + Phoenix.LiveView.Socket.t() + defp update_shell_overlay(socket, updater) do + case socket.assigns[:shell_overlay] do + nil -> socket + overlay -> assign(socket, :shell_overlay, updater.(overlay)) + end + end + + @spec close_overlay_with_output( + Phoenix.LiveView.Socket.t(), + (Phoenix.LiveView.Socket.t(), String.t(), String.t(), any(), String.t() -> + Phoenix.LiveView.Socket.t()), + String.t(), + any() + ) :: Phoenix.LiveView.Socket.t() + defp close_overlay_with_output(socket, append_output, title, details) do + socket + |> append_output.(title, translated("Command completed"), details, "info") + |> assign(:shell_overlay, nil) + end + + @spec spawn_ai_suggestions_task(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() + defp spawn_ai_suggestions_task(socket) do + current_tab = socket.assigns.current_tab + language = ai_suggestions_language(socket) + + case current_tab do + %{type: :post, id: post_id} -> + parent = self() + + Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn -> + case AI.analyze_post(post_id, language: language) do + {:ok, result} -> + send(parent, {:ai_suggestions_result, :post, post_id, result}) + + {:error, reason} -> + send(parent, {:ai_suggestions_error, :post, post_id, reason}) + end + end) + + %{type: :media, id: media_id} -> + parent = self() + + Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn -> + case AI.analyze_image(media_id, language: language) do + {:ok, result} -> + send(parent, {:ai_suggestions_result, :media, media_id, result}) + + {:error, reason} -> + send(parent, {:ai_suggestions_error, :media, media_id, reason}) + end + end) + + _other -> + :ok + end + + socket + end + + @spec ai_suggestions_language(Phoenix.LiveView.Socket.t()) :: String.t() + defp ai_suggestions_language(socket) do + active_project_id = socket.assigns.projects.active_project_id + {:ok, metadata} = Metadata.get_project_metadata(active_project_id) + metadata.main_language || "en" + rescue + _error -> "en" + end + + @spec translated(String.t(), map()) :: String.t() + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, UILocale.current()) +end diff --git a/lib/bds/desktop/shell_live/sidebar_delete.ex b/lib/bds/desktop/shell_live/sidebar_delete.ex new file mode 100644 index 0000000..fa7d6f9 --- /dev/null +++ b/lib/bds/desktop/shell_live/sidebar_delete.ex @@ -0,0 +1,176 @@ +defmodule BDS.Desktop.ShellLive.SidebarDelete do + @moduledoc false + + import Phoenix.Component, only: [assign: 3] + + alias BDS.{AI, ImportDefinitions, Media, Posts, Scripts, Templates} + alias BDS.Desktop.ShellData + alias BDS.Desktop.UILocale + + @spec request_delete(Phoenix.LiveView.Socket.t(), String.t(), String.t(), String.t() | nil, map()) :: + Phoenix.LiveView.Socket.t() + def request_delete(socket, route, id, fallback_title, callbacks) do + case delete_target(socket, route, id, fallback_title) do + {:ok, entity_name} -> + assign(socket, :shell_overlay, %{ + kind: :confirm_delete, + title: delete_title(route), + entity_name: entity_name, + entity_type: route, + reference_count: 0, + reference_list: [], + delete_action: %{source: :sidebar, route: route, id: id} + }) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> callbacks.append_output.(delete_title(route), inspect(reason), nil, "error") + |> callbacks.reload.(socket.assigns.workbench) + end + end + + @spec execute_delete(Phoenix.LiveView.Socket.t(), String.t(), String.t(), map()) :: + Phoenix.LiveView.Socket.t() + def execute_delete(socket, route, id, callbacks) do + case route do + "post" -> + delete_entity(socket, :post, id, &Posts.delete_post/1, callbacks) + + "media" -> + delete_entity(socket, :media, id, &Media.delete_media/1, callbacks) + + "scripts" -> + delete_entity(socket, :scripts, id, &Scripts.delete_script/1, callbacks) + + "templates" -> + delete_entity(socket, :templates, id, fn tid -> + Templates.delete_template(tid, force: true) + end, callbacks) + + "chat" -> + delete_entity(socket, :chat, id, &AI.delete_chat_conversation/1, callbacks) + + "import" -> + delete_entity(socket, :import, id, &ImportDefinitions.delete_definition/1, callbacks) + + _other -> + socket + |> assign(:shell_overlay, nil) + |> callbacks.append_output.(translated("Delete"), inspect(:unsupported_route), nil, "error") + |> callbacks.reload.(socket.assigns.workbench) + end + end + + # ── Private helpers ──────────────────────────────────────────────────────── + + defp delete_entity(socket, type, id, delete_fn, callbacks) do + case delete_fn.(id) do + {:ok, :deleted} -> + workbench = BDS.UI.Workbench.close_tab(socket.assigns.workbench, type, id) + + socket + |> assign(:shell_overlay, nil) + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {type, id})) + |> callbacks.reload.(workbench) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> callbacks.append_output.( + delete_title(Atom.to_string(type)), + inspect(reason), + nil, + "error" + ) + |> callbacks.reload.(socket.assigns.workbench) + end + end + + @spec delete_target(Phoenix.LiveView.Socket.t(), String.t(), String.t(), String.t() | nil) :: + {:ok, String.t()} | {:error, atom()} + defp delete_target(socket, route, id, fallback_title) do + active_project_id = socket.assigns.projects.active_project_id + + case route do + "post" -> + case Posts.get_post(id) do + %{project_id: ^active_project_id} = post -> + {:ok, present_title(fallback_title) || present_title(post.title) || present_title(post.slug) || id} + + _other -> + {:error, :not_found} + end + + "media" -> + case Media.get_media(id) do + %{project_id: ^active_project_id} = media -> + {:ok, + present_title(fallback_title) || present_title(media.title) || + present_title(media.original_name) || id} + + _other -> + {:error, :not_found} + end + + "scripts" -> + case Scripts.get_script(id) do + %{project_id: ^active_project_id} = script -> + {:ok, present_title(fallback_title) || present_title(script.title) || id} + + _other -> + {:error, :not_found} + end + + "templates" -> + case Templates.get_template(id) do + %{project_id: ^active_project_id} = template -> + {:ok, present_title(fallback_title) || present_title(template.title) || id} + + _other -> + {:error, :not_found} + end + + "chat" -> + case AI.get_chat_conversation(id) do + %{title: title} -> {:ok, present_title(fallback_title) || present_title(title) || id} + _other -> {:error, :not_found} + end + + "import" -> + case ImportDefinitions.get_definition(id) do + %{project_id: ^active_project_id} = definition -> + {:ok, present_title(fallback_title) || present_title(definition.name) || id} + + _other -> + {:error, :not_found} + end + + _other -> + {:error, :unsupported_route} + end + end + + @spec delete_title(String.t()) :: String.t() + defp delete_title("chat"), do: translated("sidebar.chat.deleteConversation") + defp delete_title("post"), do: translated("Delete") <> " " <> translated("Post") + defp delete_title("media"), do: translated("Delete") <> " " <> translated("Media") + defp delete_title("scripts"), do: translated("Delete") <> " " <> translated("Script") + defp delete_title("templates"), do: translated("Delete") <> " " <> translated("Template") + defp delete_title("import"), do: translated("Delete") <> " " <> translated("Import") + defp delete_title(_route), do: translated("Delete") + + @spec present_title(String.t() | nil) :: String.t() | nil + defp present_title(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp present_title(_value), do: nil + + @spec translated(String.t(), map()) :: String.t() + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, UILocale.current()) +end