Files
bDS2/lib/bds/desktop/shell_live/overlay_manager.ex

451 lines
14 KiB
Elixir

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