454 lines
14 KiB
Elixir
454 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}
|
|
|
|
alias BDS.Desktop.ShellLive.{
|
|
MediaEditor,
|
|
PostEditor,
|
|
TabHelpers
|
|
}
|
|
|
|
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
|
|
use Gettext, backend: BDS.Gettext
|
|
|
|
# ── 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,
|
|
dgettext("ui", "AI Suggestions"),
|
|
dgettext("ui", "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
|
|
""
|
|
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.(
|
|
dgettext("ui", "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, dgettext("ui", "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
|
|
end
|