feat: implementation of step 5 of the plan - still not fully done
This commit is contained in:
35
lib/bds/desktop/file_picker.ex
Normal file
35
lib/bds/desktop/file_picker.ex
Normal file
@@ -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
|
||||||
@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
import Phoenix.HTML
|
import Phoenix.HTML
|
||||||
|
|
||||||
alias BDS.Desktop.{FolderPicker, Overlay, ShellCommands, ShellData}
|
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.OverlayComponents, as: ShellOverlayComponents
|
||||||
alias BDS.Desktop.ShellLive.PostEditor
|
alias BDS.Desktop.ShellLive.PostEditor
|
||||||
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
|
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
|
||||||
@@ -70,6 +71,26 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:post_editor_modes, %{})
|
|> assign(:post_editor_modes, %{})
|
||||||
|> assign(:post_editor_expanded, %{})
|
|> assign(:post_editor_expanded, %{})
|
||||||
|> assign(:post_editor_save_states, %{})
|
|> 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(:shell_overlay, nil)
|
||||||
|> assign(:output_entries, [])
|
|> assign(:output_entries, [])
|
||||||
|> reload_shell(workbench)}
|
|> 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)}
|
{:noreply, PostEditor.remove_list_value(socket, post_id, :categories, category, &reload_shell/2)}
|
||||||
end
|
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
|
def handle_event("open_overlay", %{"kind" => kind}, socket) do
|
||||||
socket =
|
socket =
|
||||||
case socket.assigns[:current_tab] do
|
case socket.assigns[:current_tab] do
|
||||||
%{type: :post, id: post_id} when kind in ["ai_suggestions", "language_picker"] ->
|
%{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))
|
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 ->
|
_other ->
|
||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
@@ -535,6 +797,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{%{kind: :language_picker}, %{type: :post, id: post_id}} ->
|
{%{kind: :language_picker}, %{type: :post, id: post_id}} ->
|
||||||
PostEditor.translate(socket, post_id, code, &reload_shell/2, &append_output_entry/5)
|
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
|
_other -> socket
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -555,6 +820,18 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
&append_output_entry/5
|
&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} ->
|
{%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} ->
|
||||||
close_overlay_with_output(socket, title, entity_name)
|
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(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
||||||
|> assign(:current_tab, current_tab(workbench))
|
|> assign(:current_tab, current_tab(workbench))
|
||||||
|> assign_post_editor()
|
|> assign_post_editor()
|
||||||
|
|> assign_media_editor()
|
||||||
|
|> assign_settings_editor()
|
||||||
|
|> assign_tags_editor()
|
||||||
|
|> assign_code_entity_editor()
|
||||||
|
|> assign_chat_editor()
|
||||||
|
|> assign_misc_editor()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp render_panel_body(assigns) do
|
defp render_panel_body(assigns) do
|
||||||
@@ -963,6 +1246,30 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
PostEditor.assign_socket(socket)
|
PostEditor.assign_socket(socket)
|
||||||
end
|
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
|
defp sync_layout(workbench, params) do
|
||||||
workbench
|
workbench
|
||||||
@@ -1136,11 +1443,20 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
append_output_entry(socket, title, message, url)
|
append_output_entry(socket, title, message, url)
|
||||||
end
|
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)
|
route_atom = String.to_existing_atom(route)
|
||||||
tab_id = tab_id_for_route(route_atom, route)
|
tab_id = tab_id_for_route(route_atom, route)
|
||||||
workbench = Workbench.open_tab(socket.assigns.workbench, route_atom, tab_id, :pin)
|
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
|
socket
|
||||||
|> assign(:tab_meta, tab_meta)
|
|> assign(:tab_meta, tab_meta)
|
||||||
@@ -1173,6 +1489,22 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> elem(0)
|
|> elem(0)
|
||||||
end
|
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
|
defp active_titlebar_menu_group(assigns) do
|
||||||
Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end)
|
Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end)
|
||||||
end
|
end
|
||||||
|
|||||||
68
lib/bds/desktop/shell_live/chat_editor.ex
Normal file
68
lib/bds/desktop/shell_live/chat_editor.ex
Normal file
@@ -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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<div class="chat-panel" data-testid="chat-editor">
|
||||||
|
<div class="chat-panel-header">
|
||||||
|
<div class="chat-panel-title"><%= @chat_editor.title %></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-messages">
|
||||||
|
<%= if Enum.empty?(@chat_editor.messages) do %>
|
||||||
|
<div class="chat-welcome">
|
||||||
|
<div class="chat-welcome-icon">🤖</div>
|
||||||
|
<h2><%= translated("New Chat") %></h2>
|
||||||
|
<p><%= translated("Ask the assistant about the active project.") %></p>
|
||||||
|
<ul>
|
||||||
|
<li><%= translated("Search posts and media") %></li>
|
||||||
|
<li><%= translated("Inspect metadata") %></li>
|
||||||
|
<li><%= translated("Open related tabs") %></li>
|
||||||
|
<li><%= translated("Review generated output") %></li>
|
||||||
|
<li><%= translated("Navigate settings") %></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%= for message <- @chat_editor.messages do %>
|
||||||
|
<div class={["chat-message", to_string(message.role || "assistant")]}>
|
||||||
|
<div class="chat-message-content">
|
||||||
|
<div class="chat-message-header"><span class="chat-message-role"><%= String.capitalize(to_string(message.role || "assistant")) %></span></div>
|
||||||
|
<div class="chat-message-text"><%= message.content || "" %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-input-container">
|
||||||
|
<form class="chat-input-wrapper" phx-change="change_chat_editor_input">
|
||||||
|
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("Send a message")}><%= @chat_editor.input %></textarea>
|
||||||
|
<button class="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={String.trim(@chat_editor.input || "") == "" or @chat_editor.offline?}>↑</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
268
lib/bds/desktop/shell_live/code_entity_editor.ex
Normal file
268
lib/bds/desktop/shell_live/code_entity_editor.ex
Normal file
@@ -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
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<div class="scripts-view-shell" data-testid="script-editor">
|
||||||
|
<div class="editor-header scripts-header">
|
||||||
|
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @script_editor.title %></span></div></div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button class="scripts-save-button" type="button" phx-click="save_script_editor"><%= translated("Save") %></button>
|
||||||
|
<button class="scripts-run-button" type="button" phx-click="run_script_editor"><%= translated("Run") %></button>
|
||||||
|
<button class="scripts-check-button" type="button" phx-click="check_script_editor"><%= translated("Check Syntax") %></button>
|
||||||
|
<button class="secondary danger" type="button" phx-click="delete_script_editor"><%= translated("Delete") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="editor-content scripts-view" phx-change="change_script_editor">
|
||||||
|
<div class="editor-header-row scripts-meta-row">
|
||||||
|
<div class="editor-meta">
|
||||||
|
<div class="editor-field-row">
|
||||||
|
<div class="editor-field"><label><%= translated("Title") %></label><input type="text" name="script_editor[title]" value={@script_editor.title} /></div>
|
||||||
|
<div class="editor-field"><label><%= translated("Slug") %></label><input type="text" name="script_editor[slug]" value={@script_editor.slug} /></div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-field-row">
|
||||||
|
<div class="editor-field"><label><%= translated("Kind") %></label><select name="script_editor[kind]"><option value="utility" selected={@script_editor.kind == "utility"}>utility</option><option value="macro" selected={@script_editor.kind == "macro"}>macro</option><option value="transform" selected={@script_editor.kind == "transform"}>transform</option></select></div>
|
||||||
|
<div class="editor-field"><label><%= translated("Entrypoint") %></label><select name="script_editor[entrypoint]"><%= for entrypoint <- @script_editor.entrypoints do %><option value={entrypoint} selected={entrypoint == @script_editor.entrypoint}><%= entrypoint %></option><% end %></select></div>
|
||||||
|
<div class="editor-field scripts-enabled-field"><label><input type="checkbox" name="script_editor[enabled]" checked={@script_editor.enabled} /> <%= translated("Enabled") %></label></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-body scripts-editor">
|
||||||
|
<div class="editor-toolbar scripts-toolbar"><div class="editor-toolbar-left"><label><%= translated("Content") %></label></div></div>
|
||||||
|
<div class="scripts-monaco"><textarea class="code-editor-textarea" name="script_editor[content]"><%= @script_editor.content %></textarea></div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-footer"><span class="text-muted text-small"><%= translated("Created") %>: <%= format_timestamp(@script_editor.created_at) %></span><span class="text-muted text-small"><%= translated("Updated") %>: <%= format_timestamp(@script_editor.updated_at) %></span></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<div class="templates-view-shell" data-testid="template-editor">
|
||||||
|
<div class="editor-header templates-header">
|
||||||
|
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @template_editor.title %></span></div></div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button class="templates-save-button" type="button" phx-click="save_template_editor"><%= translated("Save") %></button>
|
||||||
|
<button class="templates-validate-button" type="button" phx-click="validate_template_editor"><%= translated("Validate") %></button>
|
||||||
|
<button class="secondary danger" type="button" phx-click="delete_template_editor"><%= translated("Delete") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="editor-content templates-view" phx-change="change_template_editor">
|
||||||
|
<div class="editor-header-row templates-meta-row">
|
||||||
|
<div class="editor-meta">
|
||||||
|
<div class="editor-field-row">
|
||||||
|
<div class="editor-field"><label><%= translated("Title") %></label><input type="text" name="template_editor[title]" value={@template_editor.title} /></div>
|
||||||
|
<div class="editor-field"><label><%= translated("Slug") %></label><input type="text" name="template_editor[slug]" value={@template_editor.slug} /></div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-field-row">
|
||||||
|
<div class="editor-field"><label><%= translated("Kind") %></label><select name="template_editor[kind]"><option value="post" selected={@template_editor.kind == :post or @template_editor.kind == "post"}>post</option><option value="list" selected={@template_editor.kind == :list or @template_editor.kind == "list"}>list</option><option value="not-found" selected={@template_editor.kind == :"not-found" or @template_editor.kind == "not-found"}>not-found</option><option value="partial" selected={@template_editor.kind == :partial or @template_editor.kind == "partial"}>partial</option></select></div>
|
||||||
|
<div class="editor-field templates-enabled-field"><label><input type="checkbox" name="template_editor[enabled]" checked={@template_editor.enabled} /> <%= translated("Enabled") %></label></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-body templates-editor">
|
||||||
|
<div class="editor-toolbar templates-toolbar"><div class="editor-toolbar-left"><label><%= translated("Content") %></label></div></div>
|
||||||
|
<div class="templates-monaco"><textarea class="code-editor-textarea" name="template_editor[content]" spellcheck="false"><%= @template_editor.content %></textarea></div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-footer"><span class="text-muted text-small"><%= translated("Created") %>: <%= format_timestamp(@template_editor.created_at) %></span><span class="text-muted text-small"><%= translated("Updated") %>: <%= format_timestamp(@template_editor.updated_at) %></span></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -43,12 +43,12 @@
|
|||||||
>
|
>
|
||||||
<%= for item <- titlebar_menu_dropdown_items(group) do %>
|
<%= for item <- titlebar_menu_dropdown_items(group) do %>
|
||||||
<%= if item.separator do %>
|
<%= if item.separator do %>
|
||||||
<div class="window-titlebar-menu-separator"></div>
|
<div class="window-titlebar-menu-separator" role="separator"></div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<button
|
<button
|
||||||
class={[
|
class={[
|
||||||
"window-titlebar-menu-item",
|
"window-titlebar-menu-item",
|
||||||
if(@titlebar_menu_item_index == item.keyboard_index, do: "is-keyboard-active")
|
if(titlebar_menu_item_active?(group, item, @titlebar_menu_item_index), do: "is-keyboard-active")
|
||||||
]}
|
]}
|
||||||
data-testid="window-titlebar-menu-item"
|
data-testid="window-titlebar-menu-item"
|
||||||
data-menu-action={item.id}
|
data-menu-action={item.id}
|
||||||
@@ -362,32 +362,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= if @current_tab.type == :post and @post_editor do %>
|
<%= cond do %>
|
||||||
<PostEditor.post_editor post_editor={@post_editor} toolbar_buttons={editor_toolbar_buttons(@current_tab)} />
|
<% @current_tab.type == :post and @post_editor -> %>
|
||||||
<% else %>
|
<PostEditor.post_editor post_editor={@post_editor} toolbar_buttons={editor_toolbar_buttons(@current_tab)} />
|
||||||
<div class="editor-frame">
|
|
||||||
<section class="editor-main">
|
|
||||||
<div class="editor-kicker"><%= tab_route_label(@current_tab) %></div>
|
|
||||||
<h1 class="editor-title" data-testid="editor-title"><%= tab_title(@current_tab, @tab_meta) %></h1>
|
|
||||||
<p class="editor-subtitle"><%= tab_subtitle(@current_tab, @tab_meta) %></p>
|
|
||||||
|
|
||||||
<%= render_editor_toolbar(assigns) %>
|
<% @current_tab.type == :media and @media_editor -> %>
|
||||||
|
<MediaEditor.media_editor media_editor={@media_editor} />
|
||||||
|
|
||||||
<div class="editor-section">
|
<% @current_tab.type == :settings and @settings_editor -> %>
|
||||||
<h2><%= tab_title(@current_tab, @tab_meta) %></h2>
|
<SettingsEditor.settings_editor settings_editor={@settings_editor} />
|
||||||
<p>Desktop workbench content routed through the Elixir shell.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside class="editor-meta">
|
<% @current_tab.type == :style and @style_editor -> %>
|
||||||
<%= for item <- @editor_meta do %>
|
<SettingsEditor.style_editor style_editor={@style_editor} />
|
||||||
<section class="editor-meta-row">
|
|
||||||
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
|
<% @current_tab.type == :tags and @tags_editor -> %>
|
||||||
<span><%= translated(item.value) %></span>
|
<TagsEditor.tags_editor tags_editor={@tags_editor} />
|
||||||
</section>
|
|
||||||
<% end %>
|
<% @current_tab.type == :scripts and @script_editor -> %>
|
||||||
</aside>
|
<CodeEntityEditor.script_editor script_editor={@script_editor} />
|
||||||
</div>
|
|
||||||
|
<% @current_tab.type == :templates and @template_editor -> %>
|
||||||
|
<CodeEntityEditor.template_editor template_editor={@template_editor} />
|
||||||
|
|
||||||
|
<% @current_tab.type == :chat and @chat_editor -> %>
|
||||||
|
<ChatEditor.chat_editor chat_editor={@chat_editor} />
|
||||||
|
|
||||||
|
<% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] and @misc_editor -> %>
|
||||||
|
<MiscEditor.misc_editor misc_editor={@misc_editor} />
|
||||||
|
|
||||||
|
<% true -> %>
|
||||||
|
<div class="editor-frame">
|
||||||
|
<section class="editor-main">
|
||||||
|
<div class="editor-kicker"><%= tab_route_label(@current_tab) %></div>
|
||||||
|
<h1 class="editor-title" data-testid="editor-title"><%= tab_title(@current_tab, @tab_meta) %></h1>
|
||||||
|
<p class="editor-subtitle"><%= tab_subtitle(@current_tab, @tab_meta) %></p>
|
||||||
|
|
||||||
|
<%= render_editor_toolbar(assigns) %>
|
||||||
|
|
||||||
|
<div class="editor-section">
|
||||||
|
<h2><%= tab_title(@current_tab, @tab_meta) %></h2>
|
||||||
|
<p>Desktop workbench content routed through the Elixir shell.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="editor-meta">
|
||||||
|
<%= for item <- @editor_meta do %>
|
||||||
|
<section class="editor-meta-row">
|
||||||
|
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
|
||||||
|
<span><%= translated(item.value) %></span>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
570
lib/bds/desktop/shell_live/media_editor.ex
Normal file
570
lib/bds/desktop/shell_live/media_editor.ex
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.Desktop.{FilePicker, ShellData}
|
||||||
|
alias BDS.{AI, I18n, Media, Repo}
|
||||||
|
alias BDS.Media.Media, as: MediaRecord
|
||||||
|
alias BDS.Media.Translation
|
||||||
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.UI.Workbench
|
||||||
|
|
||||||
|
embed_templates "media_editor_html/*"
|
||||||
|
|
||||||
|
@post_picker_limit 10
|
||||||
|
|
||||||
|
def assign_socket(socket) do
|
||||||
|
assign(socket, :media_editor, build(socket.assigns))
|
||||||
|
end
|
||||||
|
|
||||||
|
def update(socket, params, reload) do
|
||||||
|
case socket.assigns.current_tab do
|
||||||
|
%{type: :media, id: media_id} ->
|
||||||
|
case Repo.get(MediaRecord, media_id) do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
%MediaRecord{} = media ->
|
||||||
|
draft = normalize_params(params)
|
||||||
|
socket |> reconcile_draft(media, draft) |> reload_with_assigned_workbench(reload)
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def persist_socket(socket, media_id, reload, append_output) do
|
||||||
|
case Repo.get(MediaRecord, media_id) do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
%MediaRecord{} = media ->
|
||||||
|
draft = current_draft(socket.assigns, media)
|
||||||
|
|
||||||
|
case persist(media, draft) do
|
||||||
|
{:ok, updated_media} ->
|
||||||
|
workbench = Workbench.clear_dirty(socket.assigns.workbench, :media, media_id)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:workbench, workbench)
|
||||||
|
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|
||||||
|
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved))
|
||||||
|
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media)))
|
||||||
|
|> reload.(workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Media"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def toggle_quick_actions(socket, media_id, reload) do
|
||||||
|
workbench = socket.assigns.workbench
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:media_editor_quick_actions_open, Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1)))
|
||||||
|
|> reload.(workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def replace_file(socket, media_id, reload, append_output) do
|
||||||
|
case FilePicker.choose_file(translated("Replace Media File")) do
|
||||||
|
{:ok, source_path} ->
|
||||||
|
case Media.replace_media_file(media_id, source_path) do
|
||||||
|
{:ok, %MediaRecord{} = updated_media} ->
|
||||||
|
workbench = Workbench.clear_dirty(socket.assigns.workbench, :media, media_id)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:workbench, workbench)
|
||||||
|
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|
||||||
|
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved))
|
||||||
|
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media)))
|
||||||
|
|> reload.(workbench)
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
socket |> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Replace File"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
:cancel ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
{:error, %{message: message}} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Replace File"), message, nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def detect_language(socket, media_id, reload, append_output) do
|
||||||
|
if Map.get(socket.assigns, :offline_mode, true) do
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
else
|
||||||
|
case Repo.get(MediaRecord, media_id) do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
%MediaRecord{} = media ->
|
||||||
|
draft = current_draft(socket.assigns, media)
|
||||||
|
text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "alt", ""), Map.get(draft, "caption", "")], "\n\n")
|
||||||
|
|
||||||
|
case AI.detect_language(text) do
|
||||||
|
{:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" ->
|
||||||
|
normalized = normalize_language(language_code)
|
||||||
|
|
||||||
|
case Media.update_media(media.id, %{language: normalized}) do
|
||||||
|
{:ok, updated_media} ->
|
||||||
|
updated_draft = Map.put(current_draft(socket.assigns, media), "language", normalized)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> reconcile_draft(updated_media, updated_draft)
|
||||||
|
|> reload_with_assigned_workbench(reload)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Detect Language"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Detect Language"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def translate(socket, media_id, language, reload, append_output) do
|
||||||
|
if Map.get(socket.assigns, :offline_mode, true) do
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
else
|
||||||
|
normalized_language = normalize_language(language)
|
||||||
|
|
||||||
|
case AI.translate_media(media_id, normalized_language) do
|
||||||
|
{:ok, translation} ->
|
||||||
|
case Media.upsert_media_translation(media_id, normalized_language, translation) do
|
||||||
|
{:ok, _saved_translation} ->
|
||||||
|
socket
|
||||||
|
|> assign(:media_editor_quick_actions_open, Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false))
|
||||||
|
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Translate"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Translate"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do
|
||||||
|
try do
|
||||||
|
case Repo.get(MediaRecord, media_id) do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
%MediaRecord{} = media ->
|
||||||
|
attrs =
|
||||||
|
Enum.reduce(fields, current_draft(socket.assigns, media), fn field, acc ->
|
||||||
|
case field.key do
|
||||||
|
"title" -> Map.put(acc, "title", field.suggested_value)
|
||||||
|
"alt" -> Map.put(acc, "alt", field.suggested_value)
|
||||||
|
"caption" -> Map.put(acc, "caption", field.suggested_value)
|
||||||
|
_other -> acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:shell_overlay, nil)
|
||||||
|
|> reconcile_draft(media, attrs)
|
||||||
|
|> reload_with_assigned_workbench(reload)
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
error ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("AI Suggestions"), inspect(error), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_socket(socket, media_id, reload, append_output) do
|
||||||
|
case Media.delete_media(media_id) do
|
||||||
|
{:ok, :deleted} ->
|
||||||
|
workbench = Workbench.close_tab(socket.assigns.workbench, :media, media_id)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:workbench, workbench)
|
||||||
|
|> assign(:shell_overlay, nil)
|
||||||
|
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|
||||||
|
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|
||||||
|
|> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id))
|
||||||
|
|> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id))
|
||||||
|
|> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id))
|
||||||
|
|> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id))
|
||||||
|
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
||||||
|
|> reload.(workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Delete Media"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def toggle_post_picker(socket, media_id, reload) do
|
||||||
|
workbench = socket.assigns.workbench
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:media_editor_post_pickers_open, Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1)))
|
||||||
|
|> reload.(workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_post_picker_query(socket, media_id, query, reload) do
|
||||||
|
workbench = socket.assigns.workbench
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, to_string(query || "")))
|
||||||
|
|> reload.(workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_post(socket, media_id, post_id, reload, append_output) do
|
||||||
|
case Media.link_media_to_post(media_id, post_id) do
|
||||||
|
{:ok, _linked} ->
|
||||||
|
socket
|
||||||
|
|> assign(:media_editor_post_pickers_open, Map.put(socket.assigns.media_editor_post_pickers_open, media_id, false))
|
||||||
|
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, ""))
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Link to Post"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def unlink_post(socket, media_id, post_id, reload, append_output) do
|
||||||
|
case Media.unlink_media_from_post(media_id, post_id) do
|
||||||
|
{:ok, _unlinked} ->
|
||||||
|
socket |> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Unlink from Post"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit_translation(socket, media_id, language, reload) do
|
||||||
|
workbench = socket.assigns.workbench
|
||||||
|
|
||||||
|
translation = Repo.get_by(Translation, translation_for: media_id, language: language)
|
||||||
|
|
||||||
|
form = %{
|
||||||
|
"language" => language,
|
||||||
|
"title" => translation && translation.title || "",
|
||||||
|
"alt" => translation && translation.alt || "",
|
||||||
|
"caption" => translation && translation.caption || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form))
|
||||||
|
|> reload.(workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_translation(socket, media_id, params, reload) do
|
||||||
|
workbench = socket.assigns.workbench
|
||||||
|
|
||||||
|
form = %{
|
||||||
|
"language" => Map.get(params, "language", ""),
|
||||||
|
"title" => Map.get(params, "title", ""),
|
||||||
|
"alt" => Map.get(params, "alt", ""),
|
||||||
|
"caption" => Map.get(params, "caption", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form))
|
||||||
|
|> reload.(workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_translation(socket, media_id, reload, append_output) do
|
||||||
|
case Map.get(socket.assigns.media_editor_translation_forms, media_id) do
|
||||||
|
%{"language" => language} = form when language not in [nil, ""] ->
|
||||||
|
case Media.upsert_media_translation(media_id, language, %{
|
||||||
|
title: blank_to_nil(Map.get(form, "title")),
|
||||||
|
alt: blank_to_nil(Map.get(form, "alt")),
|
||||||
|
caption: blank_to_nil(Map.get(form, "caption"))
|
||||||
|
}) do
|
||||||
|
{:ok, _translation} ->
|
||||||
|
socket
|
||||||
|
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Save Translation"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_translation(socket, media_id, language, reload, append_output) do
|
||||||
|
if Map.get(socket.assigns, :offline_mode, true) do
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
else
|
||||||
|
case AI.translate_media(media_id, normalize_language(language)) do
|
||||||
|
{:ok, translation} ->
|
||||||
|
case Media.upsert_media_translation(media_id, language, translation) do
|
||||||
|
{:ok, _saved_translation} -> socket |> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Refresh Translation"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Refresh Translation"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_translation(socket, media_id, language, reload, append_output) do
|
||||||
|
case Media.delete_media_translation(media_id, language) do
|
||||||
|
{:ok, _deleted?} ->
|
||||||
|
socket
|
||||||
|
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("Delete Translation"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do
|
||||||
|
case Repo.get(MediaRecord, media_id) do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
%MediaRecord{} = media ->
|
||||||
|
linked_posts = Media.list_linked_posts(media.id)
|
||||||
|
translations = Media.list_media_translations(media.id)
|
||||||
|
form = current_draft(assigns, media)
|
||||||
|
picker_query = Map.get(assigns.media_editor_post_picker_queries, media.id, "")
|
||||||
|
{picker_results, picker_overflow_count} = post_picker_results(media, linked_posts, picker_query)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: media.id,
|
||||||
|
display_title: display_title(media),
|
||||||
|
original_name: media.original_name || media.filename || media.id,
|
||||||
|
mime_type: media.mime_type || "application/octet-stream",
|
||||||
|
file_size: format_file_size(media.size),
|
||||||
|
dimensions: dimensions_label(media),
|
||||||
|
is_image: image?(media),
|
||||||
|
preview_url: preview_url(media),
|
||||||
|
dirty?: Workbench.dirty?(assigns.workbench, :media, media.id),
|
||||||
|
save_state: Map.get(assigns.media_editor_save_states, media.id, :idle),
|
||||||
|
quick_actions_open?: Map.get(assigns.media_editor_quick_actions_open, media.id, false),
|
||||||
|
post_picker_open?: Map.get(assigns.media_editor_post_pickers_open, media.id, false),
|
||||||
|
post_picker_query: picker_query,
|
||||||
|
post_picker_results: picker_results,
|
||||||
|
post_picker_overflow_count: picker_overflow_count,
|
||||||
|
form: form,
|
||||||
|
languages: language_codes(),
|
||||||
|
translations: Enum.map(translations, &translation_view/1),
|
||||||
|
editing_translation: Map.get(assigns.media_editor_translation_forms, media.id),
|
||||||
|
linked_posts: linked_posts,
|
||||||
|
can_detect_language?: detect_language_enabled?(form),
|
||||||
|
can_translate?: form["language"] not in [nil, ""]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build(_assigns), do: nil
|
||||||
|
|
||||||
|
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||||
|
|
||||||
|
def media_editor_save_state_label(:dirty), do: translated("Unsaved")
|
||||||
|
def media_editor_save_state_label(:saved), do: translated("Saved")
|
||||||
|
def media_editor_save_state_label(_state), do: translated("Idle")
|
||||||
|
|
||||||
|
def language_label(code) do
|
||||||
|
code
|
||||||
|
|> to_string()
|
||||||
|
|> String.upcase()
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase()
|
||||||
|
|
||||||
|
def persist(%MediaRecord{} = media, draft) do
|
||||||
|
Media.update_media(media.id, %{
|
||||||
|
title: blank_to_nil(Map.get(draft, "title")),
|
||||||
|
alt: blank_to_nil(Map.get(draft, "alt")),
|
||||||
|
caption: blank_to_nil(Map.get(draft, "caption")),
|
||||||
|
author: blank_to_nil(Map.get(draft, "author")),
|
||||||
|
language: blank_to_nil(Map.get(draft, "language")),
|
||||||
|
tags: csv_to_list(Map.get(draft, "tags"))
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reconcile_draft(socket, %MediaRecord{} = media, draft) do
|
||||||
|
persisted = persisted_form(media)
|
||||||
|
dirty? = draft != persisted
|
||||||
|
workbench = if dirty?, do: Workbench.mark_dirty(socket.assigns.workbench, :media, media.id), else: Workbench.clear_dirty(socket.assigns.workbench, :media, media.id)
|
||||||
|
|
||||||
|
drafts =
|
||||||
|
if dirty? do
|
||||||
|
Map.put(socket.assigns.media_editor_drafts, media.id, draft)
|
||||||
|
else
|
||||||
|
Map.delete(socket.assigns.media_editor_drafts, media.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:workbench, workbench)
|
||||||
|
|> assign(:media_editor_drafts, drafts)
|
||||||
|
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media.id, if(dirty?, do: :dirty, else: :idle)))
|
||||||
|
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media.id}, %{title: blank_to_nil(Map.get(draft, "title")) || display_title(media), subtitle: media.original_name || media.mime_type || ""}))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_draft(assigns, %MediaRecord{} = media) do
|
||||||
|
Map.get(assigns.media_editor_drafts, media.id, persisted_form(media))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persisted_form(%MediaRecord{} = media) do
|
||||||
|
%{
|
||||||
|
"title" => media.title || "",
|
||||||
|
"alt" => media.alt || "",
|
||||||
|
"caption" => media.caption || "",
|
||||||
|
"tags" => Enum.join(media.tags || [], ", "),
|
||||||
|
"author" => media.author || "",
|
||||||
|
"language" => media.language || ""
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_params(params) do
|
||||||
|
%{
|
||||||
|
"title" => Map.get(params, "title", ""),
|
||||||
|
"alt" => Map.get(params, "alt", ""),
|
||||||
|
"caption" => Map.get(params, "caption", ""),
|
||||||
|
"tags" => Map.get(params, "tags", ""),
|
||||||
|
"author" => Map.get(params, "author", ""),
|
||||||
|
"language" => Map.get(params, "language", "")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translation_view(%Translation{} = translation) do
|
||||||
|
%{
|
||||||
|
language: translation.language,
|
||||||
|
flag: I18n.flag(translation.language),
|
||||||
|
title: translation.title,
|
||||||
|
alt: translation.alt,
|
||||||
|
caption: translation.caption
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_picker_results(%MediaRecord{} = media, linked_posts, query) do
|
||||||
|
linked_ids = MapSet.new(Enum.map(linked_posts, & &1.post_id))
|
||||||
|
normalized_query = normalize_query(query)
|
||||||
|
|
||||||
|
posts =
|
||||||
|
Repo.all(
|
||||||
|
from post in Post,
|
||||||
|
where: post.project_id == ^media.project_id,
|
||||||
|
order_by: [desc: post.updated_at, desc: post.created_at],
|
||||||
|
select: %{post_id: post.id, title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id)}
|
||||||
|
)
|
||||||
|
|> Enum.reject(&MapSet.member?(linked_ids, &1.post_id))
|
||||||
|
|> Enum.filter(fn post -> normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query) end)
|
||||||
|
|
||||||
|
{Enum.take(posts, @post_picker_limit), max(length(posts) - @post_picker_limit, 0)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tab_meta(%MediaRecord{} = media) do
|
||||||
|
%{title: display_title(media), subtitle: media.original_name || media.mime_type || ""}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preview_url(%MediaRecord{} = media) do
|
||||||
|
if image?(media), do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}", else: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp image?(%MediaRecord{} = media), do: String.starts_with?(to_string(media.mime_type || ""), "image/")
|
||||||
|
|
||||||
|
defp display_title(%MediaRecord{} = media), do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id
|
||||||
|
|
||||||
|
defp dimensions_label(%MediaRecord{width: width, height: height}) when is_integer(width) and is_integer(height), do: "#{width} x #{height}"
|
||||||
|
defp dimensions_label(_media), do: nil
|
||||||
|
|
||||||
|
defp format_file_size(size) when is_integer(size) and size >= 1_048_576, do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB"
|
||||||
|
defp format_file_size(size) when is_integer(size), do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
|
||||||
|
defp format_file_size(_size), do: "0.0 KB"
|
||||||
|
|
||||||
|
defp detect_language_enabled?(form) do
|
||||||
|
[Map.get(form, "title"), Map.get(form, "alt"), Map.get(form, "caption")]
|
||||||
|
|> Enum.any?(&(blank_to_nil(&1) != nil))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp language_codes do
|
||||||
|
I18n.supported_languages()
|
||||||
|
|> Enum.map(& &1.code)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_query(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.downcase()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp csv_to_list(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.split(",")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp blank_to_nil(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> case do
|
||||||
|
"" -> nil
|
||||||
|
trimmed -> trimmed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench)
|
||||||
|
end
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<div class="media-editor editor" data-testid="media-editor">
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-tabs">
|
||||||
|
<div class={[
|
||||||
|
"editor-tab",
|
||||||
|
"active",
|
||||||
|
if(@media_editor.dirty?, do: "dirty")
|
||||||
|
]}>
|
||||||
|
<span class="editor-tab-title" data-testid="editor-title"><%= @media_editor.display_title %></span>
|
||||||
|
<%= if @media_editor.dirty? do %>
|
||||||
|
<span class="editor-tab-dirty" title={translated("Unsaved")}>●</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-actions">
|
||||||
|
<%= if @media_editor.save_state in [:dirty, :saved] do %>
|
||||||
|
<span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="quick-actions-wrapper">
|
||||||
|
<button
|
||||||
|
class="secondary quick-actions-btn"
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_media_editor_quick_actions"
|
||||||
|
phx-value-id={@media_editor.id}
|
||||||
|
>
|
||||||
|
<%= translated("Quick Actions") %>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<%= if @media_editor.quick_actions_open? do %>
|
||||||
|
<div class="quick-actions-menu">
|
||||||
|
<%= if @media_editor.is_image do %>
|
||||||
|
<button
|
||||||
|
class="quick-action-item"
|
||||||
|
data-testid="editor-toolbar-overlay-button"
|
||||||
|
type="button"
|
||||||
|
phx-click="open_overlay"
|
||||||
|
phx-value-kind="ai_suggestions"
|
||||||
|
>
|
||||||
|
<span class="quick-action-icon">🤖</span>
|
||||||
|
<span class="quick-action-text">
|
||||||
|
<strong><%= translated("AI Suggestions") %></strong>
|
||||||
|
<small><%= translated("Review title, alt text, and caption suggestions") %></small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="quick-actions-divider"></div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="quick-action-item"
|
||||||
|
type="button"
|
||||||
|
phx-click="detect_media_editor_language"
|
||||||
|
phx-value-id={@media_editor.id}
|
||||||
|
disabled={not @media_editor.can_detect_language?}
|
||||||
|
>
|
||||||
|
<span class="quick-action-icon">🔍</span>
|
||||||
|
<span class="quick-action-text">
|
||||||
|
<strong><%= translated("Detect Language") %></strong>
|
||||||
|
<small><%= translated("Persist the detected language for this media item") %></small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="quick-actions-divider"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="quick-action-item"
|
||||||
|
data-testid="editor-toolbar-overlay-button"
|
||||||
|
type="button"
|
||||||
|
phx-click="open_overlay"
|
||||||
|
phx-value-kind="language_picker"
|
||||||
|
disabled={not @media_editor.can_translate?}
|
||||||
|
>
|
||||||
|
<span class="quick-action-icon">🌍</span>
|
||||||
|
<span class="quick-action-text">
|
||||||
|
<strong><%= translated("Translate") %></strong>
|
||||||
|
<small><%= translated("Select a target language for this media item") %></small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="secondary" type="button" phx-click="replace_media_editor_file" phx-value-id={@media_editor.id}>
|
||||||
|
<%= translated("Replace File") %>
|
||||||
|
</button>
|
||||||
|
<button data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-value-id={@media_editor.id}>
|
||||||
|
<%= translated("Save") %>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="secondary danger"
|
||||||
|
data-testid="media-delete-button"
|
||||||
|
type="button"
|
||||||
|
phx-click="open_overlay"
|
||||||
|
phx-value-kind="confirm_delete"
|
||||||
|
>
|
||||||
|
<%= translated("Delete") %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-content media-editor">
|
||||||
|
<div class="media-preview">
|
||||||
|
<%= if @media_editor.is_image and @media_editor.preview_url do %>
|
||||||
|
<div class="media-preview-image">
|
||||||
|
<img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} />
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="media-preview-placeholder">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"></path>
|
||||||
|
</svg>
|
||||||
|
<span><%= @media_editor.original_name %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="media-details media-editor-details-form" data-testid="media-editor-form" phx-change="change_media_editor">
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("File Name") %></label>
|
||||||
|
<input class="post-editor-input is-readonly" type="text" value={@media_editor.original_name} readonly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("MIME Type") %></label>
|
||||||
|
<input class="post-editor-input is-readonly" type="text" value={@media_editor.mime_type} readonly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-field-row">
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Size") %></label>
|
||||||
|
<input class="post-editor-input is-readonly" type="text" value={@media_editor.file_size} readonly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @media_editor.dimensions do %>
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Dimensions") %></label>
|
||||||
|
<input class="post-editor-input is-readonly" type="text" value={@media_editor.dimensions} readonly />
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Title") %></label>
|
||||||
|
<input class="post-editor-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Alt Text") %></label>
|
||||||
|
<input class="post-editor-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Caption") %></label>
|
||||||
|
<textarea class="post-editor-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Tags") %></label>
|
||||||
|
<input class="post-editor-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Author") %></label>
|
||||||
|
<input class="post-editor-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Language") %></label>
|
||||||
|
<select class="post-editor-input" name="media_editor[language]">
|
||||||
|
<option value=""><%= translated("None") %></option>
|
||||||
|
<%= for language <- @media_editor.languages do %>
|
||||||
|
<option value={language} selected={language == @media_editor.form["language"]}><%= language_label(language) %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @media_editor.form["language"] not in [nil, ""] do %>
|
||||||
|
<div class="editor-field media-translations-section">
|
||||||
|
<label><%= translated("Translations") %></label>
|
||||||
|
|
||||||
|
<%= if Enum.empty?(@media_editor.translations) do %>
|
||||||
|
<div class="no-linked-posts"><%= translated("No translations") %></div>
|
||||||
|
<% else %>
|
||||||
|
<div class="linked-posts-list">
|
||||||
|
<%= for translation <- @media_editor.translations do %>
|
||||||
|
<div class="linked-post-item">
|
||||||
|
<button
|
||||||
|
class="linked-post-title linked-post-link"
|
||||||
|
type="button"
|
||||||
|
phx-click="edit_media_translation"
|
||||||
|
phx-value-id={@media_editor.id}
|
||||||
|
phx-value-language={translation.language}
|
||||||
|
>
|
||||||
|
<%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " - #{translation.title}" %>
|
||||||
|
</button>
|
||||||
|
<button class="secondary compact" type="button" phx-click="refresh_media_translation" phx-value-id={@media_editor.id} phx-value-language={translation.language}>
|
||||||
|
<%= translated("Refresh") %>
|
||||||
|
</button>
|
||||||
|
<button class="unlink-btn" type="button" phx-click="delete_media_translation" phx-value-id={@media_editor.id} phx-value-language={translation.language}>×</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="editor-field linked-posts-section">
|
||||||
|
<label>
|
||||||
|
<%= translated("Linked Posts") %>
|
||||||
|
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-value-id={@media_editor.id}>
|
||||||
|
<%= translated("Link to Post") %>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<%= if @media_editor.post_picker_open? do %>
|
||||||
|
<div class="post-picker">
|
||||||
|
<div class="post-picker-search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="media_post_picker[query]"
|
||||||
|
value={@media_editor.post_picker_query}
|
||||||
|
placeholder={translated("Search posts")}
|
||||||
|
phx-change="change_media_post_picker"
|
||||||
|
phx-value-id={@media_editor.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if Enum.empty?(@media_editor.post_picker_results) do %>
|
||||||
|
<div class="no-posts"><%= translated("No posts to link") %></div>
|
||||||
|
<% else %>
|
||||||
|
<div class="post-picker-list">
|
||||||
|
<%= for result <- @media_editor.post_picker_results do %>
|
||||||
|
<button class="post-picker-item" type="button" phx-click="link_media_to_post" phx-value-id={@media_editor.id} phx-value-post-id={result.post_id}>
|
||||||
|
<%= result.title %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
<%= if @media_editor.post_picker_overflow_count > 0 do %>
|
||||||
|
<div class="post-picker-more"><%= translated("and %{count} more", %{count: @media_editor.post_picker_overflow_count}) %></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if Enum.empty?(@media_editor.linked_posts) do %>
|
||||||
|
<div class="no-linked-posts"><%= translated("Not linked to any posts") %></div>
|
||||||
|
<% else %>
|
||||||
|
<div class="linked-posts-list">
|
||||||
|
<%= for linked_post <- @media_editor.linked_posts do %>
|
||||||
|
<div class="linked-post-item">
|
||||||
|
<button
|
||||||
|
class="linked-post-title linked-post-link"
|
||||||
|
type="button"
|
||||||
|
phx-click="pin_sidebar_item"
|
||||||
|
phx-value-route="post"
|
||||||
|
phx-value-id={linked_post.post_id}
|
||||||
|
phx-value-title={linked_post.title}
|
||||||
|
phx-value-subtitle="linked post"
|
||||||
|
>
|
||||||
|
📄 <%= linked_post.title %>
|
||||||
|
</button>
|
||||||
|
<button class="unlink-btn" type="button" phx-click="unlink_media_from_post" phx-value-id={@media_editor.id} phx-value-post-id={linked_post.post_id}>×</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @media_editor.editing_translation do %>
|
||||||
|
<div class="translation-modal-backdrop">
|
||||||
|
<div class="translation-modal">
|
||||||
|
<div class="translation-modal-header">
|
||||||
|
<h2><%= translated("Edit Translation") %></h2>
|
||||||
|
<button class="translation-modal-close" type="button" phx-click="close_media_translation_editor">×</button>
|
||||||
|
</div>
|
||||||
|
<form class="translation-modal-body" phx-change="change_media_translation">
|
||||||
|
<input type="hidden" name="media_translation[language]" value={@media_editor.editing_translation["language"]} />
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Title") %></label>
|
||||||
|
<input class="post-editor-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} />
|
||||||
|
</div>
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Alt Text") %></label>
|
||||||
|
<input class="post-editor-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} />
|
||||||
|
</div>
|
||||||
|
<div class="editor-field">
|
||||||
|
<label><%= translated("Caption") %></label>
|
||||||
|
<textarea class="post-editor-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="translation-modal-footer">
|
||||||
|
<button class="secondary" type="button" phx-click="close_media_translation_editor"><%= translated("Cancel") %></button>
|
||||||
|
<button type="button" phx-click="save_media_translation" phx-value-id={@media_editor.id}><%= translated("Save") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
256
lib/bds/desktop/shell_live/misc_editor.ex
Normal file
256
lib/bds/desktop/shell_live/misc_editor.ex
Normal file
@@ -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
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<div class={["misc-editor-shell", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
|
||||||
|
<div class="misc-editor-header">
|
||||||
|
<div>
|
||||||
|
<h2><%= @misc_editor.title %></h2>
|
||||||
|
<p><%= @misc_editor.subtitle %></p>
|
||||||
|
</div>
|
||||||
|
<div class="misc-editor-actions">
|
||||||
|
<button class="secondary" type="button" phx-click="rerun_misc_editor"><%= translated("Refresh") %></button>
|
||||||
|
<%= if @misc_editor.kind == :site_validation do %>
|
||||||
|
<button class="primary" type="button" phx-click="apply_site_validation" disabled={Enum.empty?(@misc_editor.missing_pages) and Enum.empty?(@misc_editor.extra_pages) and Enum.empty?(@misc_editor.stale_pages)}><%= translated("Apply") %></button>
|
||||||
|
<% end %>
|
||||||
|
<%= if @misc_editor.kind == :find_duplicates do %>
|
||||||
|
<button class="secondary" type="button" phx-click="dismiss_selected_duplicates" disabled={MapSet.size(@misc_editor.selected_pairs) == 0}><%= translated("Dismiss Checked") %></button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="misc-editor-summary">
|
||||||
|
<%= for {label, value} <- summary_items(@misc_editor) do %>
|
||||||
|
<div class="misc-summary-pill"><span><%= label %></span><strong><%= value %></strong></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="misc-editor-content">
|
||||||
|
<%= case @misc_editor.kind do %>
|
||||||
|
<% :site_validation -> %>
|
||||||
|
<div class="misc-columns">
|
||||||
|
<section class="misc-card"><h3><%= translated("Missing URLs") %></h3><%= if Enum.empty?(@misc_editor.missing_pages) do %><p><%= translated("None found") %></p><% end %><ul><%= for path <- @misc_editor.missing_pages do %><li><%= path %></li><% end %></ul></section>
|
||||||
|
<section class="misc-card"><h3><%= translated("Extra URLs") %></h3><%= if Enum.empty?(@misc_editor.extra_pages) do %><p><%= translated("None found") %></p><% end %><ul><%= for path <- @misc_editor.extra_pages do %><li><%= path %></li><% end %></ul></section>
|
||||||
|
<section class="misc-card"><h3><%= translated("Updated URLs") %></h3><%= if Enum.empty?(@misc_editor.stale_pages) do %><p><%= translated("None found") %></p><% end %><ul><%= for path <- @misc_editor.stale_pages do %><li><%= path %></li><% end %></ul></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% :metadata_diff -> %>
|
||||||
|
<div class="misc-columns">
|
||||||
|
<section class="misc-card"><h3><%= translated("Field Summary") %></h3><div class="misc-summary-grid"><%= for field <- @misc_editor.field_summaries do %><span class="misc-summary-pill"><%= field.field_name %> <strong><%= field.diff_count %></strong></span><% end %></div></section>
|
||||||
|
<section class="misc-card"><h3><%= translated("Diff Items") %></h3><div class="misc-list"><%= for item <- @misc_editor.items do %><article class="misc-list-item"><header><strong><%= Map.get(item, :entity_type) || Map.get(item, "entity_type") %></strong> <span><%= Map.get(item, :entity_id) || Map.get(item, "entity_id") %></span></header><ul><%= for diff <- Map.get(item, :differences) || Map.get(item, "differences") || [] do %><li><strong><%= Map.get(diff, :field) || Map.get(diff, "field") %></strong><span><%= inspect(Map.get(diff, :db_value) || Map.get(diff, "db_value")) %></span><span><%= inspect(Map.get(diff, :file_value) || Map.get(diff, "file_value")) %></span></li><% end %></ul></article><% end %></div></section>
|
||||||
|
<section class="misc-card"><h3><%= translated("Orphan Files") %></h3><ul><%= for orphan <- @misc_editor.orphan_files do %><li><%= inspect(orphan) %></li><% end %></ul></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% :translation_validation -> %>
|
||||||
|
<div class="misc-columns">
|
||||||
|
<section class="misc-card"><h3><%= translated("Missing") %></h3><ul><%= for issue <- @misc_editor.missing do %><li><%= inspect(issue) %></li><% end %></ul></section>
|
||||||
|
<section class="misc-card"><h3><%= translated("Orphan Files") %></h3><ul><%= for file <- @misc_editor.orphan_files do %><li><%= file %></li><% end %></ul></section>
|
||||||
|
<section class="misc-card"><h3><%= translated("Do Not Translate") %></h3><ul><%= for post <- @misc_editor.do_not_translate_posts do %><li><%= inspect(post) %></li><% end %></ul></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% :find_duplicates -> %>
|
||||||
|
<div class="misc-list">
|
||||||
|
<%= for pair <- @misc_editor.pairs do %>
|
||||||
|
<article class="misc-list-item duplicate-pair-row">
|
||||||
|
<label><input type="checkbox" checked={duplicate_checked?(@misc_editor, pair_id_from_pair(pair))} phx-click="toggle_duplicate_pair" phx-value-pair-id={pair_id_from_pair(pair)} /> <span></span></label>
|
||||||
|
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-value-id={Map.get(pair, :post_id_a) || Map.get(pair, "post_id_a")} phx-value-title={Map.get(pair, :title_a) || Map.get(pair, "title_a") }><%= Map.get(pair, :title_a) || Map.get(pair, "title_a") %></button>
|
||||||
|
<span>→</span>
|
||||||
|
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-value-id={Map.get(pair, :post_id_b) || Map.get(pair, "post_id_b")} phx-value-title={Map.get(pair, :title_b) || Map.get(pair, "title_b") }><%= Map.get(pair, :title_b) || Map.get(pair, "title_b") %></button>
|
||||||
|
<span class="misc-summary-pill"><%= 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)}%") %></span>
|
||||||
|
<button class="secondary" type="button" phx-click="dismiss_duplicate_pair" phx-value-post-id-a={Map.get(pair, :post_id_a) || Map.get(pair, "post_id_a")} phx-value-post-id-b={Map.get(pair, :post_id_b) || Map.get(pair, "post_id_b")}><%= translated("Dismiss") %></button>
|
||||||
|
</article>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% :git_diff -> %>
|
||||||
|
<div class="misc-card misc-code-card"><pre><code><%= @misc_editor.diff_text %></code></pre></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -8,6 +8,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
alias BDS.{I18n, Metadata, Repo}
|
alias BDS.{I18n, Metadata, Repo}
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
|
alias BDS.Media.Translation, as: MediaTranslation
|
||||||
alias BDS.Posts.{Post, Translation}
|
alias BDS.Posts.{Post, Translation}
|
||||||
alias BDS.Tags.Tag
|
alias BDS.Tags.Tag
|
||||||
|
|
||||||
@@ -132,6 +133,17 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
_error -> %{}
|
_error -> %{}
|
||||||
end
|
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 existing_translations(_tab), do: %{}
|
||||||
|
|
||||||
defp blog_languages(metadata) do
|
defp blog_languages(metadata) do
|
||||||
@@ -149,6 +161,15 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
_error -> metadata.main_language || "en"
|
_error -> metadata.main_language || "en"
|
||||||
end
|
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 source_language(_tab, metadata), do: metadata.main_language || "en"
|
||||||
|
|
||||||
defp language_names do
|
defp language_names do
|
||||||
|
|||||||
409
lib/bds/desktop/shell_live/settings_editor.ex
Normal file
409
lib/bds/desktop/shell_live/settings_editor.ex
Normal file
@@ -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
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<div class="settings-view-shell" data-testid="settings-editor">
|
||||||
|
<div class="settings-view">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2 data-testid="editor-title"><%= translated("Settings") %></h2>
|
||||||
|
<form class="settings-search" phx-change="change_settings_search">
|
||||||
|
<input type="text" name="query" value={@settings_editor.search_query} placeholder={translated("Search settings")} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<%= 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 %>
|
||||||
|
<div class="settings-no-results">
|
||||||
|
<p><%= translated("No settings match the current search") %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @settings_editor.project_visible? do %>
|
||||||
|
<div class="setting-section" id="settings-section-project">
|
||||||
|
<div class="setting-section-header">
|
||||||
|
<h3><%= translated("Project") %></h3>
|
||||||
|
</div>
|
||||||
|
<form class="setting-section-content" phx-change="change_settings_project">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label class="setting-label"><%= translated("Project Name") %></label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control"><input type="text" name="settings_project[name]" value={@settings_editor.project["name"]} /></div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= translated("Description") %></label></div>
|
||||||
|
<div class="setting-control"><textarea name="settings_project[description]" rows="3"><%= @settings_editor.project["description"] %></textarea></div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= translated("Public URL") %></label></div>
|
||||||
|
<div class="setting-control"><input type="url" name="settings_project[public_url]" value={@settings_editor.project["public_url"]} /></div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= translated("Main Language") %></label></div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select name="settings_project[main_language]">
|
||||||
|
<%= for language <- @settings_editor.supported_languages do %>
|
||||||
|
<option value={language} selected={language == @settings_editor.project["main_language"]}><%= String.upcase(language) %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= translated("Blog Languages") %></label></div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="setting-input-group">
|
||||||
|
<%= for language <- @settings_editor.supported_languages do %>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="settings_project[blog_languages][]" value={language} checked={language in @settings_editor.project["blog_languages"]} />
|
||||||
|
<%= String.upcase(language) %>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= translated("Default Author") %></label></div>
|
||||||
|
<div class="setting-control"><input type="text" name="settings_project[default_author]" value={@settings_editor.project["default_author"]} /></div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= translated("Max Posts Per Page") %></label></div>
|
||||||
|
<div class="setting-control"><input type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= translated("Blogmark Category") %></label></div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select name="settings_project[blogmark_category]">
|
||||||
|
<%= for category <- Enum.map(@settings_editor.categories, & &1.name) do %>
|
||||||
|
<option value={category} selected={category == @settings_editor.project["blogmark_category"]}><%= category %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= translated("Semantic Similarity") %></label></div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label><input type="checkbox" name="settings_project[semantic_similarity_enabled]" checked={@settings_editor.project["semantic_similarity_enabled"]} /> <%= translated("Enable semantic similarity") %></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_project"><%= translated("Save") %></button></div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @settings_editor.content_visible? do %>
|
||||||
|
<div class="setting-section" id="settings-section-content">
|
||||||
|
<div class="setting-section-header"><h3><%= translated("Content Categories") %></h3></div>
|
||||||
|
<div class="setting-section-content">
|
||||||
|
<%= for category <- @settings_editor.categories do %>
|
||||||
|
<form class="setting-row" phx-change="save_settings_category">
|
||||||
|
<input type="hidden" name="category_settings[category]" value={category.name} />
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= category.name %></label></div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="setting-input-group">
|
||||||
|
<input type="text" name="category_settings[title]" value={category.title} />
|
||||||
|
<label><input type="checkbox" name="category_settings[render_in_lists]" checked={category.render_in_lists} /> <%= translated("Render in Lists") %></label>
|
||||||
|
<label><input type="checkbox" name="category_settings[show_title]" checked={category.show_title} /> <%= translated("Show Titles") %></label>
|
||||||
|
<button class="secondary" type="button" phx-click="remove_settings_category" phx-value-category={category.name} disabled={category.protected?}><%= translated("Remove") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= translated("Add Category") %></label></div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="setting-input-group">
|
||||||
|
<input type="text" value={@settings_editor.new_category} phx-change="change_settings_new_category" name="name" />
|
||||||
|
<button class="primary" type="button" phx-click="add_settings_category"><%= translated("Add") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @settings_editor.publishing_visible? do %>
|
||||||
|
<div class="setting-section" id="settings-section-publishing">
|
||||||
|
<div class="setting-section-header"><h3><%= translated("Publishing") %></h3></div>
|
||||||
|
<form class="setting-section-content" phx-change="change_settings_publishing">
|
||||||
|
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= translated("SSH Mode") %></label></div><div class="setting-control"><select name="settings_publishing[ssh_mode]"><option value="scp" selected={@settings_editor.publishing["ssh_mode"] == "scp"}>scp</option><option value="rsync" selected={@settings_editor.publishing["ssh_mode"] == "rsync"}>rsync</option></select></div></div>
|
||||||
|
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= translated("Host") %></label></div><div class="setting-control"><input type="text" name="settings_publishing[ssh_host]" value={@settings_editor.publishing["ssh_host"]} /></div></div>
|
||||||
|
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= translated("Username") %></label></div><div class="setting-control"><input type="text" name="settings_publishing[ssh_user]" value={@settings_editor.publishing["ssh_user"]} /></div></div>
|
||||||
|
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= translated("Remote Path") %></label></div><div class="setting-control"><input type="text" name="settings_publishing[ssh_remote_path]" value={@settings_editor.publishing["ssh_remote_path"]} /></div></div>
|
||||||
|
</form>
|
||||||
|
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_publishing"><%= translated("Save") %></button><button class="secondary" type="button" phx-click="clear_settings_publishing"><%= translated("Clear") %></button></div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @settings_editor.data_visible? do %>
|
||||||
|
<div class="setting-section" id="settings-section-data">
|
||||||
|
<div class="setting-section-header"><h3><%= translated("Data Maintenance") %></h3></div>
|
||||||
|
<div class="setting-actions">
|
||||||
|
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_database"><%= translated("Rebuild Database") %></button>
|
||||||
|
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= translated("Rebuild Embedding Index") %></button>
|
||||||
|
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= translated("Open Data Folder") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<div class="style-view" data-testid="style-editor">
|
||||||
|
<div class="style-view-header">
|
||||||
|
<h2 data-testid="editor-title"><%= translated("Style") %></h2>
|
||||||
|
<p><%= translated("Theme preview and renderer theme selection") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="style-theme-picker" role="group" aria-label={translated("Theme picker")}>
|
||||||
|
<%= for theme <- @style_editor.themes do %>
|
||||||
|
<button type="button" class={["style-theme-option", if(theme.name == @style_editor.selected_theme, do: "selected")]} phx-click="select_style_theme" phx-value-theme={theme.name} aria-pressed={theme.name == @style_editor.selected_theme}>
|
||||||
|
<span class="style-theme-swatch">
|
||||||
|
<span class="style-theme-tones" aria-hidden="true">
|
||||||
|
<span class="style-theme-tone style-theme-tone-accent" style={"background: linear-gradient(135deg, #{theme.accent_color}, #{theme.dark_bg_color})"}></span>
|
||||||
|
<span class="style-theme-tone style-theme-tone-light" style={"background-color: #{theme.light_bg_color}"}></span>
|
||||||
|
<span class="style-theme-tone style-theme-tone-dark" style={"background-color: #{theme.dark_bg_color}"}></span>
|
||||||
|
</span>
|
||||||
|
<span class="style-theme-name"><%= theme_display_name(theme.name) %></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="style-apply-row">
|
||||||
|
<label class="style-preview-mode-control">
|
||||||
|
<span><%= translated("Preview Mode") %></span>
|
||||||
|
<select phx-change="change_style_preview_mode" name="mode">
|
||||||
|
<option value="auto" selected={@style_editor.preview_mode == "auto"}><%= translated("Auto") %></option>
|
||||||
|
<option value="light" selected={@style_editor.preview_mode == "light"}><%= translated("Light") %></option>
|
||||||
|
<option value="dark" selected={@style_editor.preview_mode == "dark"}><%= translated("Dark") %></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="button" phx-click="apply_style_theme" disabled={@style_editor.selected_theme == @style_editor.applied_theme}><%= translated("Apply Theme") %></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="style-preview-container">
|
||||||
|
<iframe title={translated("Theme Preview")} class="style-preview-frame" src={@style_editor.preview_url}></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
246
lib/bds/desktop/shell_live/tags_editor.ex
Normal file
246
lib/bds/desktop/shell_live/tags_editor.ex
Normal file
@@ -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
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<div class="tags-view-shell" data-testid="tags-editor">
|
||||||
|
<div class="tags-view">
|
||||||
|
<div class="tags-view-header">
|
||||||
|
<h2><%= translated("Tags") %></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tags-view-content">
|
||||||
|
<div class="tags-section">
|
||||||
|
<div class="tags-section-header"><h3><%= translated("Tag Cloud") %></h3></div>
|
||||||
|
<div class="tags-section-content">
|
||||||
|
<div class="tag-cloud">
|
||||||
|
<%= for tag <- @tags_editor.tags do %>
|
||||||
|
<button class={["tag-cloud-item", if(tag.name in @tags_editor.selected, do: "selected"), if(tag.color, do: "has-color")]} style={tag_style(tag, @tags_editor.tags)} type="button" phx-click="toggle_tag_selection" phx-value-name={tag.name}>
|
||||||
|
<%= tag.name %><span class="tag-count"><%= tag.count %></span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tags-section">
|
||||||
|
<div class="tags-section-header"><h3><%= translated("Create / Edit") %></h3></div>
|
||||||
|
<div class="tags-section-content">
|
||||||
|
<form class="tag-create-form" phx-change="change_new_tag_editor">
|
||||||
|
<div class="tag-form-row">
|
||||||
|
<input type="text" name="new_tag[name]" value={@tags_editor.new_tag["name"]} placeholder={translated("Tag name")} />
|
||||||
|
<input type="color" name="new_tag[color]" value={if(@tags_editor.new_tag["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.new_tag["color"])} />
|
||||||
|
<button class="primary" type="button" phx-click="create_tag_editor"><%= translated("Create") %></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<%= if @tags_editor.edit_draft != %{} do %>
|
||||||
|
<form class="tag-edit-form" phx-change="change_edit_tag_editor">
|
||||||
|
<div class="tag-form-row">
|
||||||
|
<input type="text" name="edit_tag[name]" value={@tags_editor.edit_draft["name"]} />
|
||||||
|
<input type="color" name="edit_tag[color]" value={if(@tags_editor.edit_draft["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.edit_draft["color"])} />
|
||||||
|
<select name="edit_tag[post_template_slug]">
|
||||||
|
<option value=""><%= translated("No Template") %></option>
|
||||||
|
<%= for template <- @tags_editor.templates do %>
|
||||||
|
<option value={template.slug} selected={template.slug == @tags_editor.edit_draft["post_template_slug"]}><%= template.title %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<button class="primary" type="button" phx-click="save_tag_editor"><%= translated("Save") %></button>
|
||||||
|
<button class="danger" type="button" phx-click="delete_tag_editor"><%= translated("Delete") %></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tags-section">
|
||||||
|
<div class="tags-section-header"><h3><%= translated("Merge Tags") %></h3></div>
|
||||||
|
<div class="tags-section-content">
|
||||||
|
<div class="merge-form">
|
||||||
|
<div class="tag-form-row">
|
||||||
|
<select phx-change="change_merge_target" name="target">
|
||||||
|
<%= for tag_name <- @tags_editor.selected do %>
|
||||||
|
<option value={tag_name} selected={tag_name == @tags_editor.merge_target}><%= tag_name %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<button class="primary" type="button" phx-click="merge_tags_editor" disabled={length(@tags_editor.selected) < 2}><%= translated("Merge") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tags-section">
|
||||||
|
<div class="tags-section-header"><h3><%= translated("Sync") %></h3></div>
|
||||||
|
<div class="tags-section-content">
|
||||||
|
<button class="secondary" type="button" phx-click="sync_tags_editor"><%= translated("Discover") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -262,6 +262,87 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
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
|
def thumbnail_paths(%Media{id: id}) do
|
||||||
prefix = String.slice(id, 0, 2)
|
prefix = String.slice(id, 0, 2)
|
||||||
|
|
||||||
@@ -675,6 +756,13 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
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(nil), do: nil
|
||||||
defp blank_to_nil(""), do: nil
|
defp blank_to_nil(""), do: nil
|
||||||
defp blank_to_nil(value), do: value
|
defp blank_to_nil(value), do: value
|
||||||
|
|||||||
672
priv/ui/app.css
672
priv/ui/app.css
@@ -2598,6 +2598,513 @@ button svg * {
|
|||||||
font: inherit;
|
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-modal-results,
|
||||||
.insert-media-grid,
|
.insert-media-grid,
|
||||||
.shared-popover-list,
|
.shared-popover-list,
|
||||||
@@ -2833,6 +3340,171 @@ button svg * {
|
|||||||
|
|
||||||
.lightbox-image-container {
|
.lightbox-image-container {
|
||||||
max-width: 90%;
|
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%;
|
max-height: 78%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
|
alias BDS.AI
|
||||||
|
alias BDS.Media
|
||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
alias BDS.Posts
|
alias BDS.Posts
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
|
alias BDS.Scripts
|
||||||
|
alias BDS.Templates
|
||||||
alias BDS.Tags
|
alias BDS.Tags
|
||||||
alias BDS.UI.{Session, Workbench}
|
alias BDS.UI.{Session, Workbench}
|
||||||
|
|
||||||
@@ -727,6 +731,240 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
refute html =~ ~s(phx-value-mode="visual")
|
refute html =~ ~s(phx-value-mode="visual")
|
||||||
end
|
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: "<article>{{ post.title }}</article>"
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
test "template sidebar exposes old-app style delete control and removes template rows", %{project: project} do
|
||||||
assert {:ok, template} =
|
assert {:ok, template} =
|
||||||
BDS.Templates.create_template(%{
|
BDS.Templates.create_template(%{
|
||||||
|
|||||||
@@ -315,6 +315,19 @@ defmodule BDS.UI.ShellTest do
|
|||||||
refute live_ex =~ "defp update_post_editor_expanded("
|
refute live_ex =~ "defp update_post_editor_expanded("
|
||||||
end
|
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 =~ "<MediaEditor.media_editor"
|
||||||
|
assert media_editor_ex =~ "def build(%{current_tab: %{type: :media, id: media_id}} = assigns)"
|
||||||
|
|
||||||
|
refute live_ex =~ "defp update_media_editor("
|
||||||
|
refute live_ex =~ "defp persist_media_editor("
|
||||||
|
refute live_ex =~ "defp delete_media_editor("
|
||||||
|
end
|
||||||
|
|
||||||
test "desktop shell keeps sidebar logic in its own slice" do
|
test "desktop shell keeps sidebar logic in its own slice" do
|
||||||
live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex")
|
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")
|
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
|
||||||
|
|||||||
Reference in New Issue
Block a user