feat: implementation of step 5 of the plan - still not fully done
This commit is contained in:
68
lib/bds/desktop/shell_live/chat_editor.ex
Normal file
68
lib/bds/desktop/shell_live/chat_editor.ex
Normal file
@@ -0,0 +1,68 @@
|
||||
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
alias BDS.{AI, Repo}
|
||||
alias BDS.AI.ChatConversation
|
||||
alias BDS.Desktop.ShellData
|
||||
|
||||
embed_templates "chat_editor_html/*"
|
||||
|
||||
def assign_socket(socket) do
|
||||
assign(socket, :chat_editor, build(socket.assigns))
|
||||
end
|
||||
|
||||
def update_input(socket, value, reload) do
|
||||
%{id: conversation_id} = socket.assigns.current_tab
|
||||
|
||||
socket
|
||||
|> assign(:chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || "")))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def send_message(socket, reload, append_output) do
|
||||
%{id: conversation_id} = socket.assigns.current_tab
|
||||
message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
|
||||
|
||||
cond do
|
||||
message == "" -> reload.(socket, socket.assigns.workbench)
|
||||
socket.assigns.offline_mode ->
|
||||
socket
|
||||
|> append_output.(translated("Chat"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
true ->
|
||||
case AI.send_chat_message(conversation_id, message, project_id: socket.assigns.projects.active_project_id) do
|
||||
{:ok, _result} ->
|
||||
socket
|
||||
|> assign(:chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, ""))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Chat"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
nil -> nil
|
||||
%ChatConversation{} = conversation ->
|
||||
%{
|
||||
id: conversation.id,
|
||||
title: conversation.title || translated("New Chat"),
|
||||
model: conversation.model,
|
||||
input: Map.get(assigns.chat_editor_inputs, conversation.id, ""),
|
||||
messages: AI.list_chat_messages(conversation.id),
|
||||
offline?: Map.get(assigns, :offline_mode, true)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build(_assigns), do: nil
|
||||
|
||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="chat-panel" data-testid="chat-editor">
|
||||
<div class="chat-panel-header">
|
||||
<div class="chat-panel-title"><%= @chat_editor.title %></div>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages">
|
||||
<%= if Enum.empty?(@chat_editor.messages) do %>
|
||||
<div class="chat-welcome">
|
||||
<div class="chat-welcome-icon">🤖</div>
|
||||
<h2><%= translated("New Chat") %></h2>
|
||||
<p><%= translated("Ask the assistant about the active project.") %></p>
|
||||
<ul>
|
||||
<li><%= translated("Search posts and media") %></li>
|
||||
<li><%= translated("Inspect metadata") %></li>
|
||||
<li><%= translated("Open related tabs") %></li>
|
||||
<li><%= translated("Review generated output") %></li>
|
||||
<li><%= translated("Navigate settings") %></li>
|
||||
</ul>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= for message <- @chat_editor.messages do %>
|
||||
<div class={["chat-message", to_string(message.role || "assistant")]}>
|
||||
<div class="chat-message-content">
|
||||
<div class="chat-message-header"><span class="chat-message-role"><%= String.capitalize(to_string(message.role || "assistant")) %></span></div>
|
||||
<div class="chat-message-text"><%= message.content || "" %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<form class="chat-input-wrapper" phx-change="change_chat_editor_input">
|
||||
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("Send a message")}><%= @chat_editor.input %></textarea>
|
||||
<button class="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={String.trim(@chat_editor.input || "") == "" or @chat_editor.offline?}>↑</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
268
lib/bds/desktop/shell_live/code_entity_editor.ex
Normal file
268
lib/bds/desktop/shell_live/code_entity_editor.ex
Normal file
@@ -0,0 +1,268 @@
|
||||
defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.{MCP, Repo, Scripts, Scripting, Templates}
|
||||
alias BDS.Scripts.Script
|
||||
alias BDS.Templates.Template
|
||||
|
||||
embed_templates "code_entity_editor_html/*"
|
||||
|
||||
def assign_socket(socket) do
|
||||
socket
|
||||
|> assign(:script_editor, build_script(socket.assigns))
|
||||
|> assign(:template_editor, build_template(socket.assigns))
|
||||
end
|
||||
|
||||
def update_script(socket, params, reload) do
|
||||
%{id: script_id} = socket.assigns.current_tab
|
||||
|
||||
socket
|
||||
|> assign(:script_editor_drafts, Map.put(socket.assigns.script_editor_drafts, script_id, normalize_script_params(params)))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def save_script(socket, reload, append_output) do
|
||||
%{id: script_id} = socket.assigns.current_tab
|
||||
|
||||
case Repo.get(Script, script_id) do
|
||||
nil -> reload.(socket, socket.assigns.workbench)
|
||||
%Script{} = script ->
|
||||
draft = current_script_draft(socket.assigns, script)
|
||||
|
||||
case Scripting.validate(draft["content"] || "") do
|
||||
:ok ->
|
||||
case Scripts.update_script(script.id, script_attrs(draft)) do
|
||||
{:ok, _updated} ->
|
||||
socket
|
||||
|> assign(:script_editor_drafts, Map.delete(socket.assigns.script_editor_drafts, script.id))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def check_script(socket, reload, append_output) do
|
||||
%{id: script_id} = socket.assigns.current_tab
|
||||
|
||||
case Repo.get(Script, script_id) do
|
||||
nil -> reload.(socket, socket.assigns.workbench)
|
||||
%Script{} = script ->
|
||||
case Scripting.validate(current_script_draft(socket.assigns, script)["content"] || "") do
|
||||
:ok -> append_output.(socket, translated("Scripts"), translated("Syntax is valid")) |> reload.(socket.assigns.workbench)
|
||||
{:error, reason} -> append_output.(socket, translated("Scripts"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def run_script(socket, reload, append_output) do
|
||||
%{id: script_id} = socket.assigns.current_tab
|
||||
|
||||
case Repo.get(Script, script_id) do
|
||||
nil -> reload.(socket, socket.assigns.workbench)
|
||||
%Script{} = script ->
|
||||
draft = current_script_draft(socket.assigns, script)
|
||||
|
||||
case Scripting.execute_project_script(script.project_id, draft["content"] || "", draft["entrypoint"] || "main", []) do
|
||||
{:ok, result} ->
|
||||
socket
|
||||
|> append_output.(translated("Scripts"), inspect(result))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_script(socket, reload, append_output) do
|
||||
%{id: script_id} = socket.assigns.current_tab
|
||||
|
||||
case Scripts.delete_script(script_id) do
|
||||
{:ok, _deleted} -> reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :scripts, script_id))
|
||||
{:error, reason} -> append_output.(socket, translated("Scripts"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def update_template(socket, params, reload) do
|
||||
%{id: template_id} = socket.assigns.current_tab
|
||||
|
||||
socket
|
||||
|> assign(:template_editor_drafts, Map.put(socket.assigns.template_editor_drafts, template_id, normalize_template_params(params)))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def save_template(socket, reload, append_output) do
|
||||
%{id: template_id} = socket.assigns.current_tab
|
||||
|
||||
case Repo.get(Template, template_id) do
|
||||
nil -> reload.(socket, socket.assigns.workbench)
|
||||
%Template{} = template ->
|
||||
draft = current_template_draft(socket.assigns, template)
|
||||
|
||||
with {:ok, %{valid: true}} <- MCP.validate_template(draft["content"] || ""),
|
||||
{:ok, _updated} <- Templates.update_template(template.id, template_attrs(draft)) do
|
||||
socket
|
||||
|> assign(:template_editor_drafts, Map.delete(socket.assigns.template_editor_drafts, template.id))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
{:ok, %{valid: false, errors: errors}} -> append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error") |> reload.(socket.assigns.workbench)
|
||||
{:error, reason} -> append_output.(socket, translated("Templates"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_template(socket, reload, append_output) do
|
||||
%{id: template_id} = socket.assigns.current_tab
|
||||
|
||||
case Repo.get(Template, template_id) do
|
||||
nil -> reload.(socket, socket.assigns.workbench)
|
||||
%Template{} = template ->
|
||||
case MCP.validate_template(current_template_draft(socket.assigns, template)["content"] || "") do
|
||||
{:ok, %{valid: true}} -> append_output.(socket, translated("Templates"), translated("Template syntax is valid")) |> reload.(socket.assigns.workbench)
|
||||
{:ok, %{valid: false, errors: errors}} -> append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error") |> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_template(socket, reload, append_output) do
|
||||
%{id: template_id} = socket.assigns.current_tab
|
||||
|
||||
case Templates.delete_template(template_id, force: true) do
|
||||
{:ok, _deleted} -> reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :templates, template_id))
|
||||
{:error, reason} -> append_output.(socket, translated("Templates"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do
|
||||
case Repo.get(Script, script_id) do
|
||||
nil -> nil
|
||||
%Script{} = script ->
|
||||
draft = current_script_draft(assigns, script)
|
||||
%{
|
||||
id: script.id,
|
||||
title: draft["title"],
|
||||
slug: draft["slug"],
|
||||
kind: draft["kind"],
|
||||
entrypoint: draft["entrypoint"],
|
||||
enabled: draft["enabled"],
|
||||
content: draft["content"],
|
||||
entrypoints: discover_entrypoints(draft["content"]),
|
||||
created_at: script.created_at,
|
||||
updated_at: script.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build_script(_assigns), do: nil
|
||||
|
||||
def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do
|
||||
case Repo.get(Template, template_id) do
|
||||
nil -> nil
|
||||
%Template{} = template ->
|
||||
draft = current_template_draft(assigns, template)
|
||||
%{
|
||||
id: template.id,
|
||||
title: draft["title"],
|
||||
slug: draft["slug"],
|
||||
kind: draft["kind"],
|
||||
enabled: draft["enabled"],
|
||||
content: draft["content"],
|
||||
created_at: template.created_at,
|
||||
updated_at: template.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build_template(_assigns), do: nil
|
||||
|
||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||
|
||||
def format_timestamp(nil), do: ""
|
||||
def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)
|
||||
|
||||
defp normalize_script_params(params) do
|
||||
%{
|
||||
"title" => Map.get(params, "title", ""),
|
||||
"slug" => Map.get(params, "slug", ""),
|
||||
"kind" => Map.get(params, "kind", "utility"),
|
||||
"entrypoint" => Map.get(params, "entrypoint", "main"),
|
||||
"enabled" => Map.get(params, "enabled") in [true, "true", "on", "1", 1],
|
||||
"content" => Map.get(params, "content", "")
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_template_params(params) do
|
||||
%{
|
||||
"title" => Map.get(params, "title", ""),
|
||||
"slug" => Map.get(params, "slug", ""),
|
||||
"kind" => Map.get(params, "kind", "post"),
|
||||
"enabled" => Map.get(params, "enabled") in [true, "true", "on", "1", 1],
|
||||
"content" => Map.get(params, "content", "")
|
||||
}
|
||||
end
|
||||
|
||||
defp current_script_draft(assigns, %Script{} = script) do
|
||||
Map.get(assigns.script_editor_drafts, script.id, %{
|
||||
"title" => script.title || "",
|
||||
"slug" => script.slug || "",
|
||||
"kind" => to_string(script.kind || :utility),
|
||||
"entrypoint" => script.entrypoint || "main",
|
||||
"enabled" => script.enabled != false,
|
||||
"content" => script.content || ""
|
||||
})
|
||||
end
|
||||
|
||||
defp current_template_draft(assigns, %Template{} = template) do
|
||||
Map.get(assigns.template_editor_drafts, template.id, %{
|
||||
"title" => template.title || "",
|
||||
"slug" => template.slug || "",
|
||||
"kind" => to_string(template.kind || :post),
|
||||
"enabled" => template.enabled != false,
|
||||
"content" => template.content || ""
|
||||
})
|
||||
end
|
||||
|
||||
defp script_attrs(draft) do
|
||||
%{
|
||||
title: draft["title"],
|
||||
slug: draft["slug"],
|
||||
kind: String.to_existing_atom(draft["kind"]),
|
||||
entrypoint: draft["entrypoint"],
|
||||
enabled: draft["enabled"],
|
||||
content: draft["content"]
|
||||
}
|
||||
rescue
|
||||
_error -> %{title: draft["title"], slug: draft["slug"], kind: :utility, entrypoint: draft["entrypoint"], enabled: draft["enabled"], content: draft["content"]}
|
||||
end
|
||||
|
||||
defp template_attrs(draft) do
|
||||
%{title: draft["title"], slug: draft["slug"], kind: normalize_template_kind(draft["kind"]), enabled: draft["enabled"], content: draft["content"]}
|
||||
end
|
||||
|
||||
defp normalize_template_kind("post"), do: :post
|
||||
defp normalize_template_kind("list"), do: :list
|
||||
defp normalize_template_kind("not-found"), do: :"not-found"
|
||||
defp normalize_template_kind("partial"), do: :partial
|
||||
defp normalize_template_kind(_kind), do: :post
|
||||
|
||||
defp discover_entrypoints(content) do
|
||||
["main" | Regex.scan(~r/function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/, content || "", capture: :all_but_first)
|
||||
|> List.flatten()
|
||||
|> Enum.reject(&(&1 == "main"))]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
<div class="scripts-view-shell" data-testid="script-editor">
|
||||
<div class="editor-header scripts-header">
|
||||
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @script_editor.title %></span></div></div>
|
||||
<div class="editor-actions">
|
||||
<button class="scripts-save-button" type="button" phx-click="save_script_editor"><%= translated("Save") %></button>
|
||||
<button class="scripts-run-button" type="button" phx-click="run_script_editor"><%= translated("Run") %></button>
|
||||
<button class="scripts-check-button" type="button" phx-click="check_script_editor"><%= translated("Check Syntax") %></button>
|
||||
<button class="secondary danger" type="button" phx-click="delete_script_editor"><%= translated("Delete") %></button>
|
||||
</div>
|
||||
</div>
|
||||
<form class="editor-content scripts-view" phx-change="change_script_editor">
|
||||
<div class="editor-header-row scripts-meta-row">
|
||||
<div class="editor-meta">
|
||||
<div class="editor-field-row">
|
||||
<div class="editor-field"><label><%= translated("Title") %></label><input type="text" name="script_editor[title]" value={@script_editor.title} /></div>
|
||||
<div class="editor-field"><label><%= translated("Slug") %></label><input type="text" name="script_editor[slug]" value={@script_editor.slug} /></div>
|
||||
</div>
|
||||
<div class="editor-field-row">
|
||||
<div class="editor-field"><label><%= translated("Kind") %></label><select name="script_editor[kind]"><option value="utility" selected={@script_editor.kind == "utility"}>utility</option><option value="macro" selected={@script_editor.kind == "macro"}>macro</option><option value="transform" selected={@script_editor.kind == "transform"}>transform</option></select></div>
|
||||
<div class="editor-field"><label><%= translated("Entrypoint") %></label><select name="script_editor[entrypoint]"><%= for entrypoint <- @script_editor.entrypoints do %><option value={entrypoint} selected={entrypoint == @script_editor.entrypoint}><%= entrypoint %></option><% end %></select></div>
|
||||
<div class="editor-field scripts-enabled-field"><label><input type="checkbox" name="script_editor[enabled]" checked={@script_editor.enabled} /> <%= translated("Enabled") %></label></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-body scripts-editor">
|
||||
<div class="editor-toolbar scripts-toolbar"><div class="editor-toolbar-left"><label><%= translated("Content") %></label></div></div>
|
||||
<div class="scripts-monaco"><textarea class="code-editor-textarea" name="script_editor[content]"><%= @script_editor.content %></textarea></div>
|
||||
</div>
|
||||
<div class="editor-footer"><span class="text-muted text-small"><%= translated("Created") %>: <%= format_timestamp(@script_editor.created_at) %></span><span class="text-muted text-small"><%= translated("Updated") %>: <%= format_timestamp(@script_editor.updated_at) %></span></div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<div class="templates-view-shell" data-testid="template-editor">
|
||||
<div class="editor-header templates-header">
|
||||
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @template_editor.title %></span></div></div>
|
||||
<div class="editor-actions">
|
||||
<button class="templates-save-button" type="button" phx-click="save_template_editor"><%= translated("Save") %></button>
|
||||
<button class="templates-validate-button" type="button" phx-click="validate_template_editor"><%= translated("Validate") %></button>
|
||||
<button class="secondary danger" type="button" phx-click="delete_template_editor"><%= translated("Delete") %></button>
|
||||
</div>
|
||||
</div>
|
||||
<form class="editor-content templates-view" phx-change="change_template_editor">
|
||||
<div class="editor-header-row templates-meta-row">
|
||||
<div class="editor-meta">
|
||||
<div class="editor-field-row">
|
||||
<div class="editor-field"><label><%= translated("Title") %></label><input type="text" name="template_editor[title]" value={@template_editor.title} /></div>
|
||||
<div class="editor-field"><label><%= translated("Slug") %></label><input type="text" name="template_editor[slug]" value={@template_editor.slug} /></div>
|
||||
</div>
|
||||
<div class="editor-field-row">
|
||||
<div class="editor-field"><label><%= translated("Kind") %></label><select name="template_editor[kind]"><option value="post" selected={@template_editor.kind == :post or @template_editor.kind == "post"}>post</option><option value="list" selected={@template_editor.kind == :list or @template_editor.kind == "list"}>list</option><option value="not-found" selected={@template_editor.kind == :"not-found" or @template_editor.kind == "not-found"}>not-found</option><option value="partial" selected={@template_editor.kind == :partial or @template_editor.kind == "partial"}>partial</option></select></div>
|
||||
<div class="editor-field templates-enabled-field"><label><input type="checkbox" name="template_editor[enabled]" checked={@template_editor.enabled} /> <%= translated("Enabled") %></label></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-body templates-editor">
|
||||
<div class="editor-toolbar templates-toolbar"><div class="editor-toolbar-left"><label><%= translated("Content") %></label></div></div>
|
||||
<div class="templates-monaco"><textarea class="code-editor-textarea" name="template_editor[content]" spellcheck="false"><%= @template_editor.content %></textarea></div>
|
||||
</div>
|
||||
<div class="editor-footer"><span class="text-muted text-small"><%= translated("Created") %>: <%= format_timestamp(@template_editor.created_at) %></span><span class="text-muted text-small"><%= translated("Updated") %>: <%= format_timestamp(@template_editor.updated_at) %></span></div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -43,12 +43,12 @@
|
||||
>
|
||||
<%= for item <- titlebar_menu_dropdown_items(group) do %>
|
||||
<%= 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>
|
||||
|
||||
570
lib/bds/desktop/shell_live/media_editor.ex
Normal file
570
lib/bds/desktop/shell_live/media_editor.ex
Normal file
@@ -0,0 +1,570 @@
|
||||
defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.Desktop.{FilePicker, ShellData}
|
||||
alias BDS.{AI, I18n, Media, Repo}
|
||||
alias BDS.Media.Media, as: MediaRecord
|
||||
alias BDS.Media.Translation
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
embed_templates "media_editor_html/*"
|
||||
|
||||
@post_picker_limit 10
|
||||
|
||||
def assign_socket(socket) do
|
||||
assign(socket, :media_editor, build(socket.assigns))
|
||||
end
|
||||
|
||||
def update(socket, params, reload) do
|
||||
case socket.assigns.current_tab do
|
||||
%{type: :media, id: media_id} ->
|
||||
case Repo.get(MediaRecord, media_id) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%MediaRecord{} = media ->
|
||||
draft = normalize_params(params)
|
||||
socket |> reconcile_draft(media, draft) |> reload_with_assigned_workbench(reload)
|
||||
end
|
||||
|
||||
_other ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
def persist_socket(socket, media_id, reload, append_output) do
|
||||
case Repo.get(MediaRecord, media_id) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%MediaRecord{} = media ->
|
||||
draft = current_draft(socket.assigns, media)
|
||||
|
||||
case persist(media, draft) do
|
||||
{:ok, updated_media} ->
|
||||
workbench = Workbench.clear_dirty(socket.assigns.workbench, :media, media_id)
|
||||
|
||||
socket
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|
||||
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved))
|
||||
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media)))
|
||||
|> reload.(workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Media"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_quick_actions(socket, media_id, reload) do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
socket
|
||||
|> assign(:media_editor_quick_actions_open, Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1)))
|
||||
|> reload.(workbench)
|
||||
end
|
||||
|
||||
def replace_file(socket, media_id, reload, append_output) do
|
||||
case FilePicker.choose_file(translated("Replace Media File")) do
|
||||
{:ok, source_path} ->
|
||||
case Media.replace_media_file(media_id, source_path) do
|
||||
{:ok, %MediaRecord{} = updated_media} ->
|
||||
workbench = Workbench.clear_dirty(socket.assigns.workbench, :media, media_id)
|
||||
|
||||
socket
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|
||||
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved))
|
||||
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media)))
|
||||
|> reload.(workbench)
|
||||
|
||||
{:ok, nil} ->
|
||||
socket |> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Replace File"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
:cancel ->
|
||||
socket
|
||||
|
||||
{:error, %{message: message}} ->
|
||||
socket
|
||||
|> append_output.(translated("Replace File"), message, nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def detect_language(socket, media_id, reload, append_output) do
|
||||
if Map.get(socket.assigns, :offline_mode, true) do
|
||||
socket
|
||||
|> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
case Repo.get(MediaRecord, media_id) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%MediaRecord{} = media ->
|
||||
draft = current_draft(socket.assigns, media)
|
||||
text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "alt", ""), Map.get(draft, "caption", "")], "\n\n")
|
||||
|
||||
case AI.detect_language(text) do
|
||||
{:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" ->
|
||||
normalized = normalize_language(language_code)
|
||||
|
||||
case Media.update_media(media.id, %{language: normalized}) do
|
||||
{:ok, updated_media} ->
|
||||
updated_draft = Map.put(current_draft(socket.assigns, media), "language", normalized)
|
||||
|
||||
socket
|
||||
|> reconcile_draft(updated_media, updated_draft)
|
||||
|> reload_with_assigned_workbench(reload)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Detect Language"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Detect Language"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
_other ->
|
||||
socket
|
||||
|> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def translate(socket, media_id, language, reload, append_output) do
|
||||
if Map.get(socket.assigns, :offline_mode, true) do
|
||||
socket
|
||||
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
normalized_language = normalize_language(language)
|
||||
|
||||
case AI.translate_media(media_id, normalized_language) do
|
||||
{:ok, translation} ->
|
||||
case Media.upsert_media_translation(media_id, normalized_language, translation) do
|
||||
{:ok, _saved_translation} ->
|
||||
socket
|
||||
|> assign(:media_editor_quick_actions_open, Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false))
|
||||
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Translate"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Translate"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do
|
||||
try do
|
||||
case Repo.get(MediaRecord, media_id) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%MediaRecord{} = media ->
|
||||
attrs =
|
||||
Enum.reduce(fields, current_draft(socket.assigns, media), fn field, acc ->
|
||||
case field.key do
|
||||
"title" -> Map.put(acc, "title", field.suggested_value)
|
||||
"alt" -> Map.put(acc, "alt", field.suggested_value)
|
||||
"caption" -> Map.put(acc, "caption", field.suggested_value)
|
||||
_other -> acc
|
||||
end
|
||||
end)
|
||||
|
||||
socket
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> reconcile_draft(media, attrs)
|
||||
|> reload_with_assigned_workbench(reload)
|
||||
end
|
||||
rescue
|
||||
error ->
|
||||
socket
|
||||
|> append_output.(translated("AI Suggestions"), inspect(error), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_socket(socket, media_id, reload, append_output) do
|
||||
case Media.delete_media(media_id) do
|
||||
{:ok, :deleted} ->
|
||||
workbench = Workbench.close_tab(socket.assigns.workbench, :media, media_id)
|
||||
|
||||
socket
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|
||||
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|
||||
|> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id))
|
||||
|> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id))
|
||||
|> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id))
|
||||
|> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id))
|
||||
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
||||
|> reload.(workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Delete Media"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_post_picker(socket, media_id, reload) do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
socket
|
||||
|> assign(:media_editor_post_pickers_open, Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1)))
|
||||
|> reload.(workbench)
|
||||
end
|
||||
|
||||
def set_post_picker_query(socket, media_id, query, reload) do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
socket
|
||||
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, to_string(query || "")))
|
||||
|> reload.(workbench)
|
||||
end
|
||||
|
||||
def link_post(socket, media_id, post_id, reload, append_output) do
|
||||
case Media.link_media_to_post(media_id, post_id) do
|
||||
{:ok, _linked} ->
|
||||
socket
|
||||
|> assign(:media_editor_post_pickers_open, Map.put(socket.assigns.media_editor_post_pickers_open, media_id, false))
|
||||
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, ""))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Link to Post"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def unlink_post(socket, media_id, post_id, reload, append_output) do
|
||||
case Media.unlink_media_from_post(media_id, post_id) do
|
||||
{:ok, _unlinked} ->
|
||||
socket |> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Unlink from Post"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def edit_translation(socket, media_id, language, reload) do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
translation = Repo.get_by(Translation, translation_for: media_id, language: language)
|
||||
|
||||
form = %{
|
||||
"language" => language,
|
||||
"title" => translation && translation.title || "",
|
||||
"alt" => translation && translation.alt || "",
|
||||
"caption" => translation && translation.caption || ""
|
||||
}
|
||||
|
||||
socket
|
||||
|> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form))
|
||||
|> reload.(workbench)
|
||||
end
|
||||
|
||||
def update_translation(socket, media_id, params, reload) do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
form = %{
|
||||
"language" => Map.get(params, "language", ""),
|
||||
"title" => Map.get(params, "title", ""),
|
||||
"alt" => Map.get(params, "alt", ""),
|
||||
"caption" => Map.get(params, "caption", "")
|
||||
}
|
||||
|
||||
socket
|
||||
|> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form))
|
||||
|> reload.(workbench)
|
||||
end
|
||||
|
||||
def save_translation(socket, media_id, reload, append_output) do
|
||||
case Map.get(socket.assigns.media_editor_translation_forms, media_id) do
|
||||
%{"language" => language} = form when language not in [nil, ""] ->
|
||||
case Media.upsert_media_translation(media_id, language, %{
|
||||
title: blank_to_nil(Map.get(form, "title")),
|
||||
alt: blank_to_nil(Map.get(form, "alt")),
|
||||
caption: blank_to_nil(Map.get(form, "caption"))
|
||||
}) do
|
||||
{:ok, _translation} ->
|
||||
socket
|
||||
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Save Translation"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
_other ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
def refresh_translation(socket, media_id, language, reload, append_output) do
|
||||
if Map.get(socket.assigns, :offline_mode, true) do
|
||||
socket
|
||||
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
case AI.translate_media(media_id, normalize_language(language)) do
|
||||
{:ok, translation} ->
|
||||
case Media.upsert_media_translation(media_id, language, translation) do
|
||||
{:ok, _saved_translation} -> socket |> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Refresh Translation"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Refresh Translation"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_translation(socket, media_id, language, reload, append_output) do
|
||||
case Media.delete_media_translation(media_id, language) do
|
||||
{:ok, _deleted?} ->
|
||||
socket
|
||||
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Delete Translation"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do
|
||||
case Repo.get(MediaRecord, media_id) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
%MediaRecord{} = media ->
|
||||
linked_posts = Media.list_linked_posts(media.id)
|
||||
translations = Media.list_media_translations(media.id)
|
||||
form = current_draft(assigns, media)
|
||||
picker_query = Map.get(assigns.media_editor_post_picker_queries, media.id, "")
|
||||
{picker_results, picker_overflow_count} = post_picker_results(media, linked_posts, picker_query)
|
||||
|
||||
%{
|
||||
id: media.id,
|
||||
display_title: display_title(media),
|
||||
original_name: media.original_name || media.filename || media.id,
|
||||
mime_type: media.mime_type || "application/octet-stream",
|
||||
file_size: format_file_size(media.size),
|
||||
dimensions: dimensions_label(media),
|
||||
is_image: image?(media),
|
||||
preview_url: preview_url(media),
|
||||
dirty?: Workbench.dirty?(assigns.workbench, :media, media.id),
|
||||
save_state: Map.get(assigns.media_editor_save_states, media.id, :idle),
|
||||
quick_actions_open?: Map.get(assigns.media_editor_quick_actions_open, media.id, false),
|
||||
post_picker_open?: Map.get(assigns.media_editor_post_pickers_open, media.id, false),
|
||||
post_picker_query: picker_query,
|
||||
post_picker_results: picker_results,
|
||||
post_picker_overflow_count: picker_overflow_count,
|
||||
form: form,
|
||||
languages: language_codes(),
|
||||
translations: Enum.map(translations, &translation_view/1),
|
||||
editing_translation: Map.get(assigns.media_editor_translation_forms, media.id),
|
||||
linked_posts: linked_posts,
|
||||
can_detect_language?: detect_language_enabled?(form),
|
||||
can_translate?: form["language"] not in [nil, ""]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build(_assigns), do: nil
|
||||
|
||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||
|
||||
def media_editor_save_state_label(:dirty), do: translated("Unsaved")
|
||||
def media_editor_save_state_label(:saved), do: translated("Saved")
|
||||
def media_editor_save_state_label(_state), do: translated("Idle")
|
||||
|
||||
def language_label(code) do
|
||||
code
|
||||
|> to_string()
|
||||
|> String.upcase()
|
||||
end
|
||||
|
||||
def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase()
|
||||
|
||||
def persist(%MediaRecord{} = media, draft) do
|
||||
Media.update_media(media.id, %{
|
||||
title: blank_to_nil(Map.get(draft, "title")),
|
||||
alt: blank_to_nil(Map.get(draft, "alt")),
|
||||
caption: blank_to_nil(Map.get(draft, "caption")),
|
||||
author: blank_to_nil(Map.get(draft, "author")),
|
||||
language: blank_to_nil(Map.get(draft, "language")),
|
||||
tags: csv_to_list(Map.get(draft, "tags"))
|
||||
})
|
||||
end
|
||||
|
||||
defp reconcile_draft(socket, %MediaRecord{} = media, draft) do
|
||||
persisted = persisted_form(media)
|
||||
dirty? = draft != persisted
|
||||
workbench = if dirty?, do: Workbench.mark_dirty(socket.assigns.workbench, :media, media.id), else: Workbench.clear_dirty(socket.assigns.workbench, :media, media.id)
|
||||
|
||||
drafts =
|
||||
if dirty? do
|
||||
Map.put(socket.assigns.media_editor_drafts, media.id, draft)
|
||||
else
|
||||
Map.delete(socket.assigns.media_editor_drafts, media.id)
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:media_editor_drafts, drafts)
|
||||
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media.id, if(dirty?, do: :dirty, else: :idle)))
|
||||
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media.id}, %{title: blank_to_nil(Map.get(draft, "title")) || display_title(media), subtitle: media.original_name || media.mime_type || ""}))
|
||||
end
|
||||
|
||||
defp current_draft(assigns, %MediaRecord{} = media) do
|
||||
Map.get(assigns.media_editor_drafts, media.id, persisted_form(media))
|
||||
end
|
||||
|
||||
defp persisted_form(%MediaRecord{} = media) do
|
||||
%{
|
||||
"title" => media.title || "",
|
||||
"alt" => media.alt || "",
|
||||
"caption" => media.caption || "",
|
||||
"tags" => Enum.join(media.tags || [], ", "),
|
||||
"author" => media.author || "",
|
||||
"language" => media.language || ""
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_params(params) do
|
||||
%{
|
||||
"title" => Map.get(params, "title", ""),
|
||||
"alt" => Map.get(params, "alt", ""),
|
||||
"caption" => Map.get(params, "caption", ""),
|
||||
"tags" => Map.get(params, "tags", ""),
|
||||
"author" => Map.get(params, "author", ""),
|
||||
"language" => Map.get(params, "language", "")
|
||||
}
|
||||
end
|
||||
|
||||
defp translation_view(%Translation{} = translation) do
|
||||
%{
|
||||
language: translation.language,
|
||||
flag: I18n.flag(translation.language),
|
||||
title: translation.title,
|
||||
alt: translation.alt,
|
||||
caption: translation.caption
|
||||
}
|
||||
end
|
||||
|
||||
defp post_picker_results(%MediaRecord{} = media, linked_posts, query) do
|
||||
linked_ids = MapSet.new(Enum.map(linked_posts, & &1.post_id))
|
||||
normalized_query = normalize_query(query)
|
||||
|
||||
posts =
|
||||
Repo.all(
|
||||
from post in Post,
|
||||
where: post.project_id == ^media.project_id,
|
||||
order_by: [desc: post.updated_at, desc: post.created_at],
|
||||
select: %{post_id: post.id, title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id)}
|
||||
)
|
||||
|> Enum.reject(&MapSet.member?(linked_ids, &1.post_id))
|
||||
|> Enum.filter(fn post -> normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query) end)
|
||||
|
||||
{Enum.take(posts, @post_picker_limit), max(length(posts) - @post_picker_limit, 0)}
|
||||
end
|
||||
|
||||
defp tab_meta(%MediaRecord{} = media) do
|
||||
%{title: display_title(media), subtitle: media.original_name || media.mime_type || ""}
|
||||
end
|
||||
|
||||
defp preview_url(%MediaRecord{} = media) do
|
||||
if image?(media), do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}", else: nil
|
||||
end
|
||||
|
||||
defp image?(%MediaRecord{} = media), do: String.starts_with?(to_string(media.mime_type || ""), "image/")
|
||||
|
||||
defp display_title(%MediaRecord{} = media), do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id
|
||||
|
||||
defp dimensions_label(%MediaRecord{width: width, height: height}) when is_integer(width) and is_integer(height), do: "#{width} x #{height}"
|
||||
defp dimensions_label(_media), do: nil
|
||||
|
||||
defp format_file_size(size) when is_integer(size) and size >= 1_048_576, do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB"
|
||||
defp format_file_size(size) when is_integer(size), do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
|
||||
defp format_file_size(_size), do: "0.0 KB"
|
||||
|
||||
defp detect_language_enabled?(form) do
|
||||
[Map.get(form, "title"), Map.get(form, "alt"), Map.get(form, "caption")]
|
||||
|> Enum.any?(&(blank_to_nil(&1) != nil))
|
||||
end
|
||||
|
||||
defp language_codes do
|
||||
I18n.supported_languages()
|
||||
|> Enum.map(& &1.code)
|
||||
end
|
||||
|
||||
defp normalize_query(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp csv_to_list(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
end
|
||||
|
||||
defp blank_to_nil(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> String.trim()
|
||||
|> case do
|
||||
"" -> nil
|
||||
trimmed -> trimmed
|
||||
end
|
||||
end
|
||||
|
||||
defp reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench)
|
||||
end
|
||||
@@ -0,0 +1,303 @@
|
||||
<div class="media-editor editor" data-testid="media-editor">
|
||||
<div class="editor-header">
|
||||
<div class="editor-tabs">
|
||||
<div class={[
|
||||
"editor-tab",
|
||||
"active",
|
||||
if(@media_editor.dirty?, do: "dirty")
|
||||
]}>
|
||||
<span class="editor-tab-title" data-testid="editor-title"><%= @media_editor.display_title %></span>
|
||||
<%= if @media_editor.dirty? do %>
|
||||
<span class="editor-tab-dirty" title={translated("Unsaved")}>●</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<%= if @media_editor.save_state in [:dirty, :saved] do %>
|
||||
<span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span>
|
||||
<% end %>
|
||||
|
||||
<div class="quick-actions-wrapper">
|
||||
<button
|
||||
class="secondary quick-actions-btn"
|
||||
type="button"
|
||||
phx-click="toggle_media_editor_quick_actions"
|
||||
phx-value-id={@media_editor.id}
|
||||
>
|
||||
<%= translated("Quick Actions") %>
|
||||
</button>
|
||||
|
||||
<%= if @media_editor.quick_actions_open? do %>
|
||||
<div class="quick-actions-menu">
|
||||
<%= if @media_editor.is_image do %>
|
||||
<button
|
||||
class="quick-action-item"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="ai_suggestions"
|
||||
>
|
||||
<span class="quick-action-icon">🤖</span>
|
||||
<span class="quick-action-text">
|
||||
<strong><%= translated("AI Suggestions") %></strong>
|
||||
<small><%= translated("Review title, alt text, and caption suggestions") %></small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="quick-actions-divider"></div>
|
||||
<% end %>
|
||||
|
||||
<button
|
||||
class="quick-action-item"
|
||||
type="button"
|
||||
phx-click="detect_media_editor_language"
|
||||
phx-value-id={@media_editor.id}
|
||||
disabled={not @media_editor.can_detect_language?}
|
||||
>
|
||||
<span class="quick-action-icon">🔍</span>
|
||||
<span class="quick-action-text">
|
||||
<strong><%= translated("Detect Language") %></strong>
|
||||
<small><%= translated("Persist the detected language for this media item") %></small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="quick-actions-divider"></div>
|
||||
|
||||
<button
|
||||
class="quick-action-item"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="language_picker"
|
||||
disabled={not @media_editor.can_translate?}
|
||||
>
|
||||
<span class="quick-action-icon">🌍</span>
|
||||
<span class="quick-action-text">
|
||||
<strong><%= translated("Translate") %></strong>
|
||||
<small><%= translated("Select a target language for this media item") %></small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<button class="secondary" type="button" phx-click="replace_media_editor_file" phx-value-id={@media_editor.id}>
|
||||
<%= translated("Replace File") %>
|
||||
</button>
|
||||
<button data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-value-id={@media_editor.id}>
|
||||
<%= translated("Save") %>
|
||||
</button>
|
||||
<button
|
||||
class="secondary danger"
|
||||
data-testid="media-delete-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="confirm_delete"
|
||||
>
|
||||
<%= translated("Delete") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content media-editor">
|
||||
<div class="media-preview">
|
||||
<%= if @media_editor.is_image and @media_editor.preview_url do %>
|
||||
<div class="media-preview-image">
|
||||
<img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} />
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="media-preview-placeholder">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"></path>
|
||||
</svg>
|
||||
<span><%= @media_editor.original_name %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<form class="media-details media-editor-details-form" data-testid="media-editor-form" phx-change="change_media_editor">
|
||||
<div class="editor-field">
|
||||
<label><%= translated("File Name") %></label>
|
||||
<input class="post-editor-input is-readonly" type="text" value={@media_editor.original_name} readonly />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("MIME Type") %></label>
|
||||
<input class="post-editor-input is-readonly" type="text" value={@media_editor.mime_type} readonly />
|
||||
</div>
|
||||
|
||||
<div class="editor-field-row">
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Size") %></label>
|
||||
<input class="post-editor-input is-readonly" type="text" value={@media_editor.file_size} readonly />
|
||||
</div>
|
||||
|
||||
<%= if @media_editor.dimensions do %>
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Dimensions") %></label>
|
||||
<input class="post-editor-input is-readonly" type="text" value={@media_editor.dimensions} readonly />
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Title") %></label>
|
||||
<input class="post-editor-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Alt Text") %></label>
|
||||
<input class="post-editor-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Caption") %></label>
|
||||
<textarea class="post-editor-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea>
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Tags") %></label>
|
||||
<input class="post-editor-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Author") %></label>
|
||||
<input class="post-editor-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Language") %></label>
|
||||
<select class="post-editor-input" name="media_editor[language]">
|
||||
<option value=""><%= translated("None") %></option>
|
||||
<%= for language <- @media_editor.languages do %>
|
||||
<option value={language} selected={language == @media_editor.form["language"]}><%= language_label(language) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<%= if @media_editor.form["language"] not in [nil, ""] do %>
|
||||
<div class="editor-field media-translations-section">
|
||||
<label><%= translated("Translations") %></label>
|
||||
|
||||
<%= if Enum.empty?(@media_editor.translations) do %>
|
||||
<div class="no-linked-posts"><%= translated("No translations") %></div>
|
||||
<% else %>
|
||||
<div class="linked-posts-list">
|
||||
<%= for translation <- @media_editor.translations do %>
|
||||
<div class="linked-post-item">
|
||||
<button
|
||||
class="linked-post-title linked-post-link"
|
||||
type="button"
|
||||
phx-click="edit_media_translation"
|
||||
phx-value-id={@media_editor.id}
|
||||
phx-value-language={translation.language}
|
||||
>
|
||||
<%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " - #{translation.title}" %>
|
||||
</button>
|
||||
<button class="secondary compact" type="button" phx-click="refresh_media_translation" phx-value-id={@media_editor.id} phx-value-language={translation.language}>
|
||||
<%= translated("Refresh") %>
|
||||
</button>
|
||||
<button class="unlink-btn" type="button" phx-click="delete_media_translation" phx-value-id={@media_editor.id} phx-value-language={translation.language}>×</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="editor-field linked-posts-section">
|
||||
<label>
|
||||
<%= translated("Linked Posts") %>
|
||||
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-value-id={@media_editor.id}>
|
||||
<%= translated("Link to Post") %>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<%= if @media_editor.post_picker_open? do %>
|
||||
<div class="post-picker">
|
||||
<div class="post-picker-search">
|
||||
<input
|
||||
type="text"
|
||||
name="media_post_picker[query]"
|
||||
value={@media_editor.post_picker_query}
|
||||
placeholder={translated("Search posts")}
|
||||
phx-change="change_media_post_picker"
|
||||
phx-value-id={@media_editor.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if Enum.empty?(@media_editor.post_picker_results) do %>
|
||||
<div class="no-posts"><%= translated("No posts to link") %></div>
|
||||
<% else %>
|
||||
<div class="post-picker-list">
|
||||
<%= for result <- @media_editor.post_picker_results do %>
|
||||
<button class="post-picker-item" type="button" phx-click="link_media_to_post" phx-value-id={@media_editor.id} phx-value-post-id={result.post_id}>
|
||||
<%= result.title %>
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if @media_editor.post_picker_overflow_count > 0 do %>
|
||||
<div class="post-picker-more"><%= translated("and %{count} more", %{count: @media_editor.post_picker_overflow_count}) %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.empty?(@media_editor.linked_posts) do %>
|
||||
<div class="no-linked-posts"><%= translated("Not linked to any posts") %></div>
|
||||
<% else %>
|
||||
<div class="linked-posts-list">
|
||||
<%= for linked_post <- @media_editor.linked_posts do %>
|
||||
<div class="linked-post-item">
|
||||
<button
|
||||
class="linked-post-title linked-post-link"
|
||||
type="button"
|
||||
phx-click="pin_sidebar_item"
|
||||
phx-value-route="post"
|
||||
phx-value-id={linked_post.post_id}
|
||||
phx-value-title={linked_post.title}
|
||||
phx-value-subtitle="linked post"
|
||||
>
|
||||
📄 <%= linked_post.title %>
|
||||
</button>
|
||||
<button class="unlink-btn" type="button" phx-click="unlink_media_from_post" phx-value-id={@media_editor.id} phx-value-post-id={linked_post.post_id}>×</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @media_editor.editing_translation do %>
|
||||
<div class="translation-modal-backdrop">
|
||||
<div class="translation-modal">
|
||||
<div class="translation-modal-header">
|
||||
<h2><%= translated("Edit Translation") %></h2>
|
||||
<button class="translation-modal-close" type="button" phx-click="close_media_translation_editor">×</button>
|
||||
</div>
|
||||
<form class="translation-modal-body" phx-change="change_media_translation">
|
||||
<input type="hidden" name="media_translation[language]" value={@media_editor.editing_translation["language"]} />
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Title") %></label>
|
||||
<input class="post-editor-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} />
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Alt Text") %></label>
|
||||
<input class="post-editor-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} />
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Caption") %></label>
|
||||
<textarea class="post-editor-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<div class="translation-modal-footer">
|
||||
<button class="secondary" type="button" phx-click="close_media_translation_editor"><%= translated("Cancel") %></button>
|
||||
<button type="button" phx-click="save_media_translation" phx-value-id={@media_editor.id}><%= translated("Save") %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
256
lib/bds/desktop/shell_live/misc_editor.ex
Normal file
256
lib/bds/desktop/shell_live/misc_editor.ex
Normal file
@@ -0,0 +1,256 @@
|
||||
defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
alias BDS.{Embeddings, Generation, Git}
|
||||
alias BDS.Desktop.ShellData
|
||||
|
||||
embed_templates "misc_editor_html/*"
|
||||
|
||||
@misc_routes [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff]
|
||||
|
||||
def assign_socket(socket) do
|
||||
assign(socket, :misc_editor, build(socket.assigns))
|
||||
end
|
||||
|
||||
def rerun(socket) do
|
||||
case meta(socket.assigns) do
|
||||
%{action: action} when is_binary(action) -> {:command, action}
|
||||
_other -> {:noop, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def apply_site_validation(socket, append_output) do
|
||||
meta = meta(socket.assigns)
|
||||
payload = Map.get(meta, :payload, %{})
|
||||
project_id = Map.get(meta, :project_id, socket.assigns.projects.active_project_id)
|
||||
sections = Enum.map(Map.get(payload, :sections, []), &String.to_existing_atom/1)
|
||||
|
||||
case Generation.apply_validation(project_id, sections) do
|
||||
{:ok, result} ->
|
||||
{:rerun,
|
||||
socket
|
||||
|> append_output.(translated("Site Validation"), translated("Validation changes applied"), inspect(result))}
|
||||
|
||||
{:error, reason} -> {:socket, append_output.(socket, translated("Site Validation"), inspect(reason), nil, "error")}
|
||||
end
|
||||
rescue
|
||||
error -> {:socket, append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")}
|
||||
end
|
||||
|
||||
def toggle_duplicate(socket, pair_id, reload) do
|
||||
selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{})
|
||||
current = Map.get(selected_by_tab, socket.assigns.current_tab.id, MapSet.new())
|
||||
|
||||
next =
|
||||
if MapSet.member?(current, pair_id) do
|
||||
MapSet.delete(current, pair_id)
|
||||
else
|
||||
MapSet.put(current, pair_id)
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:misc_editor_selected_pairs, Map.put(selected_by_tab, socket.assigns.current_tab.id, next))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def dismiss_duplicate(socket, post_id_a, post_id_b, reload, append_output) do
|
||||
case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do
|
||||
{:ok, _saved_pair} ->
|
||||
socket
|
||||
|> update_payload(fn payload ->
|
||||
update_in(payload[:pairs], fn pairs ->
|
||||
Enum.reject(pairs || [], fn pair -> pair_identity(pair) == pair_id(post_id_a, post_id_b) end)
|
||||
end)
|
||||
end)
|
||||
|> clear_selected_pair(pair_id(post_id_a, post_id_b))
|
||||
|> append_output.(translated("Find Duplicates"), translated("Pair dismissed"))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Find Duplicates"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def dismiss_selected(socket, reload, append_output) do
|
||||
tab_id = socket.assigns.current_tab.id
|
||||
selected =
|
||||
socket.assigns.misc_editor_selected_pairs
|
||||
|> Map.get(tab_id, MapSet.new())
|
||||
|> MapSet.to_list()
|
||||
|> Enum.map(&decode_pair_id/1)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
case Embeddings.dismiss_duplicate_pairs(selected) do
|
||||
{:ok, _saved_pairs} ->
|
||||
socket
|
||||
|> update_payload(fn payload ->
|
||||
update_in(payload[:pairs], fn pairs ->
|
||||
Enum.reject(pairs || [], fn pair -> pair_identity(pair) in selected end)
|
||||
end)
|
||||
end)
|
||||
|> assign(:misc_editor_selected_pairs, Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new()))
|
||||
|> append_output.(translated("Find Duplicates"), translated("Selected pairs dismissed"))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Find Duplicates"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do
|
||||
meta = meta(assigns)
|
||||
payload = Map.get(meta, :payload, %{})
|
||||
|
||||
case type do
|
||||
:site_validation -> build_site_validation(meta, payload)
|
||||
:metadata_diff -> build_metadata_diff(meta, payload)
|
||||
:translation_validation -> build_translation_validation(meta, payload)
|
||||
:find_duplicates -> build_duplicates(assigns, meta, payload)
|
||||
:git_diff -> build_git_diff(assigns, meta)
|
||||
end
|
||||
end
|
||||
|
||||
def build(_assigns), do: nil
|
||||
|
||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||
|
||||
def misc_class(:site_validation), do: "site-validation-view"
|
||||
def misc_class(:metadata_diff), do: "metadata-diff-view"
|
||||
def misc_class(:translation_validation), do: "translation-validation-view"
|
||||
def misc_class(:find_duplicates), do: "duplicates-view"
|
||||
def misc_class(:git_diff), do: "git-diff-view"
|
||||
|
||||
def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary)
|
||||
def summary_items(_misc), do: []
|
||||
|
||||
def duplicate_checked?(misc, pair_id), do: MapSet.member?(misc.selected_pairs, pair_id)
|
||||
|
||||
def pair_id_from_pair(pair), do: pair_identity(pair)
|
||||
|
||||
defp build_site_validation(meta, payload) do
|
||||
summary = Map.get(payload, :summary, %{})
|
||||
|
||||
%{
|
||||
kind: :site_validation,
|
||||
title: Map.get(meta, :title, translated("Site Validation")),
|
||||
subtitle: Map.get(meta, :subtitle, ""),
|
||||
summary: %{
|
||||
expected: Map.get(summary, :missing_count, 0) + Map.get(summary, :extra_count, 0) + Map.get(summary, :stale_count, 0),
|
||||
missing: Map.get(summary, :missing_count, 0),
|
||||
extra: Map.get(summary, :extra_count, 0),
|
||||
stale: Map.get(summary, :stale_count, 0)
|
||||
},
|
||||
missing_pages: Map.get(payload, :missing_pages, []),
|
||||
extra_pages: Map.get(payload, :extra_pages, []),
|
||||
stale_pages: Map.get(payload, :stale_pages, []),
|
||||
sections: Map.get(payload, :sections, [])
|
||||
}
|
||||
end
|
||||
|
||||
defp build_metadata_diff(meta, payload) do
|
||||
items = Map.get(payload, :diff_reports, [])
|
||||
|
||||
%{
|
||||
kind: :metadata_diff,
|
||||
title: Map.get(meta, :title, translated("Metadata Diff")),
|
||||
subtitle: Map.get(meta, :subtitle, ""),
|
||||
summary: Map.get(payload, :summary, %{}),
|
||||
field_summaries: field_summaries(items),
|
||||
items: items,
|
||||
orphan_files: Map.get(payload, :orphan_reports, [])
|
||||
}
|
||||
end
|
||||
|
||||
defp build_translation_validation(meta, payload) do
|
||||
%{
|
||||
kind: :translation_validation,
|
||||
title: Map.get(meta, :title, translated("Translation Validation")),
|
||||
subtitle: Map.get(meta, :subtitle, ""),
|
||||
summary: Map.get(payload, :summary, %{}),
|
||||
missing: Map.get(payload, :missing, []),
|
||||
orphan_files: Map.get(payload, :orphan_files, []),
|
||||
do_not_translate_posts: Map.get(payload, :do_not_translate_posts, [])
|
||||
}
|
||||
end
|
||||
|
||||
defp build_duplicates(assigns, meta, payload) do
|
||||
selected_pairs = Map.get(assigns.misc_editor_selected_pairs, assigns.current_tab.id, MapSet.new())
|
||||
|
||||
%{
|
||||
kind: :find_duplicates,
|
||||
title: Map.get(meta, :title, translated("Find Duplicates")),
|
||||
subtitle: Map.get(meta, :subtitle, ""),
|
||||
summary: Map.get(payload, :summary, %{}),
|
||||
pairs: Map.get(payload, :pairs, []),
|
||||
selected_pairs: selected_pairs
|
||||
}
|
||||
end
|
||||
|
||||
defp build_git_diff(assigns, meta) do
|
||||
diff_text =
|
||||
case Git.diff(assigns.projects.active_project_id) do
|
||||
{:ok, %{staged_diff: staged, unstaged_diff: unstaged}} ->
|
||||
[
|
||||
"# Staged Changes\n\n",
|
||||
if(String.trim(staged) == "", do: translated("No staged changes"), else: staged),
|
||||
"\n\n# Working Tree\n\n",
|
||||
if(String.trim(unstaged) == "", do: translated("No unstaged changes"), else: unstaged)
|
||||
]
|
||||
|> IO.iodata_to_binary()
|
||||
|
||||
{:error, reason} -> inspect(reason)
|
||||
end
|
||||
|
||||
%{
|
||||
kind: :git_diff,
|
||||
title: Map.get(meta, :title, translated("Git Diff")),
|
||||
subtitle: Map.get(meta, :subtitle, ""),
|
||||
diff_text: diff_text,
|
||||
summary: %{}
|
||||
}
|
||||
end
|
||||
|
||||
defp meta(assigns) do
|
||||
Map.get(assigns.tab_meta, {assigns.current_tab.type, assigns.current_tab.id}, %{})
|
||||
end
|
||||
|
||||
defp update_payload(socket, updater) do
|
||||
key = {socket.assigns.current_tab.type, socket.assigns.current_tab.id}
|
||||
meta = Map.get(socket.assigns.tab_meta, key, %{})
|
||||
next_meta = Map.update(meta, :payload, %{}, updater)
|
||||
assign(socket, :tab_meta, Map.put(socket.assigns.tab_meta, key, next_meta))
|
||||
end
|
||||
|
||||
defp clear_selected_pair(socket, pair_id) do
|
||||
tab_id = socket.assigns.current_tab.id
|
||||
current = Map.get(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new())
|
||||
next_pairs = Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.delete(current, pair_id))
|
||||
assign(socket, :misc_editor_selected_pairs, next_pairs)
|
||||
end
|
||||
|
||||
defp pair_id(post_id_a, post_id_b), do: Enum.sort([post_id_a, post_id_b]) |> Enum.join("::")
|
||||
defp pair_identity(pair), do: pair_id(Map.get(pair, :post_id_a) || Map.get(pair, "post_id_a"), Map.get(pair, :post_id_b) || Map.get(pair, "post_id_b"))
|
||||
|
||||
defp decode_pair_id(encoded) when is_binary(encoded) do
|
||||
case String.split(encoded, "::", parts: 2) do
|
||||
[post_id_a, post_id_b] -> {post_id_a, post_id_b}
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_pair_id(_encoded), do: nil
|
||||
|
||||
defp field_summaries(items) do
|
||||
items
|
||||
|> Enum.flat_map(fn item -> Map.get(item, :differences) || Map.get(item, "differences") || [] end)
|
||||
|> Enum.group_by(fn diff -> Map.get(diff, :field) || Map.get(diff, "field") end)
|
||||
|> Enum.map(fn {field, diffs} -> %{field_name: field, diff_count: length(diffs)} end)
|
||||
|> Enum.sort_by(&{&1.diff_count * -1, &1.field_name})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
<div class={["misc-editor-shell", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
|
||||
<div class="misc-editor-header">
|
||||
<div>
|
||||
<h2><%= @misc_editor.title %></h2>
|
||||
<p><%= @misc_editor.subtitle %></p>
|
||||
</div>
|
||||
<div class="misc-editor-actions">
|
||||
<button class="secondary" type="button" phx-click="rerun_misc_editor"><%= translated("Refresh") %></button>
|
||||
<%= if @misc_editor.kind == :site_validation do %>
|
||||
<button class="primary" type="button" phx-click="apply_site_validation" disabled={Enum.empty?(@misc_editor.missing_pages) and Enum.empty?(@misc_editor.extra_pages) and Enum.empty?(@misc_editor.stale_pages)}><%= translated("Apply") %></button>
|
||||
<% end %>
|
||||
<%= if @misc_editor.kind == :find_duplicates do %>
|
||||
<button class="secondary" type="button" phx-click="dismiss_selected_duplicates" disabled={MapSet.size(@misc_editor.selected_pairs) == 0}><%= translated("Dismiss Checked") %></button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="misc-editor-summary">
|
||||
<%= for {label, value} <- summary_items(@misc_editor) do %>
|
||||
<div class="misc-summary-pill"><span><%= label %></span><strong><%= value %></strong></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="misc-editor-content">
|
||||
<%= case @misc_editor.kind do %>
|
||||
<% :site_validation -> %>
|
||||
<div class="misc-columns">
|
||||
<section class="misc-card"><h3><%= translated("Missing URLs") %></h3><%= if Enum.empty?(@misc_editor.missing_pages) do %><p><%= translated("None found") %></p><% end %><ul><%= for path <- @misc_editor.missing_pages do %><li><%= path %></li><% end %></ul></section>
|
||||
<section class="misc-card"><h3><%= translated("Extra URLs") %></h3><%= if Enum.empty?(@misc_editor.extra_pages) do %><p><%= translated("None found") %></p><% end %><ul><%= for path <- @misc_editor.extra_pages do %><li><%= path %></li><% end %></ul></section>
|
||||
<section class="misc-card"><h3><%= translated("Updated URLs") %></h3><%= if Enum.empty?(@misc_editor.stale_pages) do %><p><%= translated("None found") %></p><% end %><ul><%= for path <- @misc_editor.stale_pages do %><li><%= path %></li><% end %></ul></section>
|
||||
</div>
|
||||
|
||||
<% :metadata_diff -> %>
|
||||
<div class="misc-columns">
|
||||
<section class="misc-card"><h3><%= translated("Field Summary") %></h3><div class="misc-summary-grid"><%= for field <- @misc_editor.field_summaries do %><span class="misc-summary-pill"><%= field.field_name %> <strong><%= field.diff_count %></strong></span><% end %></div></section>
|
||||
<section class="misc-card"><h3><%= translated("Diff Items") %></h3><div class="misc-list"><%= for item <- @misc_editor.items do %><article class="misc-list-item"><header><strong><%= Map.get(item, :entity_type) || Map.get(item, "entity_type") %></strong> <span><%= Map.get(item, :entity_id) || Map.get(item, "entity_id") %></span></header><ul><%= for diff <- Map.get(item, :differences) || Map.get(item, "differences") || [] do %><li><strong><%= Map.get(diff, :field) || Map.get(diff, "field") %></strong><span><%= inspect(Map.get(diff, :db_value) || Map.get(diff, "db_value")) %></span><span><%= inspect(Map.get(diff, :file_value) || Map.get(diff, "file_value")) %></span></li><% end %></ul></article><% end %></div></section>
|
||||
<section class="misc-card"><h3><%= translated("Orphan Files") %></h3><ul><%= for orphan <- @misc_editor.orphan_files do %><li><%= inspect(orphan) %></li><% end %></ul></section>
|
||||
</div>
|
||||
|
||||
<% :translation_validation -> %>
|
||||
<div class="misc-columns">
|
||||
<section class="misc-card"><h3><%= translated("Missing") %></h3><ul><%= for issue <- @misc_editor.missing do %><li><%= inspect(issue) %></li><% end %></ul></section>
|
||||
<section class="misc-card"><h3><%= translated("Orphan Files") %></h3><ul><%= for file <- @misc_editor.orphan_files do %><li><%= file %></li><% end %></ul></section>
|
||||
<section class="misc-card"><h3><%= translated("Do Not Translate") %></h3><ul><%= for post <- @misc_editor.do_not_translate_posts do %><li><%= inspect(post) %></li><% end %></ul></section>
|
||||
</div>
|
||||
|
||||
<% :find_duplicates -> %>
|
||||
<div class="misc-list">
|
||||
<%= for pair <- @misc_editor.pairs do %>
|
||||
<article class="misc-list-item duplicate-pair-row">
|
||||
<label><input type="checkbox" checked={duplicate_checked?(@misc_editor, pair_id_from_pair(pair))} phx-click="toggle_duplicate_pair" phx-value-pair-id={pair_id_from_pair(pair)} /> <span></span></label>
|
||||
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-value-id={Map.get(pair, :post_id_a) || Map.get(pair, "post_id_a")} phx-value-title={Map.get(pair, :title_a) || Map.get(pair, "title_a") }><%= Map.get(pair, :title_a) || Map.get(pair, "title_a") %></button>
|
||||
<span>→</span>
|
||||
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-value-id={Map.get(pair, :post_id_b) || Map.get(pair, "post_id_b")} phx-value-title={Map.get(pair, :title_b) || Map.get(pair, "title_b") }><%= Map.get(pair, :title_b) || Map.get(pair, "title_b") %></button>
|
||||
<span class="misc-summary-pill"><%= if(Map.get(pair, :exact_match) || Map.get(pair, "exact_match"), do: translated("Exact Match"), else: "#{Float.round((Map.get(pair, :similarity) || Map.get(pair, "similarity") || 0.0) * 100, 1)}%") %></span>
|
||||
<button class="secondary" type="button" phx-click="dismiss_duplicate_pair" phx-value-post-id-a={Map.get(pair, :post_id_a) || Map.get(pair, "post_id_a")} phx-value-post-id-b={Map.get(pair, :post_id_b) || Map.get(pair, "post_id_b")}><%= translated("Dismiss") %></button>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% :git_diff -> %>
|
||||
<div class="misc-card misc-code-card"><pre><code><%= @misc_editor.diff_text %></code></pre></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,6 +8,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.{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
|
||||
|
||||
409
lib/bds/desktop/shell_live/settings_editor.ex
Normal file
409
lib/bds/desktop/shell_live/settings_editor.ex
Normal file
@@ -0,0 +1,409 @@
|
||||
defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
alias BDS.Metadata
|
||||
alias BDS.Desktop.ShellData
|
||||
|
||||
embed_templates "settings_editor_html/*"
|
||||
|
||||
@themes [
|
||||
"default",
|
||||
"amber",
|
||||
"blue",
|
||||
"cyan",
|
||||
"fuchsia",
|
||||
"green",
|
||||
"grey",
|
||||
"indigo",
|
||||
"jade",
|
||||
"lime",
|
||||
"orange",
|
||||
"pink",
|
||||
"pumpkin",
|
||||
"purple",
|
||||
"red",
|
||||
"sand",
|
||||
"slate",
|
||||
"violet",
|
||||
"yellow",
|
||||
"zinc"
|
||||
]
|
||||
|
||||
@supported_languages ["en", "de", "fr", "it", "es"]
|
||||
@protected_categories MapSet.new(["article", "aside", "page", "picture"])
|
||||
|
||||
def assign_socket(socket) do
|
||||
case socket.assigns[:current_tab] do
|
||||
%{type: :settings} ->
|
||||
socket
|
||||
|> assign(:settings_editor, build_settings(socket.assigns))
|
||||
|> assign(:style_editor, nil)
|
||||
|
||||
%{type: :style} ->
|
||||
socket
|
||||
|> assign(:settings_editor, nil)
|
||||
|> assign(:style_editor, build_style(socket.assigns))
|
||||
|
||||
_other ->
|
||||
socket
|
||||
|> assign(:settings_editor, nil)
|
||||
|> assign(:style_editor, nil)
|
||||
end
|
||||
end
|
||||
|
||||
def update_search(socket, query, reload) do
|
||||
socket
|
||||
|> assign(:settings_editor_search, to_string(query || ""))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def update_project_draft(socket, params, reload) do
|
||||
socket
|
||||
|> assign(:settings_editor_project_draft, normalize_project_params(params))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def save_project(socket, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
|
||||
case Metadata.update_project_metadata(project_id, project_attrs(socket.assigns)) do
|
||||
{:ok, _metadata} ->
|
||||
socket
|
||||
|> assign(:settings_editor_project_draft, %{})
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Settings"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def update_publishing_draft(socket, params, reload) do
|
||||
socket
|
||||
|> assign(:settings_editor_publishing_draft, normalize_publishing_params(params))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def save_publishing(socket, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
|
||||
case Metadata.set_publishing_preferences(project_id, publishing_attrs(socket.assigns)) do
|
||||
{:ok, _metadata} ->
|
||||
socket
|
||||
|> assign(:settings_editor_publishing_draft, %{})
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Publishing"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def clear_publishing(socket, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
|
||||
case Metadata.set_publishing_preferences(project_id, %{}) do
|
||||
{:ok, _metadata} ->
|
||||
socket
|
||||
|> assign(:settings_editor_publishing_draft, %{})
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Publishing"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def update_new_category(socket, name, reload) do
|
||||
socket
|
||||
|> assign(:settings_editor_new_category, to_string(name || ""))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def add_category(socket, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim()
|
||||
|
||||
cond do
|
||||
name == "" ->
|
||||
socket
|
||||
|> append_output.(translated("Categories"), translated("Category name is required"), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
true ->
|
||||
case Metadata.add_category(project_id, name) do
|
||||
{:ok, _metadata} ->
|
||||
socket
|
||||
|> assign(:settings_editor_new_category, "")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def save_category(socket, params, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
category = Map.get(params, "category", "")
|
||||
|
||||
settings = %{
|
||||
title: blank_to_nil(Map.get(params, "title")),
|
||||
render_in_lists: truthy?(Map.get(params, "render_in_lists")),
|
||||
show_title: truthy?(Map.get(params, "show_title")),
|
||||
post_template_slug: blank_to_nil(Map.get(params, "post_template_slug")),
|
||||
list_template_slug: blank_to_nil(Map.get(params, "list_template_slug"))
|
||||
}
|
||||
|
||||
case Metadata.update_category_settings(project_id, category, settings) do
|
||||
{:ok, _metadata} -> reload.(socket, socket.assigns.workbench)
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def remove_category(socket, category, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
|
||||
cond do
|
||||
MapSet.member?(@protected_categories, category) ->
|
||||
socket
|
||||
|> append_output.(translated("Categories"), translated("Protected categories cannot be removed"), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
true ->
|
||||
case Metadata.remove_category(project_id, category) do
|
||||
{:ok, _metadata} -> reload.(socket, socket.assigns.workbench)
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def select_style_theme(socket, theme, reload) do
|
||||
socket
|
||||
|> assign(:style_editor_theme, to_string(theme || "default"))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def change_style_preview_mode(socket, mode, reload) do
|
||||
socket
|
||||
|> assign(:style_editor_preview_mode, to_string(mode || "auto"))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def apply_style_theme(socket, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
theme = socket.assigns[:style_editor_theme] || current_theme(socket.assigns)
|
||||
|
||||
case Metadata.update_project_metadata(project_id, %{pico_theme: theme}) do
|
||||
{:ok, _metadata} -> reload.(socket, socket.assigns.workbench)
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Style"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def build_settings(%{projects: %{active_project_id: nil}}), do: nil
|
||||
|
||||
def build_settings(assigns) do
|
||||
metadata = project_metadata(assigns)
|
||||
project_form = Map.merge(project_form(metadata), Map.get(assigns, :settings_editor_project_draft, %{}))
|
||||
publishing_form = Map.merge(publishing_form(metadata), Map.get(assigns, :settings_editor_publishing_draft, %{}))
|
||||
query = Map.get(assigns, :settings_editor_search, "")
|
||||
|
||||
%{
|
||||
search_query: query,
|
||||
project: project_form,
|
||||
categories: category_rows(metadata),
|
||||
publishing: publishing_form,
|
||||
new_category: Map.get(assigns, :settings_editor_new_category, ""),
|
||||
project_visible?: section_matches?(query, ~w(project name description url language author category posts bookmarklet)),
|
||||
content_visible?: section_matches?(query, ~w(content categories templates lists blogmark)),
|
||||
publishing_visible?: section_matches?(query, ~w(publishing ssh scp rsync host user remote path)),
|
||||
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)),
|
||||
supported_languages: @supported_languages,
|
||||
protected_categories: @protected_categories
|
||||
}
|
||||
end
|
||||
|
||||
def build_style(%{projects: %{active_project_id: nil}}), do: nil
|
||||
|
||||
def build_style(assigns) do
|
||||
selected_theme = Map.get(assigns, :style_editor_theme) || current_theme(assigns)
|
||||
preview_mode = Map.get(assigns, :style_editor_preview_mode, "auto")
|
||||
|
||||
%{
|
||||
themes: Enum.map(@themes, &style_theme/1),
|
||||
selected_theme: selected_theme,
|
||||
applied_theme: current_theme(assigns),
|
||||
preview_mode: preview_mode,
|
||||
preview_url: "http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
|
||||
}
|
||||
end
|
||||
|
||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||
|
||||
def protected_category?(category), do: MapSet.member?(@protected_categories, category)
|
||||
|
||||
def theme_display_name(theme) do
|
||||
theme
|
||||
|> to_string()
|
||||
|> String.replace("-", " ")
|
||||
|> String.capitalize()
|
||||
end
|
||||
|
||||
defp project_attrs(assigns) do
|
||||
draft = Map.get(assigns, :settings_editor_project_draft, %{})
|
||||
|
||||
%{
|
||||
name: blank_to_nil(Map.get(draft, "name")),
|
||||
description: blank_to_nil(Map.get(draft, "description")),
|
||||
public_url: blank_to_nil(Map.get(draft, "public_url")),
|
||||
main_language: blank_to_nil(Map.get(draft, "main_language")),
|
||||
default_author: blank_to_nil(Map.get(draft, "default_author")),
|
||||
max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50),
|
||||
blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")),
|
||||
blog_languages: Map.get(draft, "blog_languages", []),
|
||||
semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled"))
|
||||
}
|
||||
end
|
||||
|
||||
defp publishing_attrs(assigns) do
|
||||
draft = Map.get(assigns, :settings_editor_publishing_draft, %{})
|
||||
|
||||
%{
|
||||
ssh_host: blank_to_nil(Map.get(draft, "ssh_host")),
|
||||
ssh_user: blank_to_nil(Map.get(draft, "ssh_user")),
|
||||
ssh_remote_path: blank_to_nil(Map.get(draft, "ssh_remote_path")),
|
||||
ssh_mode: Map.get(draft, "ssh_mode", "scp")
|
||||
}
|
||||
end
|
||||
|
||||
defp project_metadata(assigns) do
|
||||
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
|
||||
{:ok, metadata} -> metadata
|
||||
_other -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
defp project_form(metadata) do
|
||||
%{
|
||||
"name" => Map.get(metadata, :name, ""),
|
||||
"description" => Map.get(metadata, :description, ""),
|
||||
"public_url" => Map.get(metadata, :public_url, ""),
|
||||
"main_language" => Map.get(metadata, :main_language) || "en",
|
||||
"default_author" => Map.get(metadata, :default_author, ""),
|
||||
"max_posts_per_page" => Integer.to_string(Map.get(metadata, :max_posts_per_page, 50)),
|
||||
"blogmark_category" => Map.get(metadata, :blogmark_category) || List.first(Map.get(metadata, :categories, [])) || "article",
|
||||
"blog_languages" => Map.get(metadata, :blog_languages, []),
|
||||
"semantic_similarity_enabled" => Map.get(metadata, :semantic_similarity_enabled, false)
|
||||
}
|
||||
end
|
||||
|
||||
defp publishing_form(metadata) do
|
||||
prefs = Map.get(metadata, :publishing_preferences, %{})
|
||||
|
||||
%{
|
||||
"ssh_host" => Map.get(prefs, "ssh_host", ""),
|
||||
"ssh_user" => Map.get(prefs, "ssh_user", ""),
|
||||
"ssh_remote_path" => Map.get(prefs, "ssh_remote_path", ""),
|
||||
"ssh_mode" => Map.get(prefs, "ssh_mode", "scp")
|
||||
}
|
||||
end
|
||||
|
||||
defp current_theme(assigns) do
|
||||
assigns
|
||||
|> project_metadata()
|
||||
|> Map.get(:pico_theme)
|
||||
|> case do
|
||||
nil -> "default"
|
||||
"" -> "default"
|
||||
theme -> theme
|
||||
end
|
||||
end
|
||||
|
||||
defp category_rows(metadata) do
|
||||
categories = Map.get(metadata, :categories, [])
|
||||
settings = Map.get(metadata, :category_settings, %{})
|
||||
|
||||
Enum.map(categories, fn category ->
|
||||
category_settings = Map.get(settings, category, %{})
|
||||
|
||||
%{
|
||||
name: category,
|
||||
title: Map.get(category_settings, "title") || category,
|
||||
render_in_lists: Map.get(category_settings, "render_in_lists", true),
|
||||
show_title: Map.get(category_settings, "show_title", true),
|
||||
post_template_slug: Map.get(category_settings, "post_template_slug", ""),
|
||||
list_template_slug: Map.get(category_settings, "list_template_slug", ""),
|
||||
protected?: protected_category?(category)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_project_params(params) do
|
||||
%{
|
||||
"name" => Map.get(params, "name", ""),
|
||||
"description" => Map.get(params, "description", ""),
|
||||
"public_url" => Map.get(params, "public_url", ""),
|
||||
"main_language" => Map.get(params, "main_language", "en"),
|
||||
"default_author" => Map.get(params, "default_author", ""),
|
||||
"max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"),
|
||||
"blogmark_category" => Map.get(params, "blogmark_category", "article"),
|
||||
"blog_languages" => List.wrap(Map.get(params, "blog_languages", [])),
|
||||
"semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled"))
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_publishing_params(params) do
|
||||
%{
|
||||
"ssh_host" => Map.get(params, "ssh_host", ""),
|
||||
"ssh_user" => Map.get(params, "ssh_user", ""),
|
||||
"ssh_remote_path" => Map.get(params, "ssh_remote_path", ""),
|
||||
"ssh_mode" => Map.get(params, "ssh_mode", "scp")
|
||||
}
|
||||
end
|
||||
|
||||
defp section_matches?("", _keywords), do: true
|
||||
defp section_matches?(query, keywords), do: Enum.any?(keywords, &String.contains?(&1, String.downcase(query)))
|
||||
|
||||
defp style_theme(name) do
|
||||
%{
|
||||
name: name,
|
||||
accent_color: "#4f46e5",
|
||||
light_bg_color: "#f8fafc",
|
||||
dark_bg_color: "#0f172a"
|
||||
}
|
||||
end
|
||||
|
||||
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
|
||||
defp parse_integer(nil, fallback), do: fallback
|
||||
defp parse_integer(value, _fallback) when is_integer(value), do: value
|
||||
defp parse_integer(value, fallback) do
|
||||
case Integer.parse(to_string(value)) do
|
||||
{parsed, _rest} -> parsed
|
||||
:error -> fallback
|
||||
end
|
||||
end
|
||||
|
||||
defp blank_to_nil(nil), do: nil
|
||||
defp blank_to_nil(value) do
|
||||
case String.trim(to_string(value)) do
|
||||
"" -> nil
|
||||
trimmed -> trimmed
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,145 @@
|
||||
<div class="settings-view-shell" data-testid="settings-editor">
|
||||
<div class="settings-view">
|
||||
<div class="settings-header">
|
||||
<h2 data-testid="editor-title"><%= translated("Settings") %></h2>
|
||||
<form class="settings-search" phx-change="change_settings_search">
|
||||
<input type="text" name="query" value={@settings_editor.search_query} placeholder={translated("Search settings")} />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<%= if not @settings_editor.project_visible? and not @settings_editor.content_visible? and not @settings_editor.publishing_visible? and not @settings_editor.data_visible? do %>
|
||||
<div class="settings-no-results">
|
||||
<p><%= translated("No settings match the current search") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @settings_editor.project_visible? do %>
|
||||
<div class="setting-section" id="settings-section-project">
|
||||
<div class="setting-section-header">
|
||||
<h3><%= translated("Project") %></h3>
|
||||
</div>
|
||||
<form class="setting-section-content" phx-change="change_settings_project">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label class="setting-label"><%= translated("Project Name") %></label>
|
||||
</div>
|
||||
<div class="setting-control"><input type="text" name="settings_project[name]" value={@settings_editor.project["name"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Description") %></label></div>
|
||||
<div class="setting-control"><textarea name="settings_project[description]" rows="3"><%= @settings_editor.project["description"] %></textarea></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Public URL") %></label></div>
|
||||
<div class="setting-control"><input type="url" name="settings_project[public_url]" value={@settings_editor.project["public_url"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Main Language") %></label></div>
|
||||
<div class="setting-control">
|
||||
<select name="settings_project[main_language]">
|
||||
<%= for language <- @settings_editor.supported_languages do %>
|
||||
<option value={language} selected={language == @settings_editor.project["main_language"]}><%= String.upcase(language) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Blog Languages") %></label></div>
|
||||
<div class="setting-control">
|
||||
<div class="setting-input-group">
|
||||
<%= for language <- @settings_editor.supported_languages do %>
|
||||
<label>
|
||||
<input type="checkbox" name="settings_project[blog_languages][]" value={language} checked={language in @settings_editor.project["blog_languages"]} />
|
||||
<%= String.upcase(language) %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Default Author") %></label></div>
|
||||
<div class="setting-control"><input type="text" name="settings_project[default_author]" value={@settings_editor.project["default_author"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Max Posts Per Page") %></label></div>
|
||||
<div class="setting-control"><input type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Blogmark Category") %></label></div>
|
||||
<div class="setting-control">
|
||||
<select name="settings_project[blogmark_category]">
|
||||
<%= for category <- Enum.map(@settings_editor.categories, & &1.name) do %>
|
||||
<option value={category} selected={category == @settings_editor.project["blogmark_category"]}><%= category %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Semantic Similarity") %></label></div>
|
||||
<div class="setting-control">
|
||||
<label><input type="checkbox" name="settings_project[semantic_similarity_enabled]" checked={@settings_editor.project["semantic_similarity_enabled"]} /> <%= translated("Enable semantic similarity") %></label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_project"><%= translated("Save") %></button></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @settings_editor.content_visible? do %>
|
||||
<div class="setting-section" id="settings-section-content">
|
||||
<div class="setting-section-header"><h3><%= translated("Content Categories") %></h3></div>
|
||||
<div class="setting-section-content">
|
||||
<%= for category <- @settings_editor.categories do %>
|
||||
<form class="setting-row" phx-change="save_settings_category">
|
||||
<input type="hidden" name="category_settings[category]" value={category.name} />
|
||||
<div class="setting-info"><label class="setting-label"><%= category.name %></label></div>
|
||||
<div class="setting-control">
|
||||
<div class="setting-input-group">
|
||||
<input type="text" name="category_settings[title]" value={category.title} />
|
||||
<label><input type="checkbox" name="category_settings[render_in_lists]" checked={category.render_in_lists} /> <%= translated("Render in Lists") %></label>
|
||||
<label><input type="checkbox" name="category_settings[show_title]" checked={category.show_title} /> <%= translated("Show Titles") %></label>
|
||||
<button class="secondary" type="button" phx-click="remove_settings_category" phx-value-category={category.name} disabled={category.protected?}><%= translated("Remove") %></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<% end %>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Add Category") %></label></div>
|
||||
<div class="setting-control">
|
||||
<div class="setting-input-group">
|
||||
<input type="text" value={@settings_editor.new_category} phx-change="change_settings_new_category" name="name" />
|
||||
<button class="primary" type="button" phx-click="add_settings_category"><%= translated("Add") %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @settings_editor.publishing_visible? do %>
|
||||
<div class="setting-section" id="settings-section-publishing">
|
||||
<div class="setting-section-header"><h3><%= translated("Publishing") %></h3></div>
|
||||
<form class="setting-section-content" phx-change="change_settings_publishing">
|
||||
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= translated("SSH Mode") %></label></div><div class="setting-control"><select name="settings_publishing[ssh_mode]"><option value="scp" selected={@settings_editor.publishing["ssh_mode"] == "scp"}>scp</option><option value="rsync" selected={@settings_editor.publishing["ssh_mode"] == "rsync"}>rsync</option></select></div></div>
|
||||
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= translated("Host") %></label></div><div class="setting-control"><input type="text" name="settings_publishing[ssh_host]" value={@settings_editor.publishing["ssh_host"]} /></div></div>
|
||||
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= translated("Username") %></label></div><div class="setting-control"><input type="text" name="settings_publishing[ssh_user]" value={@settings_editor.publishing["ssh_user"]} /></div></div>
|
||||
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= translated("Remote Path") %></label></div><div class="setting-control"><input type="text" name="settings_publishing[ssh_remote_path]" value={@settings_editor.publishing["ssh_remote_path"]} /></div></div>
|
||||
</form>
|
||||
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_publishing"><%= translated("Save") %></button><button class="secondary" type="button" phx-click="clear_settings_publishing"><%= translated("Clear") %></button></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @settings_editor.data_visible? do %>
|
||||
<div class="setting-section" id="settings-section-data">
|
||||
<div class="setting-section-header"><h3><%= translated("Data Maintenance") %></h3></div>
|
||||
<div class="setting-actions">
|
||||
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_database"><%= translated("Rebuild Database") %></button>
|
||||
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= translated("Rebuild Embedding Index") %></button>
|
||||
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= translated("Open Data Folder") %></button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="style-view" data-testid="style-editor">
|
||||
<div class="style-view-header">
|
||||
<h2 data-testid="editor-title"><%= translated("Style") %></h2>
|
||||
<p><%= translated("Theme preview and renderer theme selection") %></p>
|
||||
</div>
|
||||
|
||||
<div class="style-theme-picker" role="group" aria-label={translated("Theme picker")}>
|
||||
<%= for theme <- @style_editor.themes do %>
|
||||
<button type="button" class={["style-theme-option", if(theme.name == @style_editor.selected_theme, do: "selected")]} phx-click="select_style_theme" phx-value-theme={theme.name} aria-pressed={theme.name == @style_editor.selected_theme}>
|
||||
<span class="style-theme-swatch">
|
||||
<span class="style-theme-tones" aria-hidden="true">
|
||||
<span class="style-theme-tone style-theme-tone-accent" style={"background: linear-gradient(135deg, #{theme.accent_color}, #{theme.dark_bg_color})"}></span>
|
||||
<span class="style-theme-tone style-theme-tone-light" style={"background-color: #{theme.light_bg_color}"}></span>
|
||||
<span class="style-theme-tone style-theme-tone-dark" style={"background-color: #{theme.dark_bg_color}"}></span>
|
||||
</span>
|
||||
<span class="style-theme-name"><%= theme_display_name(theme.name) %></span>
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="style-apply-row">
|
||||
<label class="style-preview-mode-control">
|
||||
<span><%= translated("Preview Mode") %></span>
|
||||
<select phx-change="change_style_preview_mode" name="mode">
|
||||
<option value="auto" selected={@style_editor.preview_mode == "auto"}><%= translated("Auto") %></option>
|
||||
<option value="light" selected={@style_editor.preview_mode == "light"}><%= translated("Light") %></option>
|
||||
<option value="dark" selected={@style_editor.preview_mode == "dark"}><%= translated("Dark") %></option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="primary" type="button" phx-click="apply_style_theme" disabled={@style_editor.selected_theme == @style_editor.applied_theme}><%= translated("Apply Theme") %></button>
|
||||
</div>
|
||||
|
||||
<div class="style-preview-container">
|
||||
<iframe title={translated("Theme Preview")} class="style-preview-frame" src={@style_editor.preview_url}></iframe>
|
||||
</div>
|
||||
</div>
|
||||
246
lib/bds/desktop/shell_live/tags_editor.ex
Normal file
246
lib/bds/desktop/shell_live/tags_editor.ex
Normal file
@@ -0,0 +1,246 @@
|
||||
defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.{Repo, Tags}
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Tags.Tag
|
||||
alias BDS.Templates.Template
|
||||
|
||||
embed_templates "tags_editor_html/*"
|
||||
|
||||
def assign_socket(socket) do
|
||||
assign(socket, :tags_editor, build(socket.assigns))
|
||||
end
|
||||
|
||||
def toggle_selection(socket, tag_name, reload) do
|
||||
selected = Map.get(socket.assigns, :tags_editor_selected, [])
|
||||
|
||||
next_selected =
|
||||
if tag_name in selected do
|
||||
Enum.reject(selected, &(&1 == tag_name))
|
||||
else
|
||||
selected ++ [tag_name]
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:tags_editor_selected, next_selected)
|
||||
|> maybe_seed_edit_draft(next_selected)
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def update_new_tag(socket, params, reload) do
|
||||
socket
|
||||
|> assign(:tags_editor_new_tag, %{
|
||||
"name" => Map.get(params, "name", ""),
|
||||
"color" => Map.get(params, "color", "")
|
||||
})
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def create_tag(socket, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
draft = Map.get(socket.assigns, :tags_editor_new_tag, %{})
|
||||
|
||||
case Tags.create_tag(%{project_id: project_id, name: Map.get(draft, "name"), color: blank_to_nil(Map.get(draft, "color"))}) do
|
||||
{:ok, _tag} ->
|
||||
socket
|
||||
|> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""})
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Tags"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def update_edit_tag(socket, params, reload) do
|
||||
socket
|
||||
|> assign(:tags_editor_edit_draft, %{
|
||||
"name" => Map.get(params, "name", ""),
|
||||
"color" => Map.get(params, "color", ""),
|
||||
"post_template_slug" => Map.get(params, "post_template_slug", "")
|
||||
})
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def save_tag(socket, reload, append_output) do
|
||||
selected = Map.get(socket.assigns, :tags_editor_selected, [])
|
||||
draft = Map.get(socket.assigns, :tags_editor_edit_draft, %{})
|
||||
|
||||
case selected do
|
||||
[tag_name] ->
|
||||
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do
|
||||
nil -> reload.(socket, socket.assigns.workbench)
|
||||
%Tag{} = tag ->
|
||||
with {:ok, _updated_tag} <- Tags.update_tag(tag.id, %{color: blank_to_nil(Map.get(draft, "color")), post_template_slug: blank_to_nil(Map.get(draft, "post_template_slug"))}),
|
||||
{:ok, renamed_tag} <- maybe_rename_tag(tag, Map.get(draft, "name", tag.name)) do
|
||||
socket
|
||||
|> assign(:tags_editor_selected, [renamed_tag.name])
|
||||
|> maybe_seed_edit_draft([renamed_tag.name])
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Tags"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
_other -> reload.(socket, socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_selected(socket, reload, append_output) do
|
||||
case Map.get(socket.assigns, :tags_editor_selected, []) do
|
||||
[tag_name] ->
|
||||
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do
|
||||
nil -> reload.(socket, socket.assigns.workbench)
|
||||
%Tag{} = tag ->
|
||||
case Tags.delete_tag(tag.id) do
|
||||
{:ok, _deleted} ->
|
||||
socket
|
||||
|> assign(:tags_editor_selected, [])
|
||||
|> assign(:tags_editor_edit_draft, %{})
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Tags"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
_other -> reload.(socket, socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def update_merge_target(socket, target, reload) do
|
||||
socket
|
||||
|> assign(:tags_editor_merge_target, to_string(target || ""))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def merge_selected(socket, reload, append_output) do
|
||||
selected = Map.get(socket.assigns, :tags_editor_selected, [])
|
||||
target_name = Map.get(socket.assigns, :tags_editor_merge_target, "")
|
||||
|
||||
cond do
|
||||
length(selected) < 2 or target_name == "" ->
|
||||
reload.(socket, socket.assigns.workbench)
|
||||
|
||||
true ->
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
tags = Repo.all(from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^selected)
|
||||
target = Enum.find(tags, &(&1.name == target_name))
|
||||
sources = Enum.reject(tags, &(&1.name == target_name))
|
||||
|
||||
case target do
|
||||
nil -> reload.(socket, socket.assigns.workbench)
|
||||
_target ->
|
||||
case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do
|
||||
{:ok, _merged} ->
|
||||
socket
|
||||
|> assign(:tags_editor_selected, [target.name])
|
||||
|> assign(:tags_editor_merge_target, target.name)
|
||||
|> maybe_seed_edit_draft([target.name])
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Tags"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync(socket, reload, append_output) do
|
||||
_ = append_output
|
||||
:ok = Tags.sync_tags_json(socket.assigns.projects.active_project_id)
|
||||
reload.(socket, socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def build(%{current_tab: %{type: :tags}} = assigns) do
|
||||
project_id = assigns.projects.active_project_id
|
||||
tags = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name])
|
||||
counts = tag_counts(project_id)
|
||||
selected = Map.get(assigns, :tags_editor_selected, [])
|
||||
edit_tag = if length(selected) == 1, do: Enum.find(tags, &(&1.name == hd(selected))), else: nil
|
||||
edit_draft = Map.get(assigns, :tags_editor_edit_draft, edit_draft(edit_tag))
|
||||
templates = Repo.all(from template in Template, where: template.project_id == ^project_id, order_by: [asc: template.title], select: %{slug: template.slug, title: template.title})
|
||||
|
||||
%{
|
||||
tags: Enum.map(tags, fn tag -> %{name: tag.name, color: tag.color, count: Map.get(counts, tag.name, 0)} end),
|
||||
selected: selected,
|
||||
new_tag: Map.get(assigns, :tags_editor_new_tag, %{"name" => "", "color" => ""}),
|
||||
edit_draft: edit_draft,
|
||||
templates: templates,
|
||||
merge_target: Map.get(assigns, :tags_editor_merge_target, List.first(selected) || "")
|
||||
}
|
||||
end
|
||||
|
||||
def build(_assigns), do: nil
|
||||
|
||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||
|
||||
def tag_font_size(count, counts) do
|
||||
max_count = Enum.max([1 | Enum.map(counts, & &1.count)])
|
||||
ratio = if max_count <= 1, do: 0.0, else: (count - 1) / max(max_count - 1, 1)
|
||||
Float.round(0.85 + (1.8 - 0.85) * ratio, 2)
|
||||
end
|
||||
|
||||
def tag_style(tag, counts) do
|
||||
size = tag_font_size(tag.count, counts)
|
||||
|
||||
[
|
||||
"font-size: #{size}rem",
|
||||
if(tag.color, do: "background-color: #{tag.color}"),
|
||||
if(tag.color, do: "color: #ffffff")
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.join("; ")
|
||||
end
|
||||
|
||||
defp maybe_seed_edit_draft(socket, [tag_name]) do
|
||||
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do
|
||||
%Tag{} = tag -> assign(socket, :tags_editor_edit_draft, edit_draft(tag))
|
||||
_other -> assign(socket, :tags_editor_edit_draft, %{})
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_seed_edit_draft(socket, _selected), do: assign(socket, :tags_editor_edit_draft, %{})
|
||||
|
||||
defp edit_draft(nil), do: %{}
|
||||
defp edit_draft(%Tag{} = tag), do: %{"name" => tag.name, "color" => tag.color || "", "post_template_slug" => tag.post_template_slug || ""}
|
||||
|
||||
defp maybe_rename_tag(%Tag{} = tag, next_name) do
|
||||
normalized = String.trim(to_string(next_name || tag.name))
|
||||
|
||||
if normalized == tag.name do
|
||||
{:ok, tag}
|
||||
else
|
||||
Tags.rename_tag(tag.id, normalized)
|
||||
end
|
||||
end
|
||||
|
||||
defp tag_counts(project_id) do
|
||||
Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.tags)
|
||||
|> List.flatten()
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
|
||||
end
|
||||
|
||||
defp blank_to_nil(nil), do: nil
|
||||
defp blank_to_nil(value) do
|
||||
case String.trim(to_string(value)) do
|
||||
"" -> nil
|
||||
trimmed -> trimmed
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
<div class="tags-view-shell" data-testid="tags-editor">
|
||||
<div class="tags-view">
|
||||
<div class="tags-view-header">
|
||||
<h2><%= translated("Tags") %></h2>
|
||||
</div>
|
||||
|
||||
<div class="tags-view-content">
|
||||
<div class="tags-section">
|
||||
<div class="tags-section-header"><h3><%= translated("Tag Cloud") %></h3></div>
|
||||
<div class="tags-section-content">
|
||||
<div class="tag-cloud">
|
||||
<%= for tag <- @tags_editor.tags do %>
|
||||
<button class={["tag-cloud-item", if(tag.name in @tags_editor.selected, do: "selected"), if(tag.color, do: "has-color")]} style={tag_style(tag, @tags_editor.tags)} type="button" phx-click="toggle_tag_selection" phx-value-name={tag.name}>
|
||||
<%= tag.name %><span class="tag-count"><%= tag.count %></span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tags-section">
|
||||
<div class="tags-section-header"><h3><%= translated("Create / Edit") %></h3></div>
|
||||
<div class="tags-section-content">
|
||||
<form class="tag-create-form" phx-change="change_new_tag_editor">
|
||||
<div class="tag-form-row">
|
||||
<input type="text" name="new_tag[name]" value={@tags_editor.new_tag["name"]} placeholder={translated("Tag name")} />
|
||||
<input type="color" name="new_tag[color]" value={if(@tags_editor.new_tag["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.new_tag["color"])} />
|
||||
<button class="primary" type="button" phx-click="create_tag_editor"><%= translated("Create") %></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<%= if @tags_editor.edit_draft != %{} do %>
|
||||
<form class="tag-edit-form" phx-change="change_edit_tag_editor">
|
||||
<div class="tag-form-row">
|
||||
<input type="text" name="edit_tag[name]" value={@tags_editor.edit_draft["name"]} />
|
||||
<input type="color" name="edit_tag[color]" value={if(@tags_editor.edit_draft["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.edit_draft["color"])} />
|
||||
<select name="edit_tag[post_template_slug]">
|
||||
<option value=""><%= translated("No Template") %></option>
|
||||
<%= for template <- @tags_editor.templates do %>
|
||||
<option value={template.slug} selected={template.slug == @tags_editor.edit_draft["post_template_slug"]}><%= template.title %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<button class="primary" type="button" phx-click="save_tag_editor"><%= translated("Save") %></button>
|
||||
<button class="danger" type="button" phx-click="delete_tag_editor"><%= translated("Delete") %></button>
|
||||
</div>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tags-section">
|
||||
<div class="tags-section-header"><h3><%= translated("Merge Tags") %></h3></div>
|
||||
<div class="tags-section-content">
|
||||
<div class="merge-form">
|
||||
<div class="tag-form-row">
|
||||
<select phx-change="change_merge_target" name="target">
|
||||
<%= for tag_name <- @tags_editor.selected do %>
|
||||
<option value={tag_name} selected={tag_name == @tags_editor.merge_target}><%= tag_name %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<button class="primary" type="button" phx-click="merge_tags_editor" disabled={length(@tags_editor.selected) < 2}><%= translated("Merge") %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tags-section">
|
||||
<div class="tags-section-header"><h3><%= translated("Sync") %></h3></div>
|
||||
<div class="tags-section-content">
|
||||
<button class="secondary" type="button" phx-click="sync_tags_editor"><%= translated("Discover") %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user