chore: more of the overlay and sidebar stuff

This commit is contained in:
2026-05-03 18:56:10 +02:00
parent f3d8fbcbdc
commit 483c13aaa3
2 changed files with 626 additions and 0 deletions

View File

@@ -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

View File

@@ -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