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
alias BDS.Desktop.{FolderPicker, Overlay, ShellCommands, ShellData}
alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MiscEditor, SettingsEditor, TagsEditor}
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
alias BDS.Desktop.ShellLive.PostEditor
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
@@ -70,6 +71,26 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:post_editor_modes, %{})
|> assign(:post_editor_expanded, %{})
|> assign(:post_editor_save_states, %{})
|> assign(:media_editor_drafts, %{})
|> assign(:media_editor_quick_actions_open, %{})
|> assign(:media_editor_post_pickers_open, %{})
|> assign(:media_editor_post_picker_queries, %{})
|> assign(:media_editor_save_states, %{})
|> assign(:media_editor_translation_forms, %{})
|> assign(:settings_editor_search, "")
|> assign(:settings_editor_project_draft, %{})
|> assign(:settings_editor_publishing_draft, %{})
|> assign(:settings_editor_new_category, "")
|> assign(:style_editor_theme, nil)
|> assign(:style_editor_preview_mode, "auto")
|> assign(:tags_editor_selected, [])
|> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""})
|> assign(:tags_editor_edit_draft, %{})
|> assign(:tags_editor_merge_target, "")
|> assign(:script_editor_drafts, %{})
|> assign(:template_editor_drafts, %{})
|> assign(:chat_editor_inputs, %{})
|> assign(:misc_editor_selected_pairs, %{})
|> assign(:shell_overlay, nil)
|> assign(:output_entries, [])
|> reload_shell(workbench)}
@@ -408,12 +429,253 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, PostEditor.remove_list_value(socket, post_id, :categories, category, &reload_shell/2)}
end
def handle_event("change_media_editor", %{"media_editor" => params}, socket) do
{:noreply, MediaEditor.update(socket, params, &reload_shell/2)}
end
def handle_event("save_media_editor", %{"id" => media_id}, socket) do
{:noreply, MediaEditor.persist_socket(socket, media_id, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("toggle_media_editor_quick_actions", %{"id" => media_id}, socket) do
{:noreply, MediaEditor.toggle_quick_actions(socket, media_id, &reload_shell/2)}
end
def handle_event("replace_media_editor_file", %{"id" => media_id}, socket) do
{:noreply, MediaEditor.replace_file(socket, media_id, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("detect_media_editor_language", %{"id" => media_id}, socket) do
{:noreply, MediaEditor.detect_language(socket, media_id, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("toggle_media_post_picker", %{"id" => media_id}, socket) do
{:noreply, MediaEditor.toggle_post_picker(socket, media_id, &reload_shell/2)}
end
def handle_event("change_media_post_picker", %{"id" => media_id, "media_post_picker" => %{"query" => query}}, socket) do
{:noreply, MediaEditor.set_post_picker_query(socket, media_id, query, &reload_shell/2)}
end
def handle_event("link_media_to_post", %{"id" => media_id, "post-id" => post_id}, socket) do
{:noreply, MediaEditor.link_post(socket, media_id, post_id, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("unlink_media_from_post", %{"id" => media_id, "post-id" => post_id}, socket) do
{:noreply, MediaEditor.unlink_post(socket, media_id, post_id, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("edit_media_translation", %{"id" => media_id, "language" => language}, socket) do
{:noreply, MediaEditor.edit_translation(socket, media_id, language, &reload_shell/2)}
end
def handle_event("change_media_translation", %{"media_translation" => params}, socket) do
case socket.assigns.current_tab do
%{type: :media, id: media_id} -> {:noreply, MediaEditor.update_translation(socket, media_id, params, &reload_shell/2)}
_other -> {:noreply, socket}
end
end
def handle_event("save_media_translation", %{"id" => media_id}, socket) do
{:noreply, MediaEditor.save_translation(socket, media_id, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("refresh_media_translation", %{"id" => media_id, "language" => language}, socket) do
{:noreply, MediaEditor.refresh_translation(socket, media_id, language, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("delete_media_translation", %{"id" => media_id, "language" => language}, socket) do
{:noreply, MediaEditor.delete_translation(socket, media_id, language, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("close_media_translation_editor", _params, socket) do
case socket.assigns.current_tab do
%{type: :media, id: media_id} ->
{:noreply,
socket
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|> reload_shell(socket.assigns.workbench)}
_other ->
{:noreply, socket}
end
end
def handle_event("change_settings_search", %{"query" => query}, socket) do
{:noreply, SettingsEditor.update_search(socket, query, &reload_shell/2)}
end
def handle_event("change_settings_project", %{"settings_project" => params}, socket) do
{:noreply, SettingsEditor.update_project_draft(socket, params, &reload_shell/2)}
end
def handle_event("save_settings_project", _params, socket) do
{:noreply, SettingsEditor.save_project(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("change_settings_publishing", %{"settings_publishing" => params}, socket) do
{:noreply, SettingsEditor.update_publishing_draft(socket, params, &reload_shell/2)}
end
def handle_event("save_settings_publishing", _params, socket) do
{:noreply, SettingsEditor.save_publishing(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("clear_settings_publishing", _params, socket) do
{:noreply, SettingsEditor.clear_publishing(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("change_settings_new_category", %{"name" => name}, socket) do
{:noreply, SettingsEditor.update_new_category(socket, name, &reload_shell/2)}
end
def handle_event("add_settings_category", _params, socket) do
{:noreply, SettingsEditor.add_category(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("save_settings_category", %{"category_settings" => params}, socket) do
{:noreply, SettingsEditor.save_category(socket, params, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("remove_settings_category", %{"category" => category}, socket) do
{:noreply, SettingsEditor.remove_category(socket, category, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("settings_shell_command", %{"action" => action}, socket) do
{:noreply, apply_shell_command(socket, action)}
end
def handle_event("select_style_theme", %{"theme" => theme}, socket) do
{:noreply, SettingsEditor.select_style_theme(socket, theme, &reload_shell/2)}
end
def handle_event("change_style_preview_mode", %{"mode" => mode}, socket) do
{:noreply, SettingsEditor.change_style_preview_mode(socket, mode, &reload_shell/2)}
end
def handle_event("apply_style_theme", _params, socket) do
{:noreply, SettingsEditor.apply_style_theme(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("toggle_tag_selection", %{"name" => tag_name}, socket) do
{:noreply, TagsEditor.toggle_selection(socket, tag_name, &reload_shell/2)}
end
def handle_event("change_new_tag_editor", %{"new_tag" => params}, socket) do
{:noreply, TagsEditor.update_new_tag(socket, params, &reload_shell/2)}
end
def handle_event("create_tag_editor", _params, socket) do
{:noreply, TagsEditor.create_tag(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("change_edit_tag_editor", %{"edit_tag" => params}, socket) do
{:noreply, TagsEditor.update_edit_tag(socket, params, &reload_shell/2)}
end
def handle_event("save_tag_editor", _params, socket) do
{:noreply, TagsEditor.save_tag(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("delete_tag_editor", _params, socket) do
{:noreply, TagsEditor.delete_selected(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("change_merge_target", %{"target" => target}, socket) do
{:noreply, TagsEditor.update_merge_target(socket, target, &reload_shell/2)}
end
def handle_event("merge_tags_editor", _params, socket) do
{:noreply, TagsEditor.merge_selected(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("sync_tags_editor", _params, socket) do
{:noreply, TagsEditor.sync(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("change_script_editor", %{"script_editor" => params}, socket) do
{:noreply, CodeEntityEditor.update_script(socket, params, &reload_shell/2)}
end
def handle_event("save_script_editor", _params, socket) do
{:noreply, CodeEntityEditor.save_script(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("run_script_editor", _params, socket) do
{:noreply, CodeEntityEditor.run_script(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("check_script_editor", _params, socket) do
{:noreply, CodeEntityEditor.check_script(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("delete_script_editor", _params, socket) do
{:noreply, CodeEntityEditor.delete_script(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("change_template_editor", %{"template_editor" => params}, socket) do
{:noreply, CodeEntityEditor.update_template(socket, params, &reload_shell/2)}
end
def handle_event("save_template_editor", _params, socket) do
{:noreply, CodeEntityEditor.save_template(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("validate_template_editor", _params, socket) do
{:noreply, CodeEntityEditor.validate_template(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("delete_template_editor", _params, socket) do
{:noreply, CodeEntityEditor.delete_template(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("change_chat_editor_input", %{"message" => message}, socket) do
{:noreply, ChatEditor.update_input(socket, message, &reload_shell/2)}
end
def handle_event("send_chat_editor_message", _params, socket) do
{:noreply, ChatEditor.send_message(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("rerun_misc_editor", _params, socket) do
case MiscEditor.rerun(socket) do
{:command, action} -> {:noreply, apply_shell_command(socket, action)}
{:noop, next_socket} -> {:noreply, next_socket}
end
end
def handle_event("apply_site_validation", _params, socket) do
case MiscEditor.apply_site_validation(socket, &append_output_entry/5) do
{:rerun, next_socket} -> {:noreply, apply_shell_command(next_socket, "validate_site")}
{:socket, next_socket} -> {:noreply, next_socket}
end
end
def handle_event("toggle_duplicate_pair", %{"pair-id" => pair_id}, socket) do
{:noreply, MiscEditor.toggle_duplicate(socket, pair_id, &reload_shell/2)}
end
def handle_event("dismiss_duplicate_pair", %{"post-id-a" => post_id_a, "post-id-b" => post_id_b}, socket) do
{:noreply, MiscEditor.dismiss_duplicate(socket, post_id_a, post_id_b, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("dismiss_selected_duplicates", _params, socket) do
{:noreply, MiscEditor.dismiss_selected(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("open_duplicate_post", %{"id" => id, "title" => title}, socket) do
{:noreply, open_sidebar_item(socket, %{"route" => "post", "id" => id, "title" => title, "subtitle" => "draft"}, :preview)}
end
def handle_event("open_overlay", %{"kind" => kind}, socket) do
socket =
case socket.assigns[:current_tab] do
%{type: :post, id: post_id} when kind in ["ai_suggestions", "language_picker"] ->
assign(socket, :post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false))
%{type: :media, id: media_id} when kind in ["ai_suggestions", "language_picker", "confirm_delete"] ->
assign(socket, :media_editor_quick_actions_open, Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false))
_other ->
socket
end
@@ -535,6 +797,9 @@ defmodule BDS.Desktop.ShellLive do
{%{kind: :language_picker}, %{type: :post, id: post_id}} ->
PostEditor.translate(socket, post_id, code, &reload_shell/2, &append_output_entry/5)
{%{kind: :language_picker}, %{type: :media, id: media_id}} ->
MediaEditor.translate(socket, media_id, code, &reload_shell/2, &append_output_entry/5)
_other -> socket
end
@@ -555,6 +820,18 @@ defmodule BDS.Desktop.ShellLive do
&append_output_entry/5
)
{%{kind: :ai_suggestions} = overlay, %{type: :media, id: media_id}} ->
MediaEditor.apply_ai_suggestions(
socket,
media_id,
Overlay.selected_ai_fields(overlay),
&reload_shell/2,
&append_output_entry/5
)
{%{kind: :confirm_delete}, %{type: :media, id: media_id}} ->
MediaEditor.delete_socket(socket, media_id, &reload_shell/2, &append_output_entry/5)
{%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} ->
close_overlay_with_output(socket, title, entity_name)
@@ -742,6 +1019,12 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> assign(:current_tab, current_tab(workbench))
|> assign_post_editor()
|> assign_media_editor()
|> assign_settings_editor()
|> assign_tags_editor()
|> assign_code_entity_editor()
|> assign_chat_editor()
|> assign_misc_editor()
end
defp render_panel_body(assigns) do
@@ -963,6 +1246,30 @@ defmodule BDS.Desktop.ShellLive do
PostEditor.assign_socket(socket)
end
defp assign_media_editor(socket) do
MediaEditor.assign_socket(socket)
end
defp assign_settings_editor(socket) do
SettingsEditor.assign_socket(socket)
end
defp assign_tags_editor(socket) do
TagsEditor.assign_socket(socket)
end
defp assign_code_entity_editor(socket) do
CodeEntityEditor.assign_socket(socket)
end
defp assign_chat_editor(socket) do
ChatEditor.assign_socket(socket)
end
defp assign_misc_editor(socket) do
MiscEditor.assign_socket(socket)
end
defp sync_layout(workbench, params) do
workbench
@@ -1136,11 +1443,20 @@ defmodule BDS.Desktop.ShellLive do
append_output_entry(socket, title, message, url)
end
defp apply_shell_command_result(socket, %{kind: "open_editor", route: route, title: title, subtitle: subtitle}) do
defp apply_shell_command_result(socket, %{kind: "open_editor", route: route, title: title, subtitle: subtitle} = result) do
route_atom = String.to_existing_atom(route)
tab_id = tab_id_for_route(route_atom, route)
workbench = Workbench.open_tab(socket.assigns.workbench, route_atom, tab_id, :pin)
tab_meta = Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{title: title, subtitle: subtitle})
tab_meta =
Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{
title: title,
subtitle: subtitle,
action: Map.get(result, :action),
payload: Map.get(result, :payload),
project_id: Map.get(result, :project_id),
editor_meta: Map.get(result, :editorMeta, [])
})
socket
|> assign(:tab_meta, tab_meta)
@@ -1173,6 +1489,22 @@ defmodule BDS.Desktop.ShellLive do
|> elem(0)
end
defp titlebar_menu_item_active?(group, item, current_index) do
cond do
is_nil(current_index) ->
false
Map.get(item, :separator, false) ->
false
true ->
group.items
|> Enum.reject(&Map.get(&1, :separator, false))
|> Enum.find_index(&(&1.id == item.id))
|> Kernel.==(current_index)
end
end
defp active_titlebar_menu_group(assigns) do
Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end)
end

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 %>
<%= if item.separator do %>
<div class="window-titlebar-menu-separator"></div>
<div class="window-titlebar-menu-separator" role="separator"></div>
<% else %>
<button
class={[
"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-menu-action={item.id}
@@ -362,32 +362,58 @@
</div>
</div>
<% else %>
<%= if @current_tab.type == :post and @post_editor do %>
<PostEditor.post_editor post_editor={@post_editor} toolbar_buttons={editor_toolbar_buttons(@current_tab)} />
<% else %>
<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>
<%= cond do %>
<% @current_tab.type == :post and @post_editor -> %>
<PostEditor.post_editor post_editor={@post_editor} toolbar_buttons={editor_toolbar_buttons(@current_tab)} />
<%= render_editor_toolbar(assigns) %>
<% @current_tab.type == :media and @media_editor -> %>
<MediaEditor.media_editor media_editor={@media_editor} />
<div class="editor-section">
<h2><%= tab_title(@current_tab, @tab_meta) %></h2>
<p>Desktop workbench content routed through the Elixir shell.</p>
</div>
</section>
<% @current_tab.type == :settings and @settings_editor -> %>
<SettingsEditor.settings_editor settings_editor={@settings_editor} />
<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>
<% @current_tab.type == :style and @style_editor -> %>
<SettingsEditor.style_editor style_editor={@style_editor} />
<% @current_tab.type == :tags and @tags_editor -> %>
<TagsEditor.tags_editor tags_editor={@tags_editor} />
<% @current_tab.type == :scripts and @script_editor -> %>
<CodeEntityEditor.script_editor script_editor={@script_editor} />
<% @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 %>
</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.{I18n, Metadata, Repo}
alias BDS.Media.Media
alias BDS.Media.Translation, as: MediaTranslation
alias BDS.Posts.{Post, Translation}
alias BDS.Tags.Tag
@@ -132,6 +133,17 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
_error -> %{}
end
defp existing_translations(%{type: :media, id: media_id}) do
Repo.all(
from translation in MediaTranslation,
where: translation.translation_for == ^media_id,
select: {translation.language, "draft"}
)
|> Map.new(fn {language, status} -> {language, status} end)
rescue
_error -> %{}
end
defp existing_translations(_tab), do: %{}
defp blog_languages(metadata) do
@@ -149,6 +161,15 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
_error -> metadata.main_language || "en"
end
defp source_language(%{type: :media, id: media_id}, metadata) do
case Repo.get(Media, media_id) do
%Media{language: language} when is_binary(language) and language != "" -> language
_other -> metadata.main_language || "en"
end
rescue
_error -> metadata.main_language || "en"
end
defp source_language(_tab, metadata), do: metadata.main_language || "en"
defp language_names do

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
def list_media_translations(media_id) when is_binary(media_id) do
Repo.all(
from translation in Translation,
where: translation.translation_for == ^media_id,
order_by: [asc: translation.language]
)
end
def list_linked_posts(media_id) when is_binary(media_id) do
Repo.all(
from post in BDS.Posts.Post,
join: post_media in "post_media",
on: post_media.post_id == post.id,
where: post_media.media_id == ^media_id,
order_by: [asc: post_media.sort_order, asc: post.updated_at],
select: %{
post_id: post.id,
title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id),
sort_order: post_media.sort_order
}
)
end
def link_media_to_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do
case {Repo.get(Media, media_id), Repo.get(BDS.Posts.Post, post_id)} do
{nil, _post} ->
{:error, :not_found}
{_media, nil} ->
{:error, :not_found}
{%Media{} = media, %BDS.Posts.Post{} = post} ->
project = Projects.get_project!(media.project_id)
Repo.transaction(fn ->
case Repo.query("SELECT 1 FROM post_media WHERE post_id = ? AND media_id = ? LIMIT 1", [post.id, media.id]) do
{:ok, %{rows: [[1]]}} ->
:already_linked
_other ->
sort_order = next_sort_order(media.id)
{:ok, _result} =
Repo.query(
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?)",
[Ecto.UUID.generate(), media.project_id, post.id, media.id, sort_order, Persistence.now_ms()]
)
:linked
end
:ok = write_sidecar(project, media)
:ok
end)
|> case do
{:ok, :ok} -> {:ok, :linked}
{:error, reason} -> {:error, reason}
end
end
end
def unlink_media_from_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do
case Repo.get(Media, media_id) do
nil ->
{:error, :not_found}
%Media{} = media ->
project = Projects.get_project!(media.project_id)
Repo.transaction(fn ->
{:ok, _result} = Repo.query("DELETE FROM post_media WHERE media_id = ? AND post_id = ?", [media.id, post_id])
:ok = write_sidecar(project, media)
:ok
end)
|> case do
{:ok, :ok} -> {:ok, :unlinked}
{:error, reason} -> {:error, reason}
end
end
end
def thumbnail_paths(%Media{id: id}) do
prefix = String.slice(id, 0, 2)
@@ -675,6 +756,13 @@ defmodule BDS.Media do
end
end
defp next_sort_order(media_id) do
case Repo.query("SELECT COALESCE(MAX(sort_order), -1) FROM post_media WHERE media_id = ?", [media_id]) do
{:ok, %{rows: [[value]]}} when is_integer(value) -> value + 1
_other -> 0
end
end
defp blank_to_nil(nil), do: nil
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value