diff --git a/lib/bds/desktop/file_picker.ex b/lib/bds/desktop/file_picker.ex
new file mode 100644
index 0000000..2aa6bf1
--- /dev/null
+++ b/lib/bds/desktop/file_picker.ex
@@ -0,0 +1,35 @@
+defmodule BDS.Desktop.FilePicker do
+ @moduledoc false
+
+ def choose_file(prompt) when is_binary(prompt) do
+ case :os.type() do
+ {:unix, :darwin} -> choose_file_macos(prompt)
+ _other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
+ end
+ end
+
+ defp choose_file_macos(prompt) do
+ script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")"
+
+ case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
+ {output, 0} -> {:ok, String.trim(output)}
+ {output, _status} -> normalize_picker_failure(output)
+ end
+ end
+
+ defp normalize_picker_failure(output) do
+ message = String.trim(output)
+
+ if message == "" or String.contains?(String.downcase(message), "canceled") do
+ :cancel
+ else
+ {:error, %{message: message}}
+ end
+ end
+
+ defp escape_applescript(value) do
+ value
+ |> String.replace("\\", "\\\\")
+ |> String.replace("\"", "\\\"")
+ end
+end
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex
index c4cd59a..210da96 100644
--- a/lib/bds/desktop/shell_live.ex
+++ b/lib/bds/desktop/shell_live.ex
@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML
alias BDS.Desktop.{FolderPicker, Overlay, ShellCommands, ShellData}
+ alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MiscEditor, SettingsEditor, TagsEditor}
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
alias BDS.Desktop.ShellLive.PostEditor
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
@@ -70,6 +71,26 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:post_editor_modes, %{})
|> assign(:post_editor_expanded, %{})
|> assign(:post_editor_save_states, %{})
+ |> 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(:settings_editor_search, "")
+ |> assign(:settings_editor_project_draft, %{})
+ |> assign(:settings_editor_publishing_draft, %{})
+ |> assign(:settings_editor_new_category, "")
+ |> assign(:style_editor_theme, nil)
+ |> assign(:style_editor_preview_mode, "auto")
+ |> assign(:tags_editor_selected, [])
+ |> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""})
+ |> assign(:tags_editor_edit_draft, %{})
+ |> assign(:tags_editor_merge_target, "")
+ |> assign(:script_editor_drafts, %{})
+ |> assign(:template_editor_drafts, %{})
+ |> assign(:chat_editor_inputs, %{})
+ |> assign(:misc_editor_selected_pairs, %{})
|> assign(:shell_overlay, nil)
|> assign(:output_entries, [])
|> reload_shell(workbench)}
@@ -408,12 +429,253 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, PostEditor.remove_list_value(socket, post_id, :categories, category, &reload_shell/2)}
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("change_settings_search", %{"query" => query}, socket) do
+ {:noreply, SettingsEditor.update_search(socket, query, &reload_shell/2)}
+ end
+
+ def handle_event("change_settings_project", %{"settings_project" => params}, socket) do
+ {:noreply, SettingsEditor.update_project_draft(socket, params, &reload_shell/2)}
+ end
+
+ def handle_event("save_settings_project", _params, socket) do
+ {:noreply, SettingsEditor.save_project(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("change_settings_publishing", %{"settings_publishing" => params}, socket) do
+ {:noreply, SettingsEditor.update_publishing_draft(socket, params, &reload_shell/2)}
+ end
+
+ def handle_event("save_settings_publishing", _params, socket) do
+ {:noreply, SettingsEditor.save_publishing(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("clear_settings_publishing", _params, socket) do
+ {:noreply, SettingsEditor.clear_publishing(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("change_settings_new_category", %{"name" => name}, socket) do
+ {:noreply, SettingsEditor.update_new_category(socket, name, &reload_shell/2)}
+ end
+
+ def handle_event("add_settings_category", _params, socket) do
+ {:noreply, SettingsEditor.add_category(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("save_settings_category", %{"category_settings" => params}, socket) do
+ {:noreply, SettingsEditor.save_category(socket, params, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("remove_settings_category", %{"category" => category}, socket) do
+ {:noreply, SettingsEditor.remove_category(socket, category, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("settings_shell_command", %{"action" => action}, socket) do
+ {:noreply, apply_shell_command(socket, action)}
+ end
+
+ def handle_event("select_style_theme", %{"theme" => theme}, socket) do
+ {:noreply, SettingsEditor.select_style_theme(socket, theme, &reload_shell/2)}
+ end
+
+ def handle_event("change_style_preview_mode", %{"mode" => mode}, socket) do
+ {:noreply, SettingsEditor.change_style_preview_mode(socket, mode, &reload_shell/2)}
+ end
+
+ def handle_event("apply_style_theme", _params, socket) do
+ {:noreply, SettingsEditor.apply_style_theme(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("toggle_tag_selection", %{"name" => tag_name}, socket) do
+ {:noreply, TagsEditor.toggle_selection(socket, tag_name, &reload_shell/2)}
+ end
+
+ def handle_event("change_new_tag_editor", %{"new_tag" => params}, socket) do
+ {:noreply, TagsEditor.update_new_tag(socket, params, &reload_shell/2)}
+ end
+
+ def handle_event("create_tag_editor", _params, socket) do
+ {:noreply, TagsEditor.create_tag(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("change_edit_tag_editor", %{"edit_tag" => params}, socket) do
+ {:noreply, TagsEditor.update_edit_tag(socket, params, &reload_shell/2)}
+ end
+
+ def handle_event("save_tag_editor", _params, socket) do
+ {:noreply, TagsEditor.save_tag(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("delete_tag_editor", _params, socket) do
+ {:noreply, TagsEditor.delete_selected(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("change_merge_target", %{"target" => target}, socket) do
+ {:noreply, TagsEditor.update_merge_target(socket, target, &reload_shell/2)}
+ end
+
+ def handle_event("merge_tags_editor", _params, socket) do
+ {:noreply, TagsEditor.merge_selected(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("sync_tags_editor", _params, socket) do
+ {:noreply, TagsEditor.sync(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("change_script_editor", %{"script_editor" => params}, socket) do
+ {:noreply, CodeEntityEditor.update_script(socket, params, &reload_shell/2)}
+ end
+
+ def handle_event("save_script_editor", _params, socket) do
+ {:noreply, CodeEntityEditor.save_script(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("run_script_editor", _params, socket) do
+ {:noreply, CodeEntityEditor.run_script(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("check_script_editor", _params, socket) do
+ {:noreply, CodeEntityEditor.check_script(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("delete_script_editor", _params, socket) do
+ {:noreply, CodeEntityEditor.delete_script(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("change_template_editor", %{"template_editor" => params}, socket) do
+ {:noreply, CodeEntityEditor.update_template(socket, params, &reload_shell/2)}
+ end
+
+ def handle_event("save_template_editor", _params, socket) do
+ {:noreply, CodeEntityEditor.save_template(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("validate_template_editor", _params, socket) do
+ {:noreply, CodeEntityEditor.validate_template(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("delete_template_editor", _params, socket) do
+ {:noreply, CodeEntityEditor.delete_template(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("change_chat_editor_input", %{"message" => message}, socket) do
+ {:noreply, ChatEditor.update_input(socket, message, &reload_shell/2)}
+ end
+
+ def handle_event("send_chat_editor_message", _params, socket) do
+ {:noreply, ChatEditor.send_message(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("rerun_misc_editor", _params, socket) do
+ case MiscEditor.rerun(socket) do
+ {:command, action} -> {:noreply, apply_shell_command(socket, action)}
+ {:noop, next_socket} -> {:noreply, next_socket}
+ end
+ end
+
+ def handle_event("apply_site_validation", _params, socket) do
+ case MiscEditor.apply_site_validation(socket, &append_output_entry/5) do
+ {:rerun, next_socket} -> {:noreply, apply_shell_command(next_socket, "validate_site")}
+ {:socket, next_socket} -> {:noreply, next_socket}
+ end
+ end
+
+ def handle_event("toggle_duplicate_pair", %{"pair-id" => pair_id}, socket) do
+ {:noreply, MiscEditor.toggle_duplicate(socket, pair_id, &reload_shell/2)}
+ end
+
+ def handle_event("dismiss_duplicate_pair", %{"post-id-a" => post_id_a, "post-id-b" => post_id_b}, socket) do
+ {:noreply, MiscEditor.dismiss_duplicate(socket, post_id_a, post_id_b, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("dismiss_selected_duplicates", _params, socket) do
+ {:noreply, MiscEditor.dismiss_selected(socket, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_event("open_duplicate_post", %{"id" => id, "title" => title}, socket) do
+ {:noreply, open_sidebar_item(socket, %{"route" => "post", "id" => id, "title" => title, "subtitle" => "draft"}, :preview)}
+ end
+
def handle_event("open_overlay", %{"kind" => kind}, socket) do
socket =
case socket.assigns[:current_tab] do
%{type: :post, id: post_id} when kind in ["ai_suggestions", "language_picker"] ->
assign(socket, :post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false))
+ %{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))
+
_other ->
socket
end
@@ -535,6 +797,9 @@ defmodule BDS.Desktop.ShellLive do
{%{kind: :language_picker}, %{type: :post, id: post_id}} ->
PostEditor.translate(socket, post_id, code, &reload_shell/2, &append_output_entry/5)
+ {%{kind: :language_picker}, %{type: :media, id: media_id}} ->
+ MediaEditor.translate(socket, media_id, code, &reload_shell/2, &append_output_entry/5)
+
_other -> socket
end
@@ -555,6 +820,18 @@ defmodule BDS.Desktop.ShellLive do
&append_output_entry/5
)
+ {%{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
+ )
+
+ {%{kind: :confirm_delete}, %{type: :media, id: media_id}} ->
+ MediaEditor.delete_socket(socket, media_id, &reload_shell/2, &append_output_entry/5)
+
{%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} ->
close_overlay_with_output(socket, title, entity_name)
@@ -742,6 +1019,12 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> assign(:current_tab, current_tab(workbench))
|> assign_post_editor()
+ |> assign_media_editor()
+ |> assign_settings_editor()
+ |> assign_tags_editor()
+ |> assign_code_entity_editor()
+ |> assign_chat_editor()
+ |> assign_misc_editor()
end
defp render_panel_body(assigns) do
@@ -963,6 +1246,30 @@ defmodule BDS.Desktop.ShellLive do
PostEditor.assign_socket(socket)
end
+ defp assign_media_editor(socket) do
+ MediaEditor.assign_socket(socket)
+ end
+
+ defp assign_settings_editor(socket) do
+ SettingsEditor.assign_socket(socket)
+ end
+
+ defp assign_tags_editor(socket) do
+ TagsEditor.assign_socket(socket)
+ end
+
+ defp assign_code_entity_editor(socket) do
+ CodeEntityEditor.assign_socket(socket)
+ end
+
+ defp assign_chat_editor(socket) do
+ ChatEditor.assign_socket(socket)
+ end
+
+ defp assign_misc_editor(socket) do
+ MiscEditor.assign_socket(socket)
+ end
+
defp sync_layout(workbench, params) do
workbench
@@ -1136,11 +1443,20 @@ defmodule BDS.Desktop.ShellLive do
append_output_entry(socket, title, message, url)
end
- defp apply_shell_command_result(socket, %{kind: "open_editor", route: route, title: title, subtitle: subtitle}) do
+ defp apply_shell_command_result(socket, %{kind: "open_editor", route: route, title: title, subtitle: subtitle} = result) do
route_atom = String.to_existing_atom(route)
tab_id = tab_id_for_route(route_atom, route)
workbench = Workbench.open_tab(socket.assigns.workbench, route_atom, tab_id, :pin)
- tab_meta = Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{title: title, subtitle: subtitle})
+
+ tab_meta =
+ Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{
+ title: title,
+ subtitle: subtitle,
+ action: Map.get(result, :action),
+ payload: Map.get(result, :payload),
+ project_id: Map.get(result, :project_id),
+ editor_meta: Map.get(result, :editorMeta, [])
+ })
socket
|> assign(:tab_meta, tab_meta)
@@ -1173,6 +1489,22 @@ defmodule BDS.Desktop.ShellLive do
|> elem(0)
end
+ defp titlebar_menu_item_active?(group, item, current_index) do
+ cond do
+ is_nil(current_index) ->
+ false
+
+ Map.get(item, :separator, false) ->
+ false
+
+ true ->
+ group.items
+ |> Enum.reject(&Map.get(&1, :separator, false))
+ |> Enum.find_index(&(&1.id == item.id))
+ |> Kernel.==(current_index)
+ end
+ end
+
defp active_titlebar_menu_group(assigns) do
Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end)
end
diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex
new file mode 100644
index 0000000..37430f3
--- /dev/null
+++ b/lib/bds/desktop/shell_live/chat_editor.ex
@@ -0,0 +1,68 @@
+defmodule BDS.Desktop.ShellLive.ChatEditor do
+ @moduledoc false
+
+ use Phoenix.Component
+
+ alias BDS.{AI, Repo}
+ alias BDS.AI.ChatConversation
+ alias BDS.Desktop.ShellData
+
+ embed_templates "chat_editor_html/*"
+
+ def assign_socket(socket) do
+ assign(socket, :chat_editor, build(socket.assigns))
+ end
+
+ def update_input(socket, value, reload) do
+ %{id: conversation_id} = socket.assigns.current_tab
+
+ socket
+ |> assign(:chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || "")))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def send_message(socket, reload, append_output) do
+ %{id: conversation_id} = socket.assigns.current_tab
+ message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
+
+ cond do
+ message == "" -> reload.(socket, socket.assigns.workbench)
+ socket.assigns.offline_mode ->
+ socket
+ |> append_output.(translated("Chat"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
+ |> reload.(socket.assigns.workbench)
+
+ true ->
+ case AI.send_chat_message(conversation_id, message, project_id: socket.assigns.projects.active_project_id) do
+ {:ok, _result} ->
+ socket
+ |> assign(:chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, ""))
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Chat"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+
+ def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
+ case Repo.get(ChatConversation, conversation_id) do
+ nil -> nil
+ %ChatConversation{} = conversation ->
+ %{
+ id: conversation.id,
+ title: conversation.title || translated("New Chat"),
+ model: conversation.model,
+ input: Map.get(assigns.chat_editor_inputs, conversation.id, ""),
+ messages: AI.list_chat_messages(conversation.id),
+ offline?: Map.get(assigns, :offline_mode, true)
+ }
+ end
+ end
+
+ def build(_assigns), do: nil
+
+ def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
+end
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex
new file mode 100644
index 0000000..a87db3e
--- /dev/null
+++ b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex
@@ -0,0 +1,38 @@
+
+
+
+
+ <%= if Enum.empty?(@chat_editor.messages) do %>
+
+
🤖
+
<%= translated("New Chat") %>
+
<%= translated("Ask the assistant about the active project.") %>
+
+ <%= translated("Search posts and media") %>
+ <%= translated("Inspect metadata") %>
+ <%= translated("Open related tabs") %>
+ <%= translated("Review generated output") %>
+ <%= translated("Navigate settings") %>
+
+
+ <% else %>
+ <%= for message <- @chat_editor.messages do %>
+
+
+
+
<%= message.content || "" %>
+
+
+ <% end %>
+ <% end %>
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/code_entity_editor.ex b/lib/bds/desktop/shell_live/code_entity_editor.ex
new file mode 100644
index 0000000..655cbe9
--- /dev/null
+++ b/lib/bds/desktop/shell_live/code_entity_editor.ex
@@ -0,0 +1,268 @@
+defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
+ @moduledoc false
+
+ use Phoenix.Component
+
+ alias BDS.Desktop.ShellData
+ alias BDS.{MCP, Repo, Scripts, Scripting, Templates}
+ alias BDS.Scripts.Script
+ alias BDS.Templates.Template
+
+ embed_templates "code_entity_editor_html/*"
+
+ def assign_socket(socket) do
+ socket
+ |> assign(:script_editor, build_script(socket.assigns))
+ |> assign(:template_editor, build_template(socket.assigns))
+ end
+
+ def update_script(socket, params, reload) do
+ %{id: script_id} = socket.assigns.current_tab
+
+ socket
+ |> assign(:script_editor_drafts, Map.put(socket.assigns.script_editor_drafts, script_id, normalize_script_params(params)))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def save_script(socket, reload, append_output) do
+ %{id: script_id} = socket.assigns.current_tab
+
+ case Repo.get(Script, script_id) do
+ nil -> reload.(socket, socket.assigns.workbench)
+ %Script{} = script ->
+ draft = current_script_draft(socket.assigns, script)
+
+ case Scripting.validate(draft["content"] || "") do
+ :ok ->
+ case Scripts.update_script(script.id, script_attrs(draft)) do
+ {:ok, _updated} ->
+ socket
+ |> assign(:script_editor_drafts, Map.delete(socket.assigns.script_editor_drafts, script.id))
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Scripts"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Scripts"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+
+ def check_script(socket, reload, append_output) do
+ %{id: script_id} = socket.assigns.current_tab
+
+ case Repo.get(Script, script_id) do
+ nil -> reload.(socket, socket.assigns.workbench)
+ %Script{} = script ->
+ case Scripting.validate(current_script_draft(socket.assigns, script)["content"] || "") do
+ :ok -> append_output.(socket, translated("Scripts"), translated("Syntax is valid")) |> reload.(socket.assigns.workbench)
+ {:error, reason} -> append_output.(socket, translated("Scripts"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+
+ def run_script(socket, reload, append_output) do
+ %{id: script_id} = socket.assigns.current_tab
+
+ case Repo.get(Script, script_id) do
+ nil -> reload.(socket, socket.assigns.workbench)
+ %Script{} = script ->
+ draft = current_script_draft(socket.assigns, script)
+
+ case Scripting.execute_project_script(script.project_id, draft["content"] || "", draft["entrypoint"] || "main", []) do
+ {:ok, result} ->
+ socket
+ |> append_output.(translated("Scripts"), inspect(result))
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Scripts"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+
+ def delete_script(socket, reload, append_output) do
+ %{id: script_id} = socket.assigns.current_tab
+
+ case Scripts.delete_script(script_id) do
+ {:ok, _deleted} -> reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :scripts, script_id))
+ {:error, reason} -> append_output.(socket, translated("Scripts"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def update_template(socket, params, reload) do
+ %{id: template_id} = socket.assigns.current_tab
+
+ socket
+ |> assign(:template_editor_drafts, Map.put(socket.assigns.template_editor_drafts, template_id, normalize_template_params(params)))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def save_template(socket, reload, append_output) do
+ %{id: template_id} = socket.assigns.current_tab
+
+ case Repo.get(Template, template_id) do
+ nil -> reload.(socket, socket.assigns.workbench)
+ %Template{} = template ->
+ draft = current_template_draft(socket.assigns, template)
+
+ with {:ok, %{valid: true}} <- MCP.validate_template(draft["content"] || ""),
+ {:ok, _updated} <- Templates.update_template(template.id, template_attrs(draft)) do
+ socket
+ |> assign(:template_editor_drafts, Map.delete(socket.assigns.template_editor_drafts, template.id))
+ |> reload.(socket.assigns.workbench)
+ else
+ {:ok, %{valid: false, errors: errors}} -> append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error") |> reload.(socket.assigns.workbench)
+ {:error, reason} -> append_output.(socket, translated("Templates"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+
+ def validate_template(socket, reload, append_output) do
+ %{id: template_id} = socket.assigns.current_tab
+
+ case Repo.get(Template, template_id) do
+ nil -> reload.(socket, socket.assigns.workbench)
+ %Template{} = template ->
+ case MCP.validate_template(current_template_draft(socket.assigns, template)["content"] || "") do
+ {:ok, %{valid: true}} -> append_output.(socket, translated("Templates"), translated("Template syntax is valid")) |> reload.(socket.assigns.workbench)
+ {:ok, %{valid: false, errors: errors}} -> append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error") |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+
+ def delete_template(socket, reload, append_output) do
+ %{id: template_id} = socket.assigns.current_tab
+
+ case Templates.delete_template(template_id, force: true) do
+ {:ok, _deleted} -> reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :templates, template_id))
+ {:error, reason} -> append_output.(socket, translated("Templates"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do
+ case Repo.get(Script, script_id) do
+ nil -> nil
+ %Script{} = script ->
+ draft = current_script_draft(assigns, script)
+ %{
+ id: script.id,
+ title: draft["title"],
+ slug: draft["slug"],
+ kind: draft["kind"],
+ entrypoint: draft["entrypoint"],
+ enabled: draft["enabled"],
+ content: draft["content"],
+ entrypoints: discover_entrypoints(draft["content"]),
+ created_at: script.created_at,
+ updated_at: script.updated_at
+ }
+ end
+ end
+
+ def build_script(_assigns), do: nil
+
+ def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do
+ case Repo.get(Template, template_id) do
+ nil -> nil
+ %Template{} = template ->
+ draft = current_template_draft(assigns, template)
+ %{
+ id: template.id,
+ title: draft["title"],
+ slug: draft["slug"],
+ kind: draft["kind"],
+ enabled: draft["enabled"],
+ content: draft["content"],
+ created_at: template.created_at,
+ updated_at: template.updated_at
+ }
+ end
+ end
+
+ def build_template(_assigns), do: nil
+
+ def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
+
+ def format_timestamp(nil), do: ""
+ def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)
+
+ defp normalize_script_params(params) do
+ %{
+ "title" => Map.get(params, "title", ""),
+ "slug" => Map.get(params, "slug", ""),
+ "kind" => Map.get(params, "kind", "utility"),
+ "entrypoint" => Map.get(params, "entrypoint", "main"),
+ "enabled" => Map.get(params, "enabled") in [true, "true", "on", "1", 1],
+ "content" => Map.get(params, "content", "")
+ }
+ end
+
+ defp normalize_template_params(params) do
+ %{
+ "title" => Map.get(params, "title", ""),
+ "slug" => Map.get(params, "slug", ""),
+ "kind" => Map.get(params, "kind", "post"),
+ "enabled" => Map.get(params, "enabled") in [true, "true", "on", "1", 1],
+ "content" => Map.get(params, "content", "")
+ }
+ end
+
+ defp current_script_draft(assigns, %Script{} = script) do
+ Map.get(assigns.script_editor_drafts, script.id, %{
+ "title" => script.title || "",
+ "slug" => script.slug || "",
+ "kind" => to_string(script.kind || :utility),
+ "entrypoint" => script.entrypoint || "main",
+ "enabled" => script.enabled != false,
+ "content" => script.content || ""
+ })
+ end
+
+ defp current_template_draft(assigns, %Template{} = template) do
+ Map.get(assigns.template_editor_drafts, template.id, %{
+ "title" => template.title || "",
+ "slug" => template.slug || "",
+ "kind" => to_string(template.kind || :post),
+ "enabled" => template.enabled != false,
+ "content" => template.content || ""
+ })
+ end
+
+ defp script_attrs(draft) do
+ %{
+ title: draft["title"],
+ slug: draft["slug"],
+ kind: String.to_existing_atom(draft["kind"]),
+ entrypoint: draft["entrypoint"],
+ enabled: draft["enabled"],
+ content: draft["content"]
+ }
+ rescue
+ _error -> %{title: draft["title"], slug: draft["slug"], kind: :utility, entrypoint: draft["entrypoint"], enabled: draft["enabled"], content: draft["content"]}
+ end
+
+ defp template_attrs(draft) do
+ %{title: draft["title"], slug: draft["slug"], kind: normalize_template_kind(draft["kind"]), enabled: draft["enabled"], content: draft["content"]}
+ end
+
+ defp normalize_template_kind("post"), do: :post
+ defp normalize_template_kind("list"), do: :list
+ defp normalize_template_kind("not-found"), do: :"not-found"
+ defp normalize_template_kind("partial"), do: :partial
+ defp normalize_template_kind(_kind), do: :post
+
+ defp discover_entrypoints(content) do
+ ["main" | Regex.scan(~r/function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/, content || "", capture: :all_but_first)
+ |> List.flatten()
+ |> Enum.reject(&(&1 == "main"))]
+ end
+end
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex b/lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex
new file mode 100644
index 0000000..55ec159
--- /dev/null
+++ b/lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
<%= @script_editor.content %>
+
+
+
+
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex b/lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex
new file mode 100644
index 0000000..c9edfe9
--- /dev/null
+++ b/lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
<%= @template_editor.content %>
+
+
+
+
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex
index ac6a8cb..620a39b 100644
--- a/lib/bds/desktop/shell_live/index.html.heex
+++ b/lib/bds/desktop/shell_live/index.html.heex
@@ -43,12 +43,12 @@
>
<%= for item <- titlebar_menu_dropdown_items(group) do %>
<%= if item.separator do %>
-
+
<% else %>
<% else %>
- <%= if @current_tab.type == :post and @post_editor do %>
-
- <% else %>
-
-
- <%= tab_route_label(@current_tab) %>
- <%= tab_title(@current_tab, @tab_meta) %>
- <%= tab_subtitle(@current_tab, @tab_meta) %>
+ <%= cond do %>
+ <% @current_tab.type == :post and @post_editor -> %>
+
- <%= render_editor_toolbar(assigns) %>
+ <% @current_tab.type == :media and @media_editor -> %>
+
-
-
<%= tab_title(@current_tab, @tab_meta) %>
-
Desktop workbench content routed through the Elixir shell.
-
-
+ <% @current_tab.type == :settings and @settings_editor -> %>
+
-
-
+ <% @current_tab.type == :style and @style_editor -> %>
+
+
+ <% @current_tab.type == :tags and @tags_editor -> %>
+
+
+ <% @current_tab.type == :scripts and @script_editor -> %>
+
+
+ <% @current_tab.type == :templates and @template_editor -> %>
+
+
+ <% @current_tab.type == :chat and @chat_editor -> %>
+
+
+ <% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] and @misc_editor -> %>
+
+
+ <% true -> %>
+
+
+ <%= tab_route_label(@current_tab) %>
+ <%= tab_title(@current_tab, @tab_meta) %>
+ <%= tab_subtitle(@current_tab, @tab_meta) %>
+
+ <%= render_editor_toolbar(assigns) %>
+
+
+
<%= tab_title(@current_tab, @tab_meta) %>
+
Desktop workbench content routed through the Elixir shell.
+
+
+
+
+
<% end %>
<% end %>
diff --git a/lib/bds/desktop/shell_live/media_editor.ex b/lib/bds/desktop/shell_live/media_editor.ex
new file mode 100644
index 0000000..f0decfc
--- /dev/null
+++ b/lib/bds/desktop/shell_live/media_editor.ex
@@ -0,0 +1,570 @@
+defmodule BDS.Desktop.ShellLive.MediaEditor do
+ @moduledoc false
+
+ use Phoenix.Component
+
+ import Ecto.Query
+
+ alias BDS.Desktop.{FilePicker, ShellData}
+ alias BDS.{AI, I18n, Media, Repo}
+ alias BDS.Media.Media, as: MediaRecord
+ alias BDS.Media.Translation
+ alias BDS.Posts.Post
+ alias BDS.UI.Workbench
+
+ embed_templates "media_editor_html/*"
+
+ @post_picker_limit 10
+
+ def assign_socket(socket) do
+ assign(socket, :media_editor, build(socket.assigns))
+ end
+
+ def update(socket, params, reload) do
+ case socket.assigns.current_tab do
+ %{type: :media, id: media_id} ->
+ case Repo.get(MediaRecord, media_id) do
+ nil ->
+ socket
+
+ %MediaRecord{} = media ->
+ draft = normalize_params(params)
+ socket |> reconcile_draft(media, draft) |> reload_with_assigned_workbench(reload)
+ end
+
+ _other ->
+ socket
+ end
+ end
+
+ def persist_socket(socket, media_id, reload, append_output) do
+ case Repo.get(MediaRecord, 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
+ end
+
+ def toggle_quick_actions(socket, media_id, reload) do
+ workbench = socket.assigns.workbench
+
+ socket
+ |> assign(:media_editor_quick_actions_open, Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1)))
+ |> reload.(workbench)
+ end
+
+ def replace_file(socket, media_id, reload, append_output) do
+ case FilePicker.choose_file(translated("Replace Media File")) do
+ {:ok, source_path} ->
+ 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
+ |> 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)
+
+ {:ok, nil} ->
+ socket |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Replace File"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+
+ :cancel ->
+ socket
+
+ {:error, %{message: message}} ->
+ socket
+ |> append_output.(translated("Replace File"), message, nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def detect_language(socket, media_id, reload, append_output) do
+ if Map.get(socket.assigns, :offline_mode, true) do
+ socket
+ |> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
+ |> reload.(socket.assigns.workbench)
+ else
+ case Repo.get(MediaRecord, media_id) do
+ nil ->
+ socket
+
+ %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")
+
+ 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(current_draft(socket.assigns, media), "language", normalized)
+
+ 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
+
+ {: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
+
+ 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)
+ end
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Translate"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+
+ def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do
+ try do
+ case Repo.get(MediaRecord, media_id) do
+ nil ->
+ socket
+
+ %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
+ end
+
+ 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)
+
+ 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
+ end
+
+ def toggle_post_picker(socket, media_id, reload) do
+ workbench = socket.assigns.workbench
+
+ socket
+ |> assign(:media_editor_post_pickers_open, Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1)))
+ |> reload.(workbench)
+ end
+
+ 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
+
+ def link_post(socket, media_id, post_id, reload, append_output) 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)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Link to Post"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def unlink_post(socket, media_id, post_id, reload, append_output) do
+ case Media.unlink_media_from_post(media_id, post_id) do
+ {:ok, _unlinked} ->
+ socket |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Unlink from Post"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def edit_translation(socket, media_id, language, reload) do
+ workbench = socket.assigns.workbench
+
+ translation = Repo.get_by(Translation, translation_for: media_id, language: language)
+
+ form = %{
+ "language" => language,
+ "title" => translation && translation.title || "",
+ "alt" => translation && translation.alt || "",
+ "caption" => translation && translation.caption || ""
+ }
+
+ socket
+ |> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form))
+ |> reload.(workbench)
+ end
+
+ def update_translation(socket, media_id, params, reload) do
+ workbench = socket.assigns.workbench
+
+ form = %{
+ "language" => Map.get(params, "language", ""),
+ "title" => Map.get(params, "title", ""),
+ "alt" => Map.get(params, "alt", ""),
+ "caption" => Map.get(params, "caption", "")
+ }
+
+ socket
+ |> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form))
+ |> reload.(workbench)
+ end
+
+ def save_translation(socket, media_id, reload, append_output) do
+ case Map.get(socket.assigns.media_editor_translation_forms, media_id) do
+ %{"language" => language} = form when language not in [nil, ""] ->
+ 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)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Save Translation"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+
+ _other ->
+ socket
+ end
+ end
+
+ def refresh_translation(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
+ case AI.translate_media(media_id, normalize_language(language)) do
+ {:ok, translation} ->
+ case Media.upsert_media_translation(media_id, language, translation) do
+ {:ok, _saved_translation} -> socket |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Refresh Translation"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Refresh Translation"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+
+ def delete_translation(socket, media_id, language, reload, append_output) do
+ 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)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Delete Translation"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do
+ case Repo.get(MediaRecord, media_id) do
+ nil ->
+ 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, "")
+ {picker_results, picker_overflow_count} = post_picker_results(media, linked_posts, picker_query)
+
+ %{
+ id: media.id,
+ display_title: display_title(media),
+ original_name: media.original_name || media.filename || media.id,
+ mime_type: media.mime_type || "application/octet-stream",
+ file_size: format_file_size(media.size),
+ 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),
+ post_picker_query: picker_query,
+ post_picker_results: picker_results,
+ post_picker_overflow_count: picker_overflow_count,
+ form: form,
+ languages: language_codes(),
+ translations: Enum.map(translations, &translation_view/1),
+ editing_translation: Map.get(assigns.media_editor_translation_forms, media.id),
+ linked_posts: linked_posts,
+ can_detect_language?: detect_language_enabled?(form),
+ can_translate?: form["language"] not in [nil, ""]
+ }
+ end
+ end
+
+ def build(_assigns), do: nil
+
+ def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
+
+ 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")
+
+ def language_label(code) do
+ code
+ |> to_string()
+ |> String.upcase()
+ end
+
+ def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase()
+
+ def persist(%MediaRecord{} = media, draft) do
+ Media.update_media(media.id, %{
+ title: blank_to_nil(Map.get(draft, "title")),
+ alt: blank_to_nil(Map.get(draft, "alt")),
+ caption: blank_to_nil(Map.get(draft, "caption")),
+ author: blank_to_nil(Map.get(draft, "author")),
+ language: blank_to_nil(Map.get(draft, "language")),
+ tags: csv_to_list(Map.get(draft, "tags"))
+ })
+ 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,
+ flag: I18n.flag(translation.language),
+ title: translation.title,
+ alt: translation.alt,
+ caption: translation.caption
+ }
+ end
+
+ defp post_picker_results(%MediaRecord{} = media, linked_posts, query) do
+ linked_ids = MapSet.new(Enum.map(linked_posts, & &1.post_id))
+ normalized_query = normalize_query(query)
+
+ posts =
+ Repo.all(
+ from post in Post,
+ where: post.project_id == ^media.project_id,
+ order_by: [desc: post.updated_at, desc: post.created_at],
+ select: %{post_id: post.id, title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id)}
+ )
+ |> Enum.reject(&MapSet.member?(linked_ids, &1.post_id))
+ |> Enum.filter(fn post -> normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query) end)
+
+ {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}", else: nil
+ end
+
+ defp image?(%MediaRecord{} = media), do: String.starts_with?(to_string(media.mime_type || ""), "image/")
+
+ defp display_title(%MediaRecord{} = media), do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id
+
+ defp dimensions_label(%MediaRecord{width: width, height: height}) when is_integer(width) and is_integer(height), do: "#{width} x #{height}"
+ defp dimensions_label(_media), do: nil
+
+ defp format_file_size(size) when is_integer(size) and size >= 1_048_576, do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB"
+ defp format_file_size(size) when is_integer(size), do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
+ defp format_file_size(_size), do: "0.0 KB"
+
+ defp detect_language_enabled?(form) do
+ [Map.get(form, "title"), Map.get(form, "alt"), Map.get(form, "caption")]
+ |> Enum.any?(&(blank_to_nil(&1) != nil))
+ end
+
+ defp language_codes do
+ I18n.supported_languages()
+ |> Enum.map(& &1.code)
+ end
+
+ defp normalize_query(value) do
+ value
+ |> to_string()
+ |> String.trim()
+ |> String.downcase()
+ end
+
+ defp csv_to_list(value) do
+ value
+ |> to_string()
+ |> String.split(",")
+ |> Enum.map(&String.trim/1)
+ |> Enum.reject(&(&1 == ""))
+ end
+
+ defp blank_to_nil(value) do
+ value
+ |> to_string()
+ |> String.trim()
+ |> case do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+
+ defp reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench)
+end
\ No newline at end of file
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
new file mode 100644
index 0000000..a85edc0
--- /dev/null
+++ b/lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex
@@ -0,0 +1,303 @@
+
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/misc_editor.ex b/lib/bds/desktop/shell_live/misc_editor.ex
new file mode 100644
index 0000000..1b24f5b
--- /dev/null
+++ b/lib/bds/desktop/shell_live/misc_editor.ex
@@ -0,0 +1,256 @@
+defmodule BDS.Desktop.ShellLive.MiscEditor do
+ @moduledoc false
+
+ use Phoenix.Component
+
+ alias BDS.{Embeddings, Generation, Git}
+ alias BDS.Desktop.ShellData
+
+ embed_templates "misc_editor_html/*"
+
+ @misc_routes [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff]
+
+ def assign_socket(socket) do
+ assign(socket, :misc_editor, build(socket.assigns))
+ end
+
+ def rerun(socket) do
+ case meta(socket.assigns) do
+ %{action: action} when is_binary(action) -> {:command, action}
+ _other -> {:noop, socket}
+ end
+ end
+
+ def apply_site_validation(socket, append_output) do
+ meta = meta(socket.assigns)
+ payload = Map.get(meta, :payload, %{})
+ project_id = Map.get(meta, :project_id, socket.assigns.projects.active_project_id)
+ sections = Enum.map(Map.get(payload, :sections, []), &String.to_existing_atom/1)
+
+ case Generation.apply_validation(project_id, sections) do
+ {:ok, result} ->
+ {:rerun,
+ socket
+ |> append_output.(translated("Site Validation"), translated("Validation changes applied"), inspect(result))}
+
+ {:error, reason} -> {:socket, append_output.(socket, translated("Site Validation"), inspect(reason), nil, "error")}
+ end
+ rescue
+ error -> {:socket, append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")}
+ end
+
+ def toggle_duplicate(socket, pair_id, reload) do
+ selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{})
+ current = Map.get(selected_by_tab, socket.assigns.current_tab.id, MapSet.new())
+
+ next =
+ if MapSet.member?(current, pair_id) do
+ MapSet.delete(current, pair_id)
+ else
+ MapSet.put(current, pair_id)
+ end
+
+ socket
+ |> assign(:misc_editor_selected_pairs, Map.put(selected_by_tab, socket.assigns.current_tab.id, next))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def dismiss_duplicate(socket, post_id_a, post_id_b, reload, append_output) do
+ case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do
+ {:ok, _saved_pair} ->
+ socket
+ |> update_payload(fn payload ->
+ update_in(payload[:pairs], fn pairs ->
+ Enum.reject(pairs || [], fn pair -> pair_identity(pair) == pair_id(post_id_a, post_id_b) end)
+ end)
+ end)
+ |> clear_selected_pair(pair_id(post_id_a, post_id_b))
+ |> append_output.(translated("Find Duplicates"), translated("Pair dismissed"))
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Find Duplicates"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def dismiss_selected(socket, reload, append_output) do
+ tab_id = socket.assigns.current_tab.id
+ selected =
+ socket.assigns.misc_editor_selected_pairs
+ |> Map.get(tab_id, MapSet.new())
+ |> MapSet.to_list()
+ |> Enum.map(&decode_pair_id/1)
+ |> Enum.reject(&is_nil/1)
+
+ case Embeddings.dismiss_duplicate_pairs(selected) do
+ {:ok, _saved_pairs} ->
+ socket
+ |> update_payload(fn payload ->
+ update_in(payload[:pairs], fn pairs ->
+ Enum.reject(pairs || [], fn pair -> pair_identity(pair) in selected end)
+ end)
+ end)
+ |> assign(:misc_editor_selected_pairs, Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new()))
+ |> append_output.(translated("Find Duplicates"), translated("Selected pairs dismissed"))
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Find Duplicates"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do
+ meta = meta(assigns)
+ payload = Map.get(meta, :payload, %{})
+
+ case type do
+ :site_validation -> build_site_validation(meta, payload)
+ :metadata_diff -> build_metadata_diff(meta, payload)
+ :translation_validation -> build_translation_validation(meta, payload)
+ :find_duplicates -> build_duplicates(assigns, meta, payload)
+ :git_diff -> build_git_diff(assigns, meta)
+ end
+ end
+
+ def build(_assigns), do: nil
+
+ def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
+
+ def misc_class(:site_validation), do: "site-validation-view"
+ def misc_class(:metadata_diff), do: "metadata-diff-view"
+ def misc_class(:translation_validation), do: "translation-validation-view"
+ def misc_class(:find_duplicates), do: "duplicates-view"
+ def misc_class(:git_diff), do: "git-diff-view"
+
+ def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary)
+ def summary_items(_misc), do: []
+
+ def duplicate_checked?(misc, pair_id), do: MapSet.member?(misc.selected_pairs, pair_id)
+
+ def pair_id_from_pair(pair), do: pair_identity(pair)
+
+ defp build_site_validation(meta, payload) do
+ summary = Map.get(payload, :summary, %{})
+
+ %{
+ kind: :site_validation,
+ title: Map.get(meta, :title, translated("Site Validation")),
+ subtitle: Map.get(meta, :subtitle, ""),
+ summary: %{
+ expected: Map.get(summary, :missing_count, 0) + Map.get(summary, :extra_count, 0) + Map.get(summary, :stale_count, 0),
+ missing: Map.get(summary, :missing_count, 0),
+ extra: Map.get(summary, :extra_count, 0),
+ stale: Map.get(summary, :stale_count, 0)
+ },
+ missing_pages: Map.get(payload, :missing_pages, []),
+ extra_pages: Map.get(payload, :extra_pages, []),
+ stale_pages: Map.get(payload, :stale_pages, []),
+ sections: Map.get(payload, :sections, [])
+ }
+ end
+
+ defp build_metadata_diff(meta, payload) do
+ items = Map.get(payload, :diff_reports, [])
+
+ %{
+ kind: :metadata_diff,
+ title: Map.get(meta, :title, translated("Metadata Diff")),
+ subtitle: Map.get(meta, :subtitle, ""),
+ summary: Map.get(payload, :summary, %{}),
+ field_summaries: field_summaries(items),
+ items: items,
+ orphan_files: Map.get(payload, :orphan_reports, [])
+ }
+ end
+
+ defp build_translation_validation(meta, payload) do
+ %{
+ kind: :translation_validation,
+ title: Map.get(meta, :title, translated("Translation Validation")),
+ subtitle: Map.get(meta, :subtitle, ""),
+ summary: Map.get(payload, :summary, %{}),
+ missing: Map.get(payload, :missing, []),
+ orphan_files: Map.get(payload, :orphan_files, []),
+ do_not_translate_posts: Map.get(payload, :do_not_translate_posts, [])
+ }
+ end
+
+ defp build_duplicates(assigns, meta, payload) do
+ selected_pairs = Map.get(assigns.misc_editor_selected_pairs, assigns.current_tab.id, MapSet.new())
+
+ %{
+ kind: :find_duplicates,
+ title: Map.get(meta, :title, translated("Find Duplicates")),
+ subtitle: Map.get(meta, :subtitle, ""),
+ summary: Map.get(payload, :summary, %{}),
+ pairs: Map.get(payload, :pairs, []),
+ selected_pairs: selected_pairs
+ }
+ end
+
+ defp build_git_diff(assigns, meta) do
+ diff_text =
+ case Git.diff(assigns.projects.active_project_id) do
+ {:ok, %{staged_diff: staged, unstaged_diff: unstaged}} ->
+ [
+ "# Staged Changes\n\n",
+ if(String.trim(staged) == "", do: translated("No staged changes"), else: staged),
+ "\n\n# Working Tree\n\n",
+ if(String.trim(unstaged) == "", do: translated("No unstaged changes"), else: unstaged)
+ ]
+ |> IO.iodata_to_binary()
+
+ {:error, reason} -> inspect(reason)
+ end
+
+ %{
+ kind: :git_diff,
+ title: Map.get(meta, :title, translated("Git Diff")),
+ subtitle: Map.get(meta, :subtitle, ""),
+ diff_text: diff_text,
+ summary: %{}
+ }
+ end
+
+ defp meta(assigns) do
+ Map.get(assigns.tab_meta, {assigns.current_tab.type, assigns.current_tab.id}, %{})
+ end
+
+ defp update_payload(socket, updater) do
+ key = {socket.assigns.current_tab.type, socket.assigns.current_tab.id}
+ meta = Map.get(socket.assigns.tab_meta, key, %{})
+ next_meta = Map.update(meta, :payload, %{}, updater)
+ assign(socket, :tab_meta, Map.put(socket.assigns.tab_meta, key, next_meta))
+ end
+
+ defp clear_selected_pair(socket, pair_id) do
+ tab_id = socket.assigns.current_tab.id
+ current = Map.get(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new())
+ next_pairs = Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.delete(current, pair_id))
+ assign(socket, :misc_editor_selected_pairs, next_pairs)
+ end
+
+ defp pair_id(post_id_a, post_id_b), do: Enum.sort([post_id_a, post_id_b]) |> Enum.join("::")
+ defp pair_identity(pair), do: pair_id(Map.get(pair, :post_id_a) || Map.get(pair, "post_id_a"), Map.get(pair, :post_id_b) || Map.get(pair, "post_id_b"))
+
+ defp decode_pair_id(encoded) when is_binary(encoded) do
+ case String.split(encoded, "::", parts: 2) do
+ [post_id_a, post_id_b] -> {post_id_a, post_id_b}
+ _other -> nil
+ end
+ end
+
+ defp decode_pair_id(_encoded), do: nil
+
+ defp field_summaries(items) do
+ items
+ |> Enum.flat_map(fn item -> Map.get(item, :differences) || Map.get(item, "differences") || [] end)
+ |> Enum.group_by(fn diff -> Map.get(diff, :field) || Map.get(diff, "field") end)
+ |> Enum.map(fn {field, diffs} -> %{field_name: field, diff_count: length(diffs)} end)
+ |> Enum.sort_by(&{&1.diff_count * -1, &1.field_name})
+ end
+end
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex b/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex
new file mode 100644
index 0000000..ea8c7cb
--- /dev/null
+++ b/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex
@@ -0,0 +1,65 @@
+
+
+
+
+ <%= for {label, value} <- summary_items(@misc_editor) do %>
+
<%= label %> <%= value %>
+ <% end %>
+
+
+
+ <%= case @misc_editor.kind do %>
+ <% :site_validation -> %>
+
+
<%= translated("Missing URLs") %> <%= if Enum.empty?(@misc_editor.missing_pages) do %><%= translated("None found") %>
<% end %><%= for path <- @misc_editor.missing_pages do %><%= path %> <% end %>
+
<%= translated("Extra URLs") %> <%= if Enum.empty?(@misc_editor.extra_pages) do %><%= translated("None found") %>
<% end %><%= for path <- @misc_editor.extra_pages do %><%= path %> <% end %>
+
<%= translated("Updated URLs") %> <%= if Enum.empty?(@misc_editor.stale_pages) do %><%= translated("None found") %>
<% end %><%= for path <- @misc_editor.stale_pages do %><%= path %> <% end %>
+
+
+ <% :metadata_diff -> %>
+
+
<%= translated("Field Summary") %> <%= for field <- @misc_editor.field_summaries do %><%= field.field_name %> <%= field.diff_count %> <% end %>
+
<%= translated("Diff Items") %> <%= for item <- @misc_editor.items do %>
<%= Map.get(item, :entity_type) || Map.get(item, "entity_type") %> <%= Map.get(item, :entity_id) || Map.get(item, "entity_id") %> <%= for diff <- Map.get(item, :differences) || Map.get(item, "differences") || [] do %><%= Map.get(diff, :field) || Map.get(diff, "field") %> <%= inspect(Map.get(diff, :db_value) || Map.get(diff, "db_value")) %> <%= inspect(Map.get(diff, :file_value) || Map.get(diff, "file_value")) %> <% end %> <% end %>
+
<%= translated("Orphan Files") %> <%= for orphan <- @misc_editor.orphan_files do %><%= inspect(orphan) %> <% end %>
+
+
+ <% :translation_validation -> %>
+
+
<%= translated("Missing") %> <%= for issue <- @misc_editor.missing do %><%= inspect(issue) %> <% end %>
+
<%= translated("Orphan Files") %> <%= for file <- @misc_editor.orphan_files do %><%= file %> <% end %>
+
<%= translated("Do Not Translate") %> <%= for post <- @misc_editor.do_not_translate_posts do %><%= inspect(post) %> <% end %>
+
+
+ <% :find_duplicates -> %>
+
+ <%= for pair <- @misc_editor.pairs do %>
+
+
+ <%= Map.get(pair, :title_a) || Map.get(pair, "title_a") %>
+ →
+ <%= Map.get(pair, :title_b) || Map.get(pair, "title_b") %>
+ <%= if(Map.get(pair, :exact_match) || Map.get(pair, "exact_match"), do: translated("Exact Match"), else: "#{Float.round((Map.get(pair, :similarity) || Map.get(pair, "similarity") || 0.0) * 100, 1)}%") %>
+ <%= translated("Dismiss") %>
+
+ <% end %>
+
+
+ <% :git_diff -> %>
+
<%= @misc_editor.diff_text %>
+ <% end %>
+
+
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/overlay_components.ex b/lib/bds/desktop/shell_live/overlay_components.ex
index a327f2c..905c24a 100644
--- a/lib/bds/desktop/shell_live/overlay_components.ex
+++ b/lib/bds/desktop/shell_live/overlay_components.ex
@@ -8,6 +8,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
alias BDS.Desktop.ShellData
alias BDS.{I18n, Metadata, Repo}
alias BDS.Media.Media
+ alias BDS.Media.Translation, as: MediaTranslation
alias BDS.Posts.{Post, Translation}
alias BDS.Tags.Tag
@@ -132,6 +133,17 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
_error -> %{}
end
+ defp existing_translations(%{type: :media, id: media_id}) do
+ Repo.all(
+ from translation in MediaTranslation,
+ where: translation.translation_for == ^media_id,
+ select: {translation.language, "draft"}
+ )
+ |> Map.new(fn {language, status} -> {language, status} end)
+ rescue
+ _error -> %{}
+ end
+
defp existing_translations(_tab), do: %{}
defp blog_languages(metadata) do
@@ -149,6 +161,15 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
_error -> metadata.main_language || "en"
end
+ defp source_language(%{type: :media, id: media_id}, metadata) do
+ case Repo.get(Media, media_id) do
+ %Media{language: language} when is_binary(language) and language != "" -> language
+ _other -> metadata.main_language || "en"
+ end
+ rescue
+ _error -> metadata.main_language || "en"
+ end
+
defp source_language(_tab, metadata), do: metadata.main_language || "en"
defp language_names do
diff --git a/lib/bds/desktop/shell_live/settings_editor.ex b/lib/bds/desktop/shell_live/settings_editor.ex
new file mode 100644
index 0000000..2b784cd
--- /dev/null
+++ b/lib/bds/desktop/shell_live/settings_editor.ex
@@ -0,0 +1,409 @@
+defmodule BDS.Desktop.ShellLive.SettingsEditor do
+ @moduledoc false
+
+ use Phoenix.Component
+
+ alias BDS.Metadata
+ alias BDS.Desktop.ShellData
+
+ embed_templates "settings_editor_html/*"
+
+ @themes [
+ "default",
+ "amber",
+ "blue",
+ "cyan",
+ "fuchsia",
+ "green",
+ "grey",
+ "indigo",
+ "jade",
+ "lime",
+ "orange",
+ "pink",
+ "pumpkin",
+ "purple",
+ "red",
+ "sand",
+ "slate",
+ "violet",
+ "yellow",
+ "zinc"
+ ]
+
+ @supported_languages ["en", "de", "fr", "it", "es"]
+ @protected_categories MapSet.new(["article", "aside", "page", "picture"])
+
+ def assign_socket(socket) do
+ case socket.assigns[:current_tab] do
+ %{type: :settings} ->
+ socket
+ |> assign(:settings_editor, build_settings(socket.assigns))
+ |> assign(:style_editor, nil)
+
+ %{type: :style} ->
+ socket
+ |> assign(:settings_editor, nil)
+ |> assign(:style_editor, build_style(socket.assigns))
+
+ _other ->
+ socket
+ |> assign(:settings_editor, nil)
+ |> assign(:style_editor, nil)
+ end
+ end
+
+ def update_search(socket, query, reload) do
+ socket
+ |> assign(:settings_editor_search, to_string(query || ""))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def update_project_draft(socket, params, reload) do
+ socket
+ |> assign(:settings_editor_project_draft, normalize_project_params(params))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def save_project(socket, reload, append_output) do
+ project_id = socket.assigns.projects.active_project_id
+
+ case Metadata.update_project_metadata(project_id, project_attrs(socket.assigns)) do
+ {:ok, _metadata} ->
+ socket
+ |> assign(:settings_editor_project_draft, %{})
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Settings"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def update_publishing_draft(socket, params, reload) do
+ socket
+ |> assign(:settings_editor_publishing_draft, normalize_publishing_params(params))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def save_publishing(socket, reload, append_output) do
+ project_id = socket.assigns.projects.active_project_id
+
+ case Metadata.set_publishing_preferences(project_id, publishing_attrs(socket.assigns)) do
+ {:ok, _metadata} ->
+ socket
+ |> assign(:settings_editor_publishing_draft, %{})
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Publishing"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def clear_publishing(socket, reload, append_output) do
+ project_id = socket.assigns.projects.active_project_id
+
+ case Metadata.set_publishing_preferences(project_id, %{}) do
+ {:ok, _metadata} ->
+ socket
+ |> assign(:settings_editor_publishing_draft, %{})
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Publishing"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def update_new_category(socket, name, reload) do
+ socket
+ |> assign(:settings_editor_new_category, to_string(name || ""))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def add_category(socket, reload, append_output) do
+ project_id = socket.assigns.projects.active_project_id
+ name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim()
+
+ cond do
+ name == "" ->
+ socket
+ |> append_output.(translated("Categories"), translated("Category name is required"), nil, "error")
+ |> reload.(socket.assigns.workbench)
+
+ true ->
+ case Metadata.add_category(project_id, name) do
+ {:ok, _metadata} ->
+ socket
+ |> assign(:settings_editor_new_category, "")
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Categories"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+
+ def save_category(socket, params, reload, append_output) do
+ project_id = socket.assigns.projects.active_project_id
+ category = Map.get(params, "category", "")
+
+ settings = %{
+ title: blank_to_nil(Map.get(params, "title")),
+ render_in_lists: truthy?(Map.get(params, "render_in_lists")),
+ show_title: truthy?(Map.get(params, "show_title")),
+ post_template_slug: blank_to_nil(Map.get(params, "post_template_slug")),
+ list_template_slug: blank_to_nil(Map.get(params, "list_template_slug"))
+ }
+
+ case Metadata.update_category_settings(project_id, category, settings) do
+ {:ok, _metadata} -> reload.(socket, socket.assigns.workbench)
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Categories"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def remove_category(socket, category, reload, append_output) do
+ project_id = socket.assigns.projects.active_project_id
+
+ cond do
+ MapSet.member?(@protected_categories, category) ->
+ socket
+ |> append_output.(translated("Categories"), translated("Protected categories cannot be removed"), nil, "error")
+ |> reload.(socket.assigns.workbench)
+
+ true ->
+ case Metadata.remove_category(project_id, category) do
+ {:ok, _metadata} -> reload.(socket, socket.assigns.workbench)
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Categories"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+
+ def select_style_theme(socket, theme, reload) do
+ socket
+ |> assign(:style_editor_theme, to_string(theme || "default"))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def change_style_preview_mode(socket, mode, reload) do
+ socket
+ |> assign(:style_editor_preview_mode, to_string(mode || "auto"))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def apply_style_theme(socket, reload, append_output) do
+ project_id = socket.assigns.projects.active_project_id
+ theme = socket.assigns[:style_editor_theme] || current_theme(socket.assigns)
+
+ case Metadata.update_project_metadata(project_id, %{pico_theme: theme}) do
+ {:ok, _metadata} -> reload.(socket, socket.assigns.workbench)
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Style"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def build_settings(%{projects: %{active_project_id: nil}}), do: nil
+
+ def build_settings(assigns) do
+ metadata = project_metadata(assigns)
+ project_form = Map.merge(project_form(metadata), Map.get(assigns, :settings_editor_project_draft, %{}))
+ publishing_form = Map.merge(publishing_form(metadata), Map.get(assigns, :settings_editor_publishing_draft, %{}))
+ query = Map.get(assigns, :settings_editor_search, "")
+
+ %{
+ search_query: query,
+ project: project_form,
+ categories: category_rows(metadata),
+ publishing: publishing_form,
+ new_category: Map.get(assigns, :settings_editor_new_category, ""),
+ project_visible?: section_matches?(query, ~w(project name description url language author category posts bookmarklet)),
+ content_visible?: section_matches?(query, ~w(content categories templates lists blogmark)),
+ publishing_visible?: section_matches?(query, ~w(publishing ssh scp rsync host user remote path)),
+ data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)),
+ supported_languages: @supported_languages,
+ protected_categories: @protected_categories
+ }
+ end
+
+ def build_style(%{projects: %{active_project_id: nil}}), do: nil
+
+ def build_style(assigns) do
+ selected_theme = Map.get(assigns, :style_editor_theme) || current_theme(assigns)
+ preview_mode = Map.get(assigns, :style_editor_preview_mode, "auto")
+
+ %{
+ themes: Enum.map(@themes, &style_theme/1),
+ selected_theme: selected_theme,
+ applied_theme: current_theme(assigns),
+ preview_mode: preview_mode,
+ preview_url: "http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
+ }
+ end
+
+ def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
+
+ def protected_category?(category), do: MapSet.member?(@protected_categories, category)
+
+ def theme_display_name(theme) do
+ theme
+ |> to_string()
+ |> String.replace("-", " ")
+ |> String.capitalize()
+ end
+
+ defp project_attrs(assigns) do
+ draft = Map.get(assigns, :settings_editor_project_draft, %{})
+
+ %{
+ name: blank_to_nil(Map.get(draft, "name")),
+ description: blank_to_nil(Map.get(draft, "description")),
+ public_url: blank_to_nil(Map.get(draft, "public_url")),
+ main_language: blank_to_nil(Map.get(draft, "main_language")),
+ default_author: blank_to_nil(Map.get(draft, "default_author")),
+ max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50),
+ blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")),
+ blog_languages: Map.get(draft, "blog_languages", []),
+ semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled"))
+ }
+ end
+
+ defp publishing_attrs(assigns) do
+ draft = Map.get(assigns, :settings_editor_publishing_draft, %{})
+
+ %{
+ ssh_host: blank_to_nil(Map.get(draft, "ssh_host")),
+ ssh_user: blank_to_nil(Map.get(draft, "ssh_user")),
+ ssh_remote_path: blank_to_nil(Map.get(draft, "ssh_remote_path")),
+ ssh_mode: Map.get(draft, "ssh_mode", "scp")
+ }
+ end
+
+ defp project_metadata(assigns) do
+ case Metadata.get_project_metadata(assigns.projects.active_project_id) do
+ {:ok, metadata} -> metadata
+ _other -> %{}
+ end
+ end
+
+ defp project_form(metadata) do
+ %{
+ "name" => Map.get(metadata, :name, ""),
+ "description" => Map.get(metadata, :description, ""),
+ "public_url" => Map.get(metadata, :public_url, ""),
+ "main_language" => Map.get(metadata, :main_language) || "en",
+ "default_author" => Map.get(metadata, :default_author, ""),
+ "max_posts_per_page" => Integer.to_string(Map.get(metadata, :max_posts_per_page, 50)),
+ "blogmark_category" => Map.get(metadata, :blogmark_category) || List.first(Map.get(metadata, :categories, [])) || "article",
+ "blog_languages" => Map.get(metadata, :blog_languages, []),
+ "semantic_similarity_enabled" => Map.get(metadata, :semantic_similarity_enabled, false)
+ }
+ end
+
+ defp publishing_form(metadata) do
+ prefs = Map.get(metadata, :publishing_preferences, %{})
+
+ %{
+ "ssh_host" => Map.get(prefs, "ssh_host", ""),
+ "ssh_user" => Map.get(prefs, "ssh_user", ""),
+ "ssh_remote_path" => Map.get(prefs, "ssh_remote_path", ""),
+ "ssh_mode" => Map.get(prefs, "ssh_mode", "scp")
+ }
+ end
+
+ defp current_theme(assigns) do
+ assigns
+ |> project_metadata()
+ |> Map.get(:pico_theme)
+ |> case do
+ nil -> "default"
+ "" -> "default"
+ theme -> theme
+ end
+ end
+
+ defp category_rows(metadata) do
+ categories = Map.get(metadata, :categories, [])
+ settings = Map.get(metadata, :category_settings, %{})
+
+ Enum.map(categories, fn category ->
+ category_settings = Map.get(settings, category, %{})
+
+ %{
+ name: category,
+ title: Map.get(category_settings, "title") || category,
+ render_in_lists: Map.get(category_settings, "render_in_lists", true),
+ show_title: Map.get(category_settings, "show_title", true),
+ post_template_slug: Map.get(category_settings, "post_template_slug", ""),
+ list_template_slug: Map.get(category_settings, "list_template_slug", ""),
+ protected?: protected_category?(category)
+ }
+ end)
+ end
+
+ defp normalize_project_params(params) do
+ %{
+ "name" => Map.get(params, "name", ""),
+ "description" => Map.get(params, "description", ""),
+ "public_url" => Map.get(params, "public_url", ""),
+ "main_language" => Map.get(params, "main_language", "en"),
+ "default_author" => Map.get(params, "default_author", ""),
+ "max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"),
+ "blogmark_category" => Map.get(params, "blogmark_category", "article"),
+ "blog_languages" => List.wrap(Map.get(params, "blog_languages", [])),
+ "semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled"))
+ }
+ end
+
+ defp normalize_publishing_params(params) do
+ %{
+ "ssh_host" => Map.get(params, "ssh_host", ""),
+ "ssh_user" => Map.get(params, "ssh_user", ""),
+ "ssh_remote_path" => Map.get(params, "ssh_remote_path", ""),
+ "ssh_mode" => Map.get(params, "ssh_mode", "scp")
+ }
+ end
+
+ defp section_matches?("", _keywords), do: true
+ defp section_matches?(query, keywords), do: Enum.any?(keywords, &String.contains?(&1, String.downcase(query)))
+
+ defp style_theme(name) do
+ %{
+ name: name,
+ accent_color: "#4f46e5",
+ light_bg_color: "#f8fafc",
+ dark_bg_color: "#0f172a"
+ }
+ end
+
+ defp truthy?(value), do: value in [true, "true", "on", "1", 1]
+ defp parse_integer(nil, fallback), do: fallback
+ defp parse_integer(value, _fallback) when is_integer(value), do: value
+ defp parse_integer(value, fallback) do
+ case Integer.parse(to_string(value)) do
+ {parsed, _rest} -> parsed
+ :error -> fallback
+ end
+ end
+
+ defp blank_to_nil(nil), do: nil
+ defp blank_to_nil(value) do
+ case String.trim(to_string(value)) do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex
new file mode 100644
index 0000000..95631ad
--- /dev/null
+++ b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex
@@ -0,0 +1,145 @@
+
+
+
+
+
+ <%= if not @settings_editor.project_visible? and not @settings_editor.content_visible? and not @settings_editor.publishing_visible? and not @settings_editor.data_visible? do %>
+
+
<%= translated("No settings match the current search") %>
+
+ <% end %>
+
+ <%= if @settings_editor.project_visible? do %>
+
+
+
+
+
+ <%= translated("Project Name") %>
+
+
+
+
+
<%= translated("Description") %>
+
<%= @settings_editor.project["description"] %>
+
+
+
<%= translated("Public URL") %>
+
+
+
+
<%= translated("Main Language") %>
+
+
+ <%= for language <- @settings_editor.supported_languages do %>
+ <%= String.upcase(language) %>
+ <% end %>
+
+
+
+
+
<%= translated("Blog Languages") %>
+
+
+ <%= for language <- @settings_editor.supported_languages do %>
+
+
+ <%= String.upcase(language) %>
+
+ <% end %>
+
+
+
+
+
<%= translated("Default Author") %>
+
+
+
+
<%= translated("Max Posts Per Page") %>
+
+
+
+
<%= translated("Blogmark Category") %>
+
+
+ <%= for category <- Enum.map(@settings_editor.categories, & &1.name) do %>
+ <%= category %>
+ <% end %>
+
+
+
+
+
<%= translated("Semantic Similarity") %>
+
+ <%= translated("Enable semantic similarity") %>
+
+
+
+
<%= translated("Save") %>
+
+ <% end %>
+
+ <%= if @settings_editor.content_visible? do %>
+
+
+
+ <%= for category <- @settings_editor.categories do %>
+
+
+ <%= category.name %>
+
+
+ <% end %>
+
+
<%= translated("Add Category") %>
+
+
+
+ <%= translated("Add") %>
+
+
+
+
+
+ <% end %>
+
+ <%= if @settings_editor.publishing_visible? do %>
+
+
+
+ <%= translated("SSH Mode") %>
scp rsync
+
+
+
+
+
<%= translated("Save") %> <%= translated("Clear") %>
+
+ <% end %>
+
+ <%= if @settings_editor.data_visible? do %>
+
+
+
+ <%= translated("Rebuild Database") %>
+ <%= translated("Rebuild Embedding Index") %>
+ <%= translated("Open Data Folder") %>
+
+
+ <% end %>
+
+
+
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/settings_editor_html/style_editor.html.heex b/lib/bds/desktop/shell_live/settings_editor_html/style_editor.html.heex
new file mode 100644
index 0000000..a4b72ec
--- /dev/null
+++ b/lib/bds/desktop/shell_live/settings_editor_html/style_editor.html.heex
@@ -0,0 +1,37 @@
+
+
+
+
+ <%= for theme <- @style_editor.themes do %>
+
+
+
+
+
+
+
+ <%= theme_display_name(theme.name) %>
+
+
+ <% end %>
+
+
+
+
+ <%= translated("Preview Mode") %>
+
+ <%= translated("Auto") %>
+ <%= translated("Light") %>
+ <%= translated("Dark") %>
+
+
+ <%= translated("Apply Theme") %>
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/tags_editor.ex b/lib/bds/desktop/shell_live/tags_editor.ex
new file mode 100644
index 0000000..35d011c
--- /dev/null
+++ b/lib/bds/desktop/shell_live/tags_editor.ex
@@ -0,0 +1,246 @@
+defmodule BDS.Desktop.ShellLive.TagsEditor do
+ @moduledoc false
+
+ use Phoenix.Component
+
+ import Ecto.Query
+
+ alias BDS.Desktop.ShellData
+ alias BDS.{Repo, Tags}
+ alias BDS.Posts.Post
+ alias BDS.Tags.Tag
+ alias BDS.Templates.Template
+
+ embed_templates "tags_editor_html/*"
+
+ def assign_socket(socket) do
+ assign(socket, :tags_editor, build(socket.assigns))
+ end
+
+ def toggle_selection(socket, tag_name, reload) do
+ selected = Map.get(socket.assigns, :tags_editor_selected, [])
+
+ next_selected =
+ if tag_name in selected do
+ Enum.reject(selected, &(&1 == tag_name))
+ else
+ selected ++ [tag_name]
+ end
+
+ socket
+ |> assign(:tags_editor_selected, next_selected)
+ |> maybe_seed_edit_draft(next_selected)
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def update_new_tag(socket, params, reload) do
+ socket
+ |> assign(:tags_editor_new_tag, %{
+ "name" => Map.get(params, "name", ""),
+ "color" => Map.get(params, "color", "")
+ })
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def create_tag(socket, reload, append_output) do
+ project_id = socket.assigns.projects.active_project_id
+ draft = Map.get(socket.assigns, :tags_editor_new_tag, %{})
+
+ case Tags.create_tag(%{project_id: project_id, name: Map.get(draft, "name"), color: blank_to_nil(Map.get(draft, "color"))}) do
+ {:ok, _tag} ->
+ socket
+ |> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""})
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Tags"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ def update_edit_tag(socket, params, reload) do
+ socket
+ |> assign(:tags_editor_edit_draft, %{
+ "name" => Map.get(params, "name", ""),
+ "color" => Map.get(params, "color", ""),
+ "post_template_slug" => Map.get(params, "post_template_slug", "")
+ })
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def save_tag(socket, reload, append_output) do
+ selected = Map.get(socket.assigns, :tags_editor_selected, [])
+ draft = Map.get(socket.assigns, :tags_editor_edit_draft, %{})
+
+ case selected do
+ [tag_name] ->
+ case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do
+ nil -> reload.(socket, socket.assigns.workbench)
+ %Tag{} = tag ->
+ with {:ok, _updated_tag} <- Tags.update_tag(tag.id, %{color: blank_to_nil(Map.get(draft, "color")), post_template_slug: blank_to_nil(Map.get(draft, "post_template_slug"))}),
+ {:ok, renamed_tag} <- maybe_rename_tag(tag, Map.get(draft, "name", tag.name)) do
+ socket
+ |> assign(:tags_editor_selected, [renamed_tag.name])
+ |> maybe_seed_edit_draft([renamed_tag.name])
+ |> reload.(socket.assigns.workbench)
+ else
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Tags"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ _other -> reload.(socket, socket.assigns.workbench)
+ end
+ end
+
+ def delete_selected(socket, reload, append_output) do
+ case Map.get(socket.assigns, :tags_editor_selected, []) do
+ [tag_name] ->
+ case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do
+ nil -> reload.(socket, socket.assigns.workbench)
+ %Tag{} = tag ->
+ case Tags.delete_tag(tag.id) do
+ {:ok, _deleted} ->
+ socket
+ |> assign(:tags_editor_selected, [])
+ |> assign(:tags_editor_edit_draft, %{})
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Tags"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+
+ _other -> reload.(socket, socket.assigns.workbench)
+ end
+ end
+
+ def update_merge_target(socket, target, reload) do
+ socket
+ |> assign(:tags_editor_merge_target, to_string(target || ""))
+ |> reload.(socket.assigns.workbench)
+ end
+
+ def merge_selected(socket, reload, append_output) do
+ selected = Map.get(socket.assigns, :tags_editor_selected, [])
+ target_name = Map.get(socket.assigns, :tags_editor_merge_target, "")
+
+ cond do
+ length(selected) < 2 or target_name == "" ->
+ reload.(socket, socket.assigns.workbench)
+
+ true ->
+ project_id = socket.assigns.projects.active_project_id
+ tags = Repo.all(from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^selected)
+ target = Enum.find(tags, &(&1.name == target_name))
+ sources = Enum.reject(tags, &(&1.name == target_name))
+
+ case target do
+ nil -> reload.(socket, socket.assigns.workbench)
+ _target ->
+ case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do
+ {:ok, _merged} ->
+ socket
+ |> assign(:tags_editor_selected, [target.name])
+ |> assign(:tags_editor_merge_target, target.name)
+ |> maybe_seed_edit_draft([target.name])
+ |> reload.(socket.assigns.workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output.(translated("Tags"), inspect(reason), nil, "error")
+ |> reload.(socket.assigns.workbench)
+ end
+ end
+ end
+ end
+
+ def sync(socket, reload, append_output) do
+ _ = append_output
+ :ok = Tags.sync_tags_json(socket.assigns.projects.active_project_id)
+ reload.(socket, socket.assigns.workbench)
+ end
+
+ def build(%{current_tab: %{type: :tags}} = assigns) do
+ project_id = assigns.projects.active_project_id
+ tags = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name])
+ counts = tag_counts(project_id)
+ selected = Map.get(assigns, :tags_editor_selected, [])
+ edit_tag = if length(selected) == 1, do: Enum.find(tags, &(&1.name == hd(selected))), else: nil
+ edit_draft = Map.get(assigns, :tags_editor_edit_draft, edit_draft(edit_tag))
+ templates = Repo.all(from template in Template, where: template.project_id == ^project_id, order_by: [asc: template.title], select: %{slug: template.slug, title: template.title})
+
+ %{
+ tags: Enum.map(tags, fn tag -> %{name: tag.name, color: tag.color, count: Map.get(counts, tag.name, 0)} end),
+ selected: selected,
+ new_tag: Map.get(assigns, :tags_editor_new_tag, %{"name" => "", "color" => ""}),
+ edit_draft: edit_draft,
+ templates: templates,
+ merge_target: Map.get(assigns, :tags_editor_merge_target, List.first(selected) || "")
+ }
+ end
+
+ def build(_assigns), do: nil
+
+ def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
+
+ def tag_font_size(count, counts) do
+ max_count = Enum.max([1 | Enum.map(counts, & &1.count)])
+ ratio = if max_count <= 1, do: 0.0, else: (count - 1) / max(max_count - 1, 1)
+ Float.round(0.85 + (1.8 - 0.85) * ratio, 2)
+ end
+
+ def tag_style(tag, counts) do
+ size = tag_font_size(tag.count, counts)
+
+ [
+ "font-size: #{size}rem",
+ if(tag.color, do: "background-color: #{tag.color}"),
+ if(tag.color, do: "color: #ffffff")
+ ]
+ |> Enum.reject(&is_nil/1)
+ |> Enum.join("; ")
+ end
+
+ defp maybe_seed_edit_draft(socket, [tag_name]) do
+ case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do
+ %Tag{} = tag -> assign(socket, :tags_editor_edit_draft, edit_draft(tag))
+ _other -> assign(socket, :tags_editor_edit_draft, %{})
+ end
+ end
+
+ defp maybe_seed_edit_draft(socket, _selected), do: assign(socket, :tags_editor_edit_draft, %{})
+
+ defp edit_draft(nil), do: %{}
+ defp edit_draft(%Tag{} = tag), do: %{"name" => tag.name, "color" => tag.color || "", "post_template_slug" => tag.post_template_slug || ""}
+
+ defp maybe_rename_tag(%Tag{} = tag, next_name) do
+ normalized = String.trim(to_string(next_name || tag.name))
+
+ if normalized == tag.name do
+ {:ok, tag}
+ else
+ Tags.rename_tag(tag.id, normalized)
+ end
+ end
+
+ defp tag_counts(project_id) do
+ Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.tags)
+ |> List.flatten()
+ |> Enum.reject(&is_nil/1)
+ |> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
+ end
+
+ defp blank_to_nil(nil), do: nil
+ defp blank_to_nil(value) do
+ case String.trim(to_string(value)) do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/tags_editor_html/tags_editor.html.heex b/lib/bds/desktop/shell_live/tags_editor_html/tags_editor.html.heex
new file mode 100644
index 0000000..a82fac5
--- /dev/null
+++ b/lib/bds/desktop/shell_live/tags_editor_html/tags_editor.html.heex
@@ -0,0 +1,75 @@
+
\ No newline at end of file
diff --git a/lib/bds/media.ex b/lib/bds/media.ex
index 6fa4e28..24a0e96 100644
--- a/lib/bds/media.ex
+++ b/lib/bds/media.ex
@@ -262,6 +262,87 @@ defmodule BDS.Media do
end
end
+ def list_media_translations(media_id) when is_binary(media_id) do
+ Repo.all(
+ from translation in Translation,
+ where: translation.translation_for == ^media_id,
+ order_by: [asc: translation.language]
+ )
+ end
+
+ def list_linked_posts(media_id) when is_binary(media_id) do
+ Repo.all(
+ from post in BDS.Posts.Post,
+ join: post_media in "post_media",
+ on: post_media.post_id == post.id,
+ where: post_media.media_id == ^media_id,
+ order_by: [asc: post_media.sort_order, asc: post.updated_at],
+ select: %{
+ post_id: post.id,
+ title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id),
+ sort_order: post_media.sort_order
+ }
+ )
+ end
+
+ def link_media_to_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do
+ case {Repo.get(Media, media_id), Repo.get(BDS.Posts.Post, post_id)} do
+ {nil, _post} ->
+ {:error, :not_found}
+
+ {_media, nil} ->
+ {:error, :not_found}
+
+ {%Media{} = media, %BDS.Posts.Post{} = post} ->
+ project = Projects.get_project!(media.project_id)
+
+ Repo.transaction(fn ->
+ case Repo.query("SELECT 1 FROM post_media WHERE post_id = ? AND media_id = ? LIMIT 1", [post.id, media.id]) do
+ {:ok, %{rows: [[1]]}} ->
+ :already_linked
+
+ _other ->
+ sort_order = next_sort_order(media.id)
+
+ {:ok, _result} =
+ Repo.query(
+ "INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?)",
+ [Ecto.UUID.generate(), media.project_id, post.id, media.id, sort_order, Persistence.now_ms()]
+ )
+
+ :linked
+ end
+
+ :ok = write_sidecar(project, media)
+ :ok
+ end)
+ |> case do
+ {:ok, :ok} -> {:ok, :linked}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+ end
+
+ def unlink_media_from_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do
+ case Repo.get(Media, media_id) do
+ nil ->
+ {:error, :not_found}
+
+ %Media{} = media ->
+ project = Projects.get_project!(media.project_id)
+
+ Repo.transaction(fn ->
+ {:ok, _result} = Repo.query("DELETE FROM post_media WHERE media_id = ? AND post_id = ?", [media.id, post_id])
+ :ok = write_sidecar(project, media)
+ :ok
+ end)
+ |> case do
+ {:ok, :ok} -> {:ok, :unlinked}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+ end
+
def thumbnail_paths(%Media{id: id}) do
prefix = String.slice(id, 0, 2)
@@ -675,6 +756,13 @@ defmodule BDS.Media do
end
end
+ defp next_sort_order(media_id) do
+ case Repo.query("SELECT COALESCE(MAX(sort_order), -1) FROM post_media WHERE media_id = ?", [media_id]) do
+ {:ok, %{rows: [[value]]}} when is_integer(value) -> value + 1
+ _other -> 0
+ end
+ end
+
defp blank_to_nil(nil), do: nil
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value
diff --git a/priv/ui/app.css b/priv/ui/app.css
index 5a8f104..505aef7 100644
--- a/priv/ui/app.css
+++ b/priv/ui/app.css
@@ -2598,6 +2598,513 @@ button svg * {
font: inherit;
}
+.media-editor {
+ display: grid;
+ grid-template-columns: minmax(320px, 0.95fr) minmax(360px, 1.05fr);
+ gap: 20px;
+ padding: 20px;
+ align-items: start;
+}
+
+.media-editor-details-form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.translation-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.68);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+ z-index: 10001;
+}
+
+.translation-modal {
+ width: min(640px, calc(100vw - 32px));
+ background: #1e1e1e;
+ border: 1px solid #3c3c3c;
+ border-radius: 8px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+}
+
+.translation-modal-header,
+.translation-modal-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px 20px;
+}
+
+.translation-modal-header {
+ border-bottom: 1px solid #3c3c3c;
+}
+
+.translation-modal-footer {
+ border-top: 1px solid #3c3c3c;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.translation-modal-body {
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.translation-modal-close {
+ border: none;
+ background: transparent;
+ color: #c5c5c5;
+ cursor: pointer;
+ font-size: 20px;
+ line-height: 1;
+}
+
+.settings-view-shell,
+.style-view,
+.tags-view-shell,
+.scripts-view-shell,
+.templates-view-shell,
+.chat-panel {
+ height: 100%;
+ background: var(--panel-1, #1e1e1e);
+}
+
+.settings-view,
+.tags-view,
+.style-view {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.settings-header,
+.style-view-header,
+.tags-view-header,
+.chat-panel-header {
+ padding: 18px 20px;
+ border-bottom: 1px solid var(--line, #3c3c3c);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.settings-search input {
+ width: min(320px, 40vw);
+}
+
+.settings-content,
+.tags-view-content {
+ padding: 20px;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.setting-section,
+.tags-section {
+ border: 1px solid var(--line, #3c3c3c);
+ border-radius: 12px;
+ background: var(--panel-2, #252526);
+}
+
+.setting-section-header,
+.tags-section-header {
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--line, #3c3c3c);
+}
+
+.setting-section-content,
+.tags-section-content {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.setting-row,
+.tag-form-row {
+ display: grid;
+ grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+}
+
+.tag-form-row {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.setting-label {
+ font-weight: 600;
+}
+
+.setting-control,
+.setting-input-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+}
+
+.setting-actions {
+ padding: 0 16px 16px;
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.style-theme-picker {
+ padding: 20px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 14px;
+}
+
+.style-theme-option {
+ border: 1px solid var(--line, #3c3c3c);
+ background: var(--panel-2, #252526);
+ border-radius: 14px;
+ padding: 14px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.style-theme-option.selected {
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 1px var(--accent-color);
+}
+
+.style-theme-swatch {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.style-theme-tones {
+ display: grid;
+ grid-template-columns: 2fr 1fr 1fr;
+ gap: 8px;
+}
+
+.style-theme-tone {
+ height: 42px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.style-apply-row {
+ padding: 0 20px 20px;
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+}
+
+.style-preview-container {
+ padding: 0 20px 20px;
+ flex: 1;
+ min-height: 0;
+}
+
+.style-preview-frame {
+ width: 100%;
+ height: 100%;
+ min-height: 420px;
+ border: 1px solid var(--line, #3c3c3c);
+ border-radius: 14px;
+ background: #ffffff;
+}
+
+.tag-cloud {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.tag-cloud-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--line, #3c3c3c);
+ background: var(--panel-1, #1e1e1e);
+ cursor: pointer;
+}
+
+.tag-cloud-item.selected {
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 1px var(--accent-color);
+}
+
+.scripts-view-shell,
+.templates-view-shell {
+ display: flex;
+ flex-direction: column;
+}
+
+.scripts-view,
+.templates-view {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.scripts-header,
+.templates-header {
+ border-bottom: 1px solid var(--line, #3c3c3c);
+}
+
+.scripts-meta-row,
+.templates-meta-row {
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--line, #3c3c3c);
+}
+
+.editor-field-row {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.scripts-editor,
+.templates-editor {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ flex: 1;
+}
+
+.scripts-monaco,
+.templates-monaco {
+ flex: 1;
+ min-height: 0;
+ padding: 0 20px 20px;
+}
+
+.code-editor-textarea {
+ width: 100%;
+ height: 100%;
+ min-height: 420px;
+ resize: vertical;
+ font: 13px/1.5 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
+}
+
+.editor-footer {
+ padding: 12px 20px 20px;
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ border-top: 1px solid var(--line, #3c3c3c);
+}
+
+.chat-panel {
+ height: 100%;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+}
+
+.chat-panel-title {
+ font-weight: 700;
+}
+
+.chat-messages {
+ padding: 20px;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.chat-message {
+ display: flex;
+}
+
+.chat-message.user {
+ justify-content: flex-end;
+}
+
+.chat-message-content {
+ max-width: min(760px, 100%);
+ border: 1px solid var(--line, #3c3c3c);
+ border-radius: 14px;
+ padding: 14px 16px;
+ background: var(--panel-2, #252526);
+}
+
+.chat-message.user .chat-message-content {
+ background: rgba(0, 122, 204, 0.15);
+}
+
+.chat-input-container {
+ padding: 16px 20px 20px;
+ border-top: 1px solid var(--line, #3c3c3c);
+}
+
+.chat-input-wrapper {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+}
+
+.chat-input {
+ min-height: 48px;
+ resize: vertical;
+}
+
+.chat-send-button {
+ width: 44px;
+ height: 44px;
+ border-radius: 999px;
+}
+
+.chat-welcome {
+ margin: auto;
+ max-width: 560px;
+ text-align: center;
+ color: var(--vscode-descriptionForeground);
+}
+
+.chat-welcome ul {
+ list-style: none;
+ padding: 0;
+ margin: 18px 0 0;
+ display: grid;
+ gap: 8px;
+}
+
+.misc-editor-shell {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: var(--panel-1, #1e1e1e);
+}
+
+.misc-editor-header {
+ padding: 18px 20px;
+ border-bottom: 1px solid var(--line, #3c3c3c);
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.misc-editor-header h2,
+.misc-card h3 {
+ margin: 0;
+}
+
+.misc-editor-header p {
+ margin: 6px 0 0;
+ color: var(--vscode-descriptionForeground);
+}
+
+.misc-editor-actions,
+.misc-editor-summary {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.misc-editor-summary {
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--line, #3c3c3c);
+}
+
+.misc-summary-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ background: var(--panel-2, #252526);
+ border: 1px solid var(--line, #3c3c3c);
+}
+
+.misc-editor-content {
+ padding: 20px;
+ overflow: auto;
+ flex: 1;
+}
+
+.misc-columns {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 16px;
+}
+
+.misc-card,
+.misc-list-item {
+ border: 1px solid var(--line, #3c3c3c);
+ border-radius: 12px;
+ background: var(--panel-2, #252526);
+ padding: 16px;
+}
+
+.misc-card ul,
+.misc-list {
+ margin: 12px 0 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.misc-list-item header,
+.duplicate-pair-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto minmax(0, 1fr) auto auto;
+ gap: 12px;
+ align-items: center;
+}
+
+.misc-list-item ul li {
+ display: grid;
+ grid-template-columns: minmax(120px, 180px) minmax(0, 1fr) minmax(0, 1fr);
+ gap: 10px;
+ padding-top: 8px;
+}
+
+.misc-code-card pre {
+ margin: 0;
+ white-space: pre-wrap;
+ font: 12px/1.5 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
+}
+
+.linkish {
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--accent-color);
+ text-align: left;
+}
+
+@media (max-width: 1100px) {
+ .media-editor,
+ .setting-row,
+ .tag-form-row,
+ .editor-field-row,
+ .duplicate-pair-row,
+ .misc-list-item ul li {
+ grid-template-columns: 1fr;
+ }
+
+ .style-theme-picker {
+ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
+ }
+}
+
.insert-modal-results,
.insert-media-grid,
.shared-popover-list,
@@ -2833,6 +3340,171 @@ button svg * {
.lightbox-image-container {
max-width: 90%;
+
+.media-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.media-editor-form {
+ display: grid;
+ grid-template-columns: minmax(240px, 0.9fr) minmax(0, 1.1fr);
+ gap: 20px;
+ align-items: start;
+}
+
+.media-preview,
+.media-translations-section,
+.linked-posts-section {
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.84);
+}
+
+.media-preview {
+ min-height: 260px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.media-preview-image,
+.media-preview-image img {
+ width: 100%;
+}
+
+.media-preview-image img {
+ display: block;
+ max-height: 460px;
+ object-fit: contain;
+ border-radius: 10px;
+}
+
+.media-preview-placeholder {
+ min-height: 220px;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ color: #64748b;
+}
+
+.media-details {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.media-translations-section,
+.linked-posts-section {
+ margin-top: 2px;
+ padding: 14px 16px;
+}
+
+.linked-posts-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.linked-post-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.linked-post-link {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ color: #0f172a;
+ text-align: left;
+ cursor: pointer;
+}
+
+.unlink-btn,
+.add-link-btn {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+.add-link-btn {
+ margin-left: 10px;
+ color: #2563eb;
+ font-weight: 600;
+}
+
+.unlink-btn {
+ color: #b91c1c;
+ font-size: 1rem;
+}
+
+.post-picker {
+ margin-top: 10px;
+ padding: 12px;
+ border-radius: 10px;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ background: rgba(248, 250, 252, 0.9);
+}
+
+.post-picker-search input,
+.translation-inline-form .post-editor-input,
+.translation-inline-form .post-editor-textarea {
+ width: 100%;
+}
+
+.post-picker-list {
+ margin-top: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.post-picker-item,
+.post-picker-more,
+.no-linked-posts,
+.no-posts {
+ padding: 8px 10px;
+ border-radius: 8px;
+}
+
+.post-picker-item {
+ border: 0;
+ background: rgba(37, 99, 235, 0.08);
+ text-align: left;
+ cursor: pointer;
+}
+
+.post-picker-more,
+.no-linked-posts,
+.no-posts {
+ color: #64748b;
+ background: rgba(226, 232, 240, 0.4);
+}
+
+.translation-inline-form {
+ margin-top: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.translation-inline-actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+@media (max-width: 1100px) {
+ .media-editor-form {
+ grid-template-columns: 1fr;
+ }
+}
max-height: 78%;
}
diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs
index 4654a4e..6f7c699 100644
--- a/test/bds/desktop/shell_live_test.exs
+++ b/test/bds/desktop/shell_live_test.exs
@@ -5,11 +5,15 @@ defmodule BDS.Desktop.ShellLiveTest do
import Phoenix.LiveViewTest
alias BDS.Persistence
+ alias BDS.AI
+ alias BDS.Media
alias BDS.Metadata
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Projects
alias BDS.Repo
+ alias BDS.Scripts
+ alias BDS.Templates
alias BDS.Tags
alias BDS.UI.{Session, Workbench}
@@ -727,6 +731,240 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ ~s(phx-value-mode="visual")
end
+ test "media tabs render a real editor and drive explicit save flows", %{project: project, temp_dir: temp_dir} do
+ {:ok, post} =
+ Posts.create_post(%{
+ project_id: project.id,
+ title: "Linked Shell Post",
+ content: "Body"
+ })
+
+ source_path = Path.join(temp_dir, "cover.txt")
+ File.write!(source_path, "media body")
+
+ assert {:ok, media} =
+ Media.import_media(%{
+ project_id: project.id,
+ source_path: source_path,
+ title: "Manual Cover",
+ alt: "Cover alt",
+ caption: "Cover caption",
+ author: "Initial Author",
+ language: "en",
+ tags: ["cover", "hero"]
+ })
+
+ assert {:ok, _translation} =
+ Media.upsert_media_translation(media.id, "de", %{
+ title: "Titelbild",
+ alt: "Alt DE",
+ caption: "Beschriftung DE"
+ })
+
+ assert {:ok, _result} =
+ Repo.query(
+ "INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?)",
+ [Ecto.UUID.generate(), project.id, post.id, media.id, 0, Persistence.now_ms()]
+ )
+
+ {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
+
+ html =
+ render_click(view, "pin_sidebar_item", %{
+ "route" => "media",
+ "id" => media.id,
+ "title" => media.title,
+ "subtitle" => media.original_name
+ })
+
+ assert html =~ ~s(data-testid="media-editor")
+ assert html =~ ~s(data-testid="media-editor-form")
+ assert html =~ ~s(name="media_editor[title]")
+ assert html =~ ~s(name="media_editor[alt]")
+ assert html =~ ~s(name="media_editor[caption]")
+ assert html =~ ~s(name="media_editor[tags]")
+ assert html =~ ~s(data-testid="media-save-button")
+ assert html =~ ~s(data-testid="media-delete-button")
+ assert html =~ "quick-actions-wrapper"
+ assert html =~ "media-translations-section"
+ assert html =~ "linked-posts-section"
+ assert html =~ "Manual Cover"
+ assert html =~ "Linked Shell Post"
+ 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})
+
+ assert html =~ "quick-actions-menu"
+ assert html =~ "Detect Language"
+ assert html =~ "Translate"
+
+ html =
+ view
+ |> form("[data-testid='media-editor-form']", %{
+ media_editor: %{
+ title: "Updated Cover",
+ alt: "Updated alt",
+ caption: "Updated caption",
+ tags: "cover, feature",
+ author: "Ada Lovelace",
+ language: "fr"
+ }
+ })
+ |> render_change()
+
+ assert html =~ "Updated Cover"
+
+ _html = render_click(view, "save_media_editor", %{"id" => media.id})
+
+ saved_media = Repo.get!(BDS.Media.Media, media.id)
+ assert saved_media.title == "Updated Cover"
+ assert saved_media.alt == "Updated alt"
+ assert saved_media.caption == "Updated caption"
+ assert saved_media.tags == ["cover", "feature"]
+ assert saved_media.author == "Ada Lovelace"
+ assert saved_media.language == "fr"
+ end
+
+ test "media editor follows the old-app translation editing flow", %{project: project, temp_dir: temp_dir} do
+ source_path = Path.join(temp_dir, "hero.txt")
+ File.write!(source_path, "media body")
+
+ assert {:ok, media} =
+ Media.import_media(%{
+ project_id: project.id,
+ source_path: source_path,
+ title: "Legacy Cover",
+ alt: "Legacy alt",
+ caption: "Legacy caption",
+ language: "en"
+ })
+
+ assert {:ok, _translation} =
+ Media.upsert_media_translation(media.id, "de", %{
+ title: "Titelbild",
+ alt: "Alt DE",
+ caption: "Beschriftung DE"
+ })
+
+ {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
+
+ html =
+ render_click(view, "pin_sidebar_item", %{
+ "route" => "media",
+ "id" => media.id,
+ "title" => media.title,
+ "subtitle" => media.original_name
+ })
+
+ assert html =~ ~s(class="editor-content media-editor")
+ assert html =~ ~s(class="quick-actions-wrapper")
+ refute html =~ ~s(class="media-editor-form")
+
+ html = render_click(view, "edit_media_translation", %{"id" => media.id, "language" => "de"})
+
+ assert html =~ ~s(class="translation-modal-backdrop")
+ assert html =~ ~s(class="translation-modal")
+ assert html =~ ~s(name="media_translation[title]")
+ assert html =~ ~s(name="media_translation[alt]")
+ assert html =~ ~s(name="media_translation[caption]")
+ end
+
+ test "remaining step-5 routes render dedicated editors instead of the generic shell placeholder", %{project: project} do
+ assert {:ok, script} =
+ Scripts.create_script(%{
+ project_id: project.id,
+ title: "Sync Script",
+ kind: :utility,
+ content: "def main():\n return 'ok'\n"
+ })
+
+ assert {:ok, template} =
+ Templates.create_template(%{
+ project_id: project.id,
+ title: "Post Template",
+ kind: :post,
+ content: "{{ post.title }} "
+ })
+
+ assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "feature"})
+ assert {:ok, conversation} = AI.start_chat(%{title: "Editor Chat"})
+
+ {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
+
+ settings_html =
+ render_click(view, "pin_sidebar_item", %{
+ "route" => "settings",
+ "id" => "settings",
+ "title" => "Settings",
+ "subtitle" => "Project settings"
+ })
+
+ assert settings_html =~ ~s(class="settings-view-shell")
+ assert settings_html =~ ~s(class="setting-section")
+ refute settings_html =~ "Desktop workbench content routed through the Elixir shell."
+
+ tags_html =
+ render_click(view, "pin_sidebar_item", %{
+ "route" => "tags",
+ "id" => "tags",
+ "title" => "Tags",
+ "subtitle" => "Manage tags"
+ })
+
+ assert tags_html =~ ~s(class="tags-view-shell")
+ assert tags_html =~ ~s(class="tags-section")
+ refute tags_html =~ "Desktop workbench content routed through the Elixir shell."
+
+ style_html =
+ render_click(view, "pin_sidebar_item", %{
+ "route" => "style",
+ "id" => "style",
+ "title" => "Style",
+ "subtitle" => "Theme preview"
+ })
+
+ assert style_html =~ ~s(class="style-view")
+ assert style_html =~ ~s(class="style-theme-picker")
+ refute style_html =~ "Desktop workbench content routed through the Elixir shell."
+
+ script_html =
+ render_click(view, "pin_sidebar_item", %{
+ "route" => "scripts",
+ "id" => script.id,
+ "title" => script.title,
+ "subtitle" => script.slug
+ })
+
+ assert script_html =~ ~s(class="scripts-view-shell")
+ assert script_html =~ ~s(class="scripts-monaco")
+ refute script_html =~ "Desktop workbench content routed through the Elixir shell."
+
+ template_html =
+ render_click(view, "pin_sidebar_item", %{
+ "route" => "templates",
+ "id" => template.id,
+ "title" => template.title,
+ "subtitle" => template.slug
+ })
+
+ assert template_html =~ ~s(class="templates-view-shell")
+ assert template_html =~ ~s(class="templates-monaco")
+ refute template_html =~ "Desktop workbench content routed through the Elixir shell."
+
+ chat_html =
+ render_click(view, "pin_sidebar_item", %{
+ "route" => "chat",
+ "id" => conversation.id,
+ "title" => conversation.title,
+ "subtitle" => conversation.model || "chat"
+ })
+
+ assert chat_html =~ ~s(class="chat-panel")
+ assert chat_html =~ ~s(class="chat-input-container")
+ refute chat_html =~ "Desktop workbench content routed through the Elixir shell."
+ end
+
test "template sidebar exposes old-app style delete control and removes template rows", %{project: project} do
assert {:ok, template} =
BDS.Templates.create_template(%{
diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs
index 2c910d4..ceac1ec 100644
--- a/test/bds/ui/shell_test.exs
+++ b/test/bds/ui/shell_test.exs
@@ -315,6 +315,19 @@ defmodule BDS.UI.ShellTest do
refute live_ex =~ "defp update_post_editor_expanded("
end
+ test "desktop shell keeps media editor logic in the feature slice" do
+ live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex")
+ template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
+ media_editor_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/media_editor.ex")
+
+ assert template =~ "