feat: implementation of step 5 of the plan - still not fully done

This commit is contained in:
2026-04-26 21:05:15 +02:00
parent 92e5c2ccfd
commit fa0a2fb2e1
22 changed files with 3992 additions and 27 deletions

View 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

View File

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

View 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

View File

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

View 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

View File

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

View File

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

View File

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

View 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

View File

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

View 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

View File

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

View File

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

View 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

View File

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

View File

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

View 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

View File

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

View File

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

View File

@@ -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%;
} }

View File

@@ -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(%{

View File

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