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 @@ +
+
+
<%= @chat_editor.title %>
+
+ +
+ <%= 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 %> +
+
+
<%= String.capitalize(to_string(message.role || "assistant")) %>
+
<%= 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.title %>
+
+ + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
\ 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.title %>
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
\ 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 %> + + <%= if @media_editor.quick_actions_open? do %> +
+ <%= if @media_editor.is_image do %> + + +
+ <% end %> + + + +
+ + +
+ <% end %> + + + + + + + + +
+
+ <%= if @media_editor.is_image and @media_editor.preview_url do %> +
+ {@media_editor.form["alt"] +
+ <% else %> +
+ + + + <%= @media_editor.original_name %> +
+ <% end %> +
+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ + <%= if @media_editor.dimensions do %> +
+ + +
+ <% end %> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + <%= if @media_editor.form["language"] not in [nil, ""] do %> +
+ + + <%= if Enum.empty?(@media_editor.translations) do %> +
<%= translated("No translations") %>
+ <% else %> +
+ <%= for translation <- @media_editor.translations do %> +
+ + + +
+ <% end %> +
+ <% end %> + +
+ <% end %> + +
+ + + <%= if @media_editor.post_picker_open? do %> +
+
+ +
+ + <%= if Enum.empty?(@media_editor.post_picker_results) do %> +
<%= translated("No posts to link") %>
+ <% else %> +
+ <%= for result <- @media_editor.post_picker_results do %> + + <% end %> + <%= if @media_editor.post_picker_overflow_count > 0 do %> +
<%= translated("and %{count} more", %{count: @media_editor.post_picker_overflow_count}) %>
+ <% end %> +
+ <% end %> +
+ <% end %> + + <%= if Enum.empty?(@media_editor.linked_posts) do %> +
<%= translated("Not linked to any posts") %>
+ <% else %> +
+ <%= for linked_post <- @media_editor.linked_posts do %> +
+ + +
+ <% end %> +
+ <% end %> +
+ + <%= if @media_editor.editing_translation do %> +
+
+
+

<%= translated("Edit Translation") %>

+ +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ <% end %> + \ 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 @@ +
+
+
+

<%= @misc_editor.title %>

+

<%= @misc_editor.subtitle %>

+
+
+ + <%= if @misc_editor.kind == :site_validation do %> + + <% end %> + <%= if @misc_editor.kind == :find_duplicates do %> + + <% end %> +
+
+ +
+ <%= 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 %> +
+ + + + + <%= 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)}%") %> + +
+ <% 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 @@ +
+
+
+

<%= translated("Settings") %>

+ +
+ +
+ <%= 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") %>

+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ <%= for language <- @settings_editor.supported_languages do %> + + <% end %> +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ <% end %> + + <%= if @settings_editor.content_visible? do %> +
+

<%= translated("Content Categories") %>

+
+ <%= for category <- @settings_editor.categories do %> +
+ +
+
+
+ + + + +
+
+
+ <% end %> +
+
+
+
+ + +
+
+
+
+
+ <% end %> + + <%= if @settings_editor.publishing_visible? do %> +
+

<%= translated("Publishing") %>

+
+
+
+
+
+
+
+
+ <% end %> + + <%= if @settings_editor.data_visible? do %> +
+

<%= translated("Data Maintenance") %>

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

<%= translated("Style") %>

+

<%= translated("Theme preview and renderer theme selection") %>

+
+ +
+ <%= for theme <- @style_editor.themes do %> + + <% end %> +
+ +
+ + +
+ +
+ +
+
\ 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 @@ +
+
+
+

<%= translated("Tags") %>

+
+ +
+
+

<%= translated("Tag Cloud") %>

+
+
+ <%= for tag <- @tags_editor.tags do %> + + <% end %> +
+
+
+ +
+

<%= translated("Create / Edit") %>

+
+
+
+ + + +
+
+ + <%= if @tags_editor.edit_draft != %{} do %> +
+
+ + + + + +
+
+ <% end %> +
+
+ +
+

<%= translated("Merge Tags") %>

+
+
+
+ + +
+
+
+
+ +
+

<%= translated("Sync") %>

+
+ +
+
+
+
+
\ 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 =~ "