feat: better parity in layout for media and preferences
This commit is contained in:
@@ -116,159 +116,160 @@
|
||||
<% 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="media-details">
|
||||
<form class="media-editor-details-form" data-testid="media-editor-form" phx-change="change_media_editor">
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Size") %></label>
|
||||
<input class="post-editor-input is-readonly" type="text" value={@media_editor.file_size} readonly />
|
||||
<label><%= translated("File Name") %></label>
|
||||
<input class="post-editor-input disabled" type="text" value={@media_editor.original_name} disabled />
|
||||
</div>
|
||||
|
||||
<%= if @media_editor.dimensions do %>
|
||||
<div class="editor-field">
|
||||
<label><%= translated("MIME Type") %></label>
|
||||
<input class="post-editor-input disabled" type="text" value={@media_editor.mime_type} disabled />
|
||||
</div>
|
||||
|
||||
<div class="editor-field-row">
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Dimensions") %></label>
|
||||
<input class="post-editor-input is-readonly" type="text" value={@media_editor.dimensions} readonly />
|
||||
<label><%= translated("Size") %></label>
|
||||
<input class="post-editor-input disabled" type="text" value={@media_editor.file_size} disabled />
|
||||
</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>
|
||||
<%= if @media_editor.dimensions do %>
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Dimensions") %></label>
|
||||
<input class="post-editor-input disabled" type="text" value={@media_editor.dimensions} disabled />
|
||||
</div>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @media_editor.form["language"] not in [nil, ""] do %>
|
||||
<div class="editor-field media-translations-section">
|
||||
<label><%= translated("Translations") %></label>
|
||||
<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>
|
||||
|
||||
<%= 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 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>
|
||||
|
||||
<%= 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>
|
||||
<% 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>
|
||||
|
||||
<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 @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.post_picker_results) do %>
|
||||
<div class="no-posts"><%= translated("No posts to link") %></div>
|
||||
<%= if Enum.empty?(@media_editor.linked_posts) do %>
|
||||
<div class="no-linked-posts"><%= translated("Not linked to any posts") %></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>
|
||||
<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>
|
||||
<% 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>
|
||||
</div>
|
||||
|
||||
<%= if @media_editor.editing_translation do %>
|
||||
|
||||
@@ -32,8 +32,6 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
{: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")}
|
||||
|
||||
@@ -3,11 +3,22 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.AI
|
||||
alias BDS.AI.Model
|
||||
alias BDS.Metadata
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.MCP.AgentConfig
|
||||
alias BDS.Persistence
|
||||
alias BDS.Repo
|
||||
alias BDS.Settings.Setting
|
||||
alias BDS.Templates.Template
|
||||
|
||||
embed_templates "settings_editor_html/*"
|
||||
|
||||
@settings_sections ~w(project editor content ai technology publishing data mcp)
|
||||
|
||||
@themes [
|
||||
"default",
|
||||
"amber",
|
||||
@@ -33,6 +44,21 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
|
||||
@supported_languages ["en", "de", "fr", "it", "es"]
|
||||
@protected_categories MapSet.new(["article", "aside", "page", "picture"])
|
||||
@default_category_settings %{
|
||||
"article" => %{title: "article", render_in_lists: true, show_title: true},
|
||||
"picture" => %{title: "picture", render_in_lists: true, show_title: true},
|
||||
"aside" => %{title: "aside", render_in_lists: true, show_title: false},
|
||||
"page" => %{title: "page", render_in_lists: false, show_title: true}
|
||||
}
|
||||
@mcp_agents [
|
||||
%{id: :claude_code, label: "Claude Code", supported?: true},
|
||||
%{id: :claude_desktop, label: "Claude Desktop", supported?: false},
|
||||
%{id: :github_copilot, label: "GitHub Copilot", supported?: true},
|
||||
%{id: :gemini_cli, label: "Gemini CLI", supported?: false},
|
||||
%{id: :opencode, label: "OpenCode", supported?: false},
|
||||
%{id: :mistral_vibe, label: "Mistral Vibe", supported?: false},
|
||||
%{id: :openai_codex, label: "OpenAI Codex", supported?: false}
|
||||
]
|
||||
|
||||
def assign_socket(socket) do
|
||||
case socket.assigns[:current_tab] do
|
||||
@@ -65,6 +91,30 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def update_editor_draft(socket, params, reload) do
|
||||
socket
|
||||
|> assign(:settings_editor_editor_draft, normalize_editor_params(params))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def save_editor(socket, reload, append_output) do
|
||||
attrs = editor_attrs(socket.assigns)
|
||||
|
||||
with :ok <- put_global_setting("ui.preferred_editor_mode", attrs.default_mode),
|
||||
:ok <- put_global_setting("ui.git_diff_view_style", attrs.diff_view_style),
|
||||
:ok <- put_global_setting("ui.git_diff_word_wrap", boolean_string(attrs.wrap_long_lines)),
|
||||
:ok <- put_global_setting("ui.git_diff_hide_unchanged_regions", boolean_string(attrs.hide_unchanged_regions)) do
|
||||
socket
|
||||
|> assign(:settings_editor_editor_draft, %{})
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Editor Settings"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def save_project(socket, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
|
||||
@@ -87,6 +137,50 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def update_ai_draft(socket, params, reload) do
|
||||
socket
|
||||
|> assign(:settings_editor_ai_draft, normalize_ai_params(params))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def save_ai(socket, reload, append_output) do
|
||||
attrs = ai_attrs(socket.assigns)
|
||||
|
||||
with :ok <- maybe_put_endpoint(:online, attrs.online_api_key, attrs.default_model),
|
||||
:ok <- maybe_put_endpoint(:mistral, attrs.mistral_api_key, attrs.default_model),
|
||||
:ok <- AI.set_airplane_mode(attrs.offline_mode),
|
||||
:ok <- maybe_put_model_preference(:default, attrs.default_model),
|
||||
:ok <- maybe_put_model_preference(:title, attrs.title_model),
|
||||
:ok <- maybe_put_model_preference(:image_analysis, attrs.image_analysis_model),
|
||||
:ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model),
|
||||
:ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model),
|
||||
:ok <- maybe_put_model_preference(:airplane_image_analysis, attrs.offline_image_analysis_model),
|
||||
:ok <- put_global_setting("ai.system_prompt", attrs.system_prompt) do
|
||||
socket
|
||||
|> assign(:settings_editor_ai_draft, %{})
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def reset_ai_prompt(socket, reload, append_output) do
|
||||
case put_global_setting("ai.system_prompt", "") do
|
||||
:ok ->
|
||||
socket
|
||||
|> assign(:settings_editor_ai_draft, %{})
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def save_publishing(socket, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
|
||||
@@ -150,6 +244,54 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
end
|
||||
end
|
||||
|
||||
def reset_categories(socket, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
|
||||
result =
|
||||
Enum.reduce_while(category_names(project_metadata(socket.assigns)), :ok, fn category, _acc ->
|
||||
if MapSet.member?(@protected_categories, category) do
|
||||
{:cont, :ok}
|
||||
else
|
||||
case Metadata.remove_category(project_id, category) do
|
||||
{:ok, _metadata} -> {:cont, :ok}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
with :ok <- result,
|
||||
:ok <- ensure_default_categories(project_id),
|
||||
:ok <- reset_default_category_settings(project_id) do
|
||||
socket
|
||||
|> assign(:settings_editor_new_category, "")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_mcp_agent(socket, agent, reload, append_output) do
|
||||
case find_mcp_agent(agent) do
|
||||
%{id: agent_id, supported?: true} = config ->
|
||||
if mcp_configured?(config) do
|
||||
{:ok, _payload} = AgentConfig.remove_from_config(agent_id)
|
||||
reload.(socket, socket.assigns.workbench)
|
||||
else
|
||||
install_root = Application.app_dir(:bds)
|
||||
{:ok, _payload} = AgentConfig.add_to_config(agent_id, install_root: install_root)
|
||||
reload.(socket, socket.assigns.workbench)
|
||||
end
|
||||
|
||||
_other ->
|
||||
socket
|
||||
|> append_output.(translated("MCP"), translated("This MCP agent is not supported in the rewrite yet"), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def save_category(socket, params, reload, append_output) do
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
category = Map.get(params, "category", "")
|
||||
@@ -221,18 +363,37 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
def build_settings(assigns) do
|
||||
metadata = project_metadata(assigns)
|
||||
project_form = Map.merge(project_form(metadata), Map.get(assigns, :settings_editor_project_draft, %{}))
|
||||
editor_form = Map.merge(editor_form(), Map.get(assigns, :settings_editor_editor_draft, %{}))
|
||||
ai_form = Map.merge(ai_form(), Map.get(assigns, :settings_editor_ai_draft, %{}))
|
||||
publishing_form = Map.merge(publishing_form(metadata), Map.get(assigns, :settings_editor_publishing_draft, %{}))
|
||||
query = Map.get(assigns, :settings_editor_search, "")
|
||||
selected_section = current_settings_section(assigns)
|
||||
visible_sections = visible_settings_sections(query)
|
||||
|
||||
%{
|
||||
search_query: query,
|
||||
selected_section: selected_section,
|
||||
active_sections: visible_sections,
|
||||
project: project_form,
|
||||
editor: editor_form,
|
||||
categories: category_rows(metadata),
|
||||
ai: ai_form,
|
||||
technology: technology_form(project_form),
|
||||
publishing: publishing_form,
|
||||
mcp: mcp_rows(),
|
||||
new_category: Map.get(assigns, :settings_editor_new_category, ""),
|
||||
project_data_path: Map.get(assigns.current_project || %{}, :data_path) || "",
|
||||
project_data_default_path: Map.get(assigns.current_project || %{}, :project_path) || "",
|
||||
template_options: template_options(assigns.projects.active_project_id),
|
||||
model_options: model_options(),
|
||||
image_model_options: image_model_options(),
|
||||
project_visible?: section_matches?(query, ~w(project name description url language author category posts bookmarklet)),
|
||||
editor_visible?: section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged)),
|
||||
content_visible?: section_matches?(query, ~w(content categories templates lists blogmark)),
|
||||
ai_visible?: section_matches?(query, ~w(ai assistant model prompt offline endpoint api key title image mistral anthropic)),
|
||||
technology_visible?: section_matches?(query, ~w(technology runtime semantic similarity embedding scripting)),
|
||||
publishing_visible?: section_matches?(query, ~w(publishing ssh scp rsync host user remote path)),
|
||||
mcp_visible?: section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server)),
|
||||
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)),
|
||||
supported_languages: @supported_languages,
|
||||
protected_categories: @protected_categories
|
||||
@@ -281,6 +442,17 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
}
|
||||
end
|
||||
|
||||
defp editor_attrs(assigns) do
|
||||
draft = Map.get(assigns, :settings_editor_editor_draft, %{})
|
||||
|
||||
%{
|
||||
default_mode: Map.get(draft, "default_mode", "markdown"),
|
||||
diff_view_style: Map.get(draft, "diff_view_style", "inline"),
|
||||
wrap_long_lines: truthy?(Map.get(draft, "wrap_long_lines")),
|
||||
hide_unchanged_regions: truthy?(Map.get(draft, "hide_unchanged_regions"))
|
||||
}
|
||||
end
|
||||
|
||||
defp publishing_attrs(assigns) do
|
||||
draft = Map.get(assigns, :settings_editor_publishing_draft, %{})
|
||||
|
||||
@@ -292,10 +464,26 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
}
|
||||
end
|
||||
|
||||
defp ai_attrs(assigns) do
|
||||
draft = Map.get(assigns, :settings_editor_ai_draft, %{})
|
||||
|
||||
%{
|
||||
online_api_key: blank_to_nil(Map.get(draft, "online_api_key")),
|
||||
mistral_api_key: blank_to_nil(Map.get(draft, "mistral_api_key")),
|
||||
offline_mode: truthy?(Map.get(draft, "offline_mode")),
|
||||
default_model: blank_to_nil(Map.get(draft, "default_model")),
|
||||
title_model: blank_to_nil(Map.get(draft, "title_model")),
|
||||
image_analysis_model: blank_to_nil(Map.get(draft, "image_analysis_model")),
|
||||
offline_chat_model: blank_to_nil(Map.get(draft, "offline_chat_model")),
|
||||
offline_title_model: blank_to_nil(Map.get(draft, "offline_title_model")),
|
||||
offline_image_analysis_model: blank_to_nil(Map.get(draft, "offline_image_analysis_model")),
|
||||
system_prompt: Map.get(draft, "system_prompt", "")
|
||||
}
|
||||
end
|
||||
|
||||
defp project_metadata(assigns) do
|
||||
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
|
||||
{:ok, metadata} -> metadata
|
||||
_other -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -313,6 +501,15 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
}
|
||||
end
|
||||
|
||||
defp editor_form do
|
||||
%{
|
||||
"default_mode" => get_global_setting("ui.preferred_editor_mode") || "markdown",
|
||||
"diff_view_style" => get_global_setting("ui.git_diff_view_style") || "inline",
|
||||
"wrap_long_lines" => get_global_setting("ui.git_diff_word_wrap") == "true",
|
||||
"hide_unchanged_regions" => get_global_setting("ui.git_diff_hide_unchanged_regions") == "true"
|
||||
}
|
||||
end
|
||||
|
||||
defp publishing_form(metadata) do
|
||||
prefs = Map.get(metadata, :publishing_preferences, %{})
|
||||
|
||||
@@ -324,6 +521,30 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
}
|
||||
end
|
||||
|
||||
defp ai_form do
|
||||
{:ok, online_endpoint} = AI.get_endpoint(:online)
|
||||
{:ok, mistral_endpoint} = AI.get_endpoint(:mistral)
|
||||
|
||||
%{
|
||||
"online_api_key" => Map.get(online_endpoint || %{}, :api_key, ""),
|
||||
"mistral_api_key" => Map.get(mistral_endpoint || %{}, :api_key, ""),
|
||||
"offline_mode" => AI.airplane_mode?(),
|
||||
"default_model" => get_model_preference(:default),
|
||||
"title_model" => get_model_preference(:title),
|
||||
"image_analysis_model" => get_model_preference(:image_analysis),
|
||||
"offline_chat_model" => get_model_preference(:airplane_chat),
|
||||
"offline_title_model" => get_model_preference(:airplane_title),
|
||||
"offline_image_analysis_model" => get_model_preference(:airplane_image_analysis),
|
||||
"system_prompt" => get_global_setting("ai.system_prompt") || ""
|
||||
}
|
||||
end
|
||||
|
||||
defp technology_form(project_form) do
|
||||
%{
|
||||
"semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false)
|
||||
}
|
||||
end
|
||||
|
||||
defp current_theme(assigns) do
|
||||
assigns
|
||||
|> project_metadata()
|
||||
@@ -354,6 +575,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
end)
|
||||
end
|
||||
|
||||
defp category_names(metadata), do: Map.get(metadata, :categories, [])
|
||||
|
||||
defp normalize_project_params(params) do
|
||||
%{
|
||||
"name" => Map.get(params, "name", ""),
|
||||
@@ -368,6 +591,15 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_editor_params(params) do
|
||||
%{
|
||||
"default_mode" => Map.get(params, "default_mode", "markdown"),
|
||||
"diff_view_style" => Map.get(params, "diff_view_style", "inline"),
|
||||
"wrap_long_lines" => truthy?(Map.get(params, "wrap_long_lines")),
|
||||
"hide_unchanged_regions" => truthy?(Map.get(params, "hide_unchanged_regions"))
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_publishing_params(params) do
|
||||
%{
|
||||
"ssh_host" => Map.get(params, "ssh_host", ""),
|
||||
@@ -377,6 +609,217 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_ai_params(params) do
|
||||
%{
|
||||
"online_api_key" => Map.get(params, "online_api_key", ""),
|
||||
"mistral_api_key" => Map.get(params, "mistral_api_key", ""),
|
||||
"offline_mode" => truthy?(Map.get(params, "offline_mode")),
|
||||
"default_model" => Map.get(params, "default_model", ""),
|
||||
"title_model" => Map.get(params, "title_model", ""),
|
||||
"image_analysis_model" => Map.get(params, "image_analysis_model", ""),
|
||||
"offline_chat_model" => Map.get(params, "offline_chat_model", ""),
|
||||
"offline_title_model" => Map.get(params, "offline_title_model", ""),
|
||||
"offline_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""),
|
||||
"system_prompt" => Map.get(params, "system_prompt", "")
|
||||
}
|
||||
end
|
||||
|
||||
defp current_settings_section(assigns) do
|
||||
meta = current_tab_meta(assigns)
|
||||
|
||||
meta
|
||||
|> Map.get(:sidebar_item_id, "settings-project")
|
||||
|> to_string()
|
||||
|> String.replace_prefix("settings-", "")
|
||||
|> case do
|
||||
section when section in @settings_sections -> section
|
||||
_other -> "project"
|
||||
end
|
||||
end
|
||||
|
||||
defp current_tab_meta(assigns) do
|
||||
current_tab = Map.get(assigns, :current_tab)
|
||||
|
||||
case current_tab do
|
||||
%{type: type, id: id} -> Map.get(assigns[:tab_meta] || %{}, {type, id}, %{})
|
||||
_other -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
defp visible_settings_sections(query) do
|
||||
Enum.filter(@settings_sections, fn section ->
|
||||
case section do
|
||||
"project" -> section_matches?(query, ~w(project name description data url language author bookmarklet))
|
||||
"editor" -> section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged))
|
||||
"content" -> section_matches?(query, ~w(content categories templates lists blogmark))
|
||||
"ai" -> section_matches?(query, ~w(ai assistant model prompt offline endpoint api key title image mistral anthropic))
|
||||
"technology" -> section_matches?(query, ~w(technology semantic similarity runtime scripting embedding))
|
||||
"publishing" -> section_matches?(query, ~w(publishing ssh scp rsync host user remote path))
|
||||
"data" -> section_matches?(query, ~w(data rebuild maintenance links thumbnails filesystem))
|
||||
"mcp" -> section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp template_options(project_id) do
|
||||
%{
|
||||
post:
|
||||
Repo.all(
|
||||
from template in Template,
|
||||
where: template.project_id == ^project_id and template.kind == :post,
|
||||
order_by: [asc: template.title],
|
||||
select: %{slug: template.slug, title: template.title}
|
||||
),
|
||||
list:
|
||||
Repo.all(
|
||||
from template in Template,
|
||||
where: template.project_id == ^project_id and template.kind == :list,
|
||||
order_by: [asc: template.title],
|
||||
select: %{slug: template.slug, title: template.title}
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
defp model_options do
|
||||
Repo.all(
|
||||
from model in Model,
|
||||
order_by: [asc: model.provider, asc: model.name],
|
||||
select: %{
|
||||
id: model.model_id,
|
||||
provider: model.provider,
|
||||
name: model.name,
|
||||
context_window: model.context_window,
|
||||
max_output_tokens: model.max_output_tokens,
|
||||
supports_attachment: model.supports_attachment
|
||||
}
|
||||
)
|
||||
|> Enum.map(fn model ->
|
||||
Map.put(model, :label, model.provider <> " / " <> model.name)
|
||||
end)
|
||||
end
|
||||
|
||||
defp image_model_options do
|
||||
Enum.filter(model_options(), & &1.supports_attachment)
|
||||
end
|
||||
|
||||
defp mcp_rows do
|
||||
Enum.map(@mcp_agents, fn agent ->
|
||||
%{
|
||||
id: agent.id,
|
||||
label: agent.label,
|
||||
supported?: agent.supported?,
|
||||
configured?: mcp_configured?(agent),
|
||||
config_path: mcp_config_path(agent)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp find_mcp_agent(agent) do
|
||||
normalized =
|
||||
agent
|
||||
|> to_string()
|
||||
|> String.to_existing_atom()
|
||||
|
||||
Enum.find(@mcp_agents, &(&1.id == normalized))
|
||||
rescue
|
||||
_error -> nil
|
||||
end
|
||||
|
||||
defp mcp_configured?(%{supported?: false}), do: false
|
||||
|
||||
defp mcp_configured?(%{id: agent_id}) do
|
||||
path = AgentConfig.config_path(agent_id, System.user_home!())
|
||||
|
||||
if File.exists?(path) do
|
||||
path
|
||||
|> File.read!()
|
||||
|> Jason.decode!()
|
||||
|> mcp_server_present?(agent_id)
|
||||
else
|
||||
false
|
||||
end
|
||||
rescue
|
||||
_error -> false
|
||||
end
|
||||
|
||||
defp mcp_config_path(%{supported?: false}), do: nil
|
||||
defp mcp_config_path(%{id: agent_id}), do: AgentConfig.config_path(agent_id, System.user_home!())
|
||||
|
||||
defp mcp_server_present?(config, :github_copilot) do
|
||||
config
|
||||
|> Map.get("servers", %{})
|
||||
|> Map.has_key?("bDS")
|
||||
end
|
||||
|
||||
defp mcp_server_present?(config, _agent_id) do
|
||||
config
|
||||
|> Map.get("mcpServers", %{})
|
||||
|> Map.has_key?("bDS")
|
||||
end
|
||||
|
||||
defp get_model_preference(key) do
|
||||
case AI.get_model_preference(key) do
|
||||
{:ok, value} -> value || ""
|
||||
_other -> ""
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_model_preference(_key, nil), do: :ok
|
||||
defp maybe_put_model_preference(_key, ""), do: :ok
|
||||
defp maybe_put_model_preference(key, value), do: AI.put_model_preference(key, value)
|
||||
|
||||
defp maybe_put_endpoint(kind, nil, model) do
|
||||
case model do
|
||||
nil -> :ok
|
||||
"" -> :ok
|
||||
_other -> AI.put_endpoint(kind, %{model: model}) |> normalize_endpoint_result()
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_endpoint(kind, api_key, model) do
|
||||
AI.put_endpoint(kind, %{api_key: api_key, model: model}) |> normalize_endpoint_result()
|
||||
end
|
||||
|
||||
defp normalize_endpoint_result({:ok, _endpoint}), do: :ok
|
||||
defp normalize_endpoint_result({:error, reason}), do: {:error, reason}
|
||||
|
||||
defp ensure_default_categories(project_id) do
|
||||
Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc ->
|
||||
case Metadata.add_category(project_id, category) do
|
||||
{:ok, _metadata} -> {:cont, :ok}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp reset_default_category_settings(project_id) do
|
||||
Enum.reduce_while(@default_category_settings, :ok, fn {category, settings}, _acc ->
|
||||
case Metadata.update_category_settings(project_id, category, settings) do
|
||||
{:ok, _metadata} -> {:cont, :ok}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_global_setting(key) do
|
||||
case Repo.get(Setting, key) do
|
||||
%Setting{value: value} -> value
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp put_global_setting(key, value) do
|
||||
setting = Repo.get(Setting, key) || %Setting{}
|
||||
|
||||
setting
|
||||
|> Setting.changeset(%{key: key, value: to_string(value || ""), updated_at: Persistence.now_ms()})
|
||||
|> Repo.insert_or_update()
|
||||
|> case do
|
||||
{:ok, _setting} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp section_matches?("", _keywords), do: true
|
||||
defp section_matches?(query, keywords), do: Enum.any?(keywords, &String.contains?(&1, String.downcase(query)))
|
||||
|
||||
@@ -390,6 +833,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
end
|
||||
|
||||
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
|
||||
defp boolean_string(true), do: "true"
|
||||
defp boolean_string(false), do: "false"
|
||||
defp parse_integer(nil, fallback), do: fallback
|
||||
defp parse_integer(value, _fallback) when is_integer(value), do: value
|
||||
defp parse_integer(value, fallback) do
|
||||
@@ -406,4 +851,4 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
trimmed -> trimmed
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="settings-view-shell" data-testid="settings-editor">
|
||||
<div class="settings-view-shell" data-testid="settings-editor" data-selected-settings-section={@settings_editor.selected_section}>
|
||||
<div class="settings-view">
|
||||
<div class="settings-header">
|
||||
<h2 data-testid="editor-title"><%= translated("Settings") %></h2>
|
||||
@@ -8,7 +8,7 @@
|
||||
</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 %>
|
||||
<%= if Enum.empty?(@settings_editor.active_sections) do %>
|
||||
<div class="settings-no-results">
|
||||
<p><%= translated("No settings match the current search") %></p>
|
||||
</div>
|
||||
@@ -18,6 +18,7 @@
|
||||
<div class="setting-section" id="settings-section-project">
|
||||
<div class="setting-section-header">
|
||||
<h3><%= translated("Project") %></h3>
|
||||
<p class="setting-section-description"><%= translated("Blog identity, URLs, authoring defaults, and bookmarklet setup") %></p>
|
||||
</div>
|
||||
<form class="setting-section-content" phx-change="change_settings_project">
|
||||
<div class="setting-row">
|
||||
@@ -30,6 +31,15 @@
|
||||
<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("Data Path") %></label></div>
|
||||
<div class="setting-control">
|
||||
<div class="setting-input-group">
|
||||
<input type="text" value={@settings_editor.project_data_path} readonly />
|
||||
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= translated("Open") %></button>
|
||||
</div>
|
||||
</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>
|
||||
@@ -76,34 +86,107 @@
|
||||
</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 class="setting-info"><label class="setting-label"><%= translated("Blogmark Bookmarklet") %></label></div>
|
||||
<div class="setting-control"><p class="setting-description"><%= translated("Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p></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.editor_visible? do %>
|
||||
<div class="setting-section" id="settings-section-editor">
|
||||
<div class="setting-section-header">
|
||||
<h3><%= translated("Editor") %></h3>
|
||||
<p class="setting-section-description"><%= translated("Default editing mode and diff presentation") %></p>
|
||||
</div>
|
||||
<form class="setting-section-content" phx-change="change_settings_editor">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Default Editor Mode") %></label></div>
|
||||
<div class="setting-control">
|
||||
<select name="settings_editor[default_mode]">
|
||||
<option value="wysiwyg" selected={@settings_editor.editor["default_mode"] == "wysiwyg"}><%= translated("WYSIWYG") %></option>
|
||||
<option value="markdown" selected={@settings_editor.editor["default_mode"] == "markdown"}><%= translated("Markdown") %></option>
|
||||
<option value="preview" selected={@settings_editor.editor["default_mode"] == "preview"}><%= translated("Preview") %></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Diff View Style") %></label></div>
|
||||
<div class="setting-control">
|
||||
<select name="settings_editor[diff_view_style]">
|
||||
<option value="inline" selected={@settings_editor.editor["diff_view_style"] == "inline"}><%= translated("Inline") %></option>
|
||||
<option value="side-by-side" selected={@settings_editor.editor["diff_view_style"] == "side-by-side"}><%= translated("Side by Side") %></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Wrap Long Lines") %></label></div>
|
||||
<div class="setting-control"><label><input type="checkbox" name="settings_editor[wrap_long_lines]" checked={@settings_editor.editor["wrap_long_lines"]} /> <%= translated("Enable line wrapping in diffs") %></label></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Hide Unchanged Regions") %></label></div>
|
||||
<div class="setting-control"><label><input type="checkbox" name="settings_editor[hide_unchanged_regions]" checked={@settings_editor.editor["hide_unchanged_regions"]} /> <%= translated("Collapse unchanged diff hunks") %></label></div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_editor"><%= 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-header"><h3><%= translated("Content Categories") %></h3><p class="setting-section-description"><%= translated("Category defaults, rendering flags, and template wiring") %></p></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 %>
|
||||
<table class="categories-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= translated("Category") %></th>
|
||||
<th><%= translated("Title") %></th>
|
||||
<th><%= translated("Render in Lists") %></th>
|
||||
<th><%= translated("Show Titles") %></th>
|
||||
<th><%= translated("Post Template") %></th>
|
||||
<th><%= translated("List Template") %></th>
|
||||
<th><%= translated("Actions") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for category <- @settings_editor.categories do %>
|
||||
<tr>
|
||||
<td><%= category.name %></td>
|
||||
<td>
|
||||
<input type="text" name="category_settings[title]" value={category.title} form={"category-form-#{category.name}"} />
|
||||
</td>
|
||||
<td><input type="checkbox" name="category_settings[render_in_lists]" checked={category.render_in_lists} form={"category-form-#{category.name}"} /></td>
|
||||
<td><input type="checkbox" name="category_settings[show_title]" checked={category.show_title} form={"category-form-#{category.name}"} /></td>
|
||||
<td>
|
||||
<select name="category_settings[post_template_slug]" form={"category-form-#{category.name}"}>
|
||||
<option value=""><%= translated("Default") %></option>
|
||||
<%= for template <- @settings_editor.template_options.post do %>
|
||||
<option value={template.slug} selected={template.slug == category.post_template_slug}><%= template.title %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="category_settings[list_template_slug]" form={"category-form-#{category.name}"}>
|
||||
<option value=""><%= translated("Default") %></option>
|
||||
<%= for template <- @settings_editor.template_options.list do %>
|
||||
<option value={template.slug} selected={template.slug == category.list_template_slug}><%= template.title %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div class="setting-input-group">
|
||||
<form id={"category-form-#{category.name}"} phx-submit="save_settings_category">
|
||||
<input type="hidden" name="category_settings[category]" value={category.name} />
|
||||
</form>
|
||||
<button class="secondary" type="submit" form={"category-form-#{category.name}"}><%= translated("Save") %></button>
|
||||
<button class="secondary" type="button" phx-click="remove_settings_category" phx-value-category={category.name} disabled={category.protected?}><%= translated("Remove") %></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Add Category") %></label></div>
|
||||
<div class="setting-control">
|
||||
@@ -113,13 +196,122 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-actions"><button class="secondary" type="button" phx-click="reset_settings_categories"><%= translated("Reset to Defaults") %></button></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @settings_editor.ai_visible? do %>
|
||||
<div class="setting-section" id="settings-section-ai">
|
||||
<div class="setting-section-header"><h3><%= translated("AI") %></h3><p class="setting-section-description"><%= translated("Provider keys, model preferences, airplane mode, and system prompt") %></p></div>
|
||||
<form class="setting-section-content" phx-change="change_settings_ai">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Anthropic / Online API Key") %></label></div>
|
||||
<div class="setting-control"><input type="password" name="settings_ai[online_api_key]" value={@settings_editor.ai["online_api_key"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Mistral API Key") %></label></div>
|
||||
<div class="setting-control"><input type="password" name="settings_ai[mistral_api_key]" value={@settings_editor.ai["mistral_api_key"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Offline Mode") %></label></div>
|
||||
<div class="setting-control"><label><input type="checkbox" name="settings_ai[offline_mode]" checked={@settings_editor.ai["offline_mode"]} /> <%= translated("Route AI tasks through the airplane endpoint") %></label></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Default Model") %></label></div>
|
||||
<div class="setting-control">
|
||||
<select name="settings_ai[default_model]">
|
||||
<option value=""></option>
|
||||
<%= for model <- @settings_editor.model_options do %>
|
||||
<option value={model.id} selected={model.id == @settings_editor.ai["default_model"]}><%= model.label %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Title Model") %></label></div>
|
||||
<div class="setting-control">
|
||||
<select name="settings_ai[title_model]">
|
||||
<option value=""></option>
|
||||
<%= for model <- @settings_editor.model_options do %>
|
||||
<option value={model.id} selected={model.id == @settings_editor.ai["title_model"]}><%= model.label %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Image Analysis Model") %></label></div>
|
||||
<div class="setting-control">
|
||||
<select name="settings_ai[image_analysis_model]">
|
||||
<option value=""></option>
|
||||
<%= for model <- @settings_editor.image_model_options do %>
|
||||
<option value={model.id} selected={model.id == @settings_editor.ai["image_analysis_model"]}><%= model.label %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Offline Chat Model") %></label></div>
|
||||
<div class="setting-control">
|
||||
<select name="settings_ai[offline_chat_model]">
|
||||
<option value=""></option>
|
||||
<%= for model <- @settings_editor.model_options do %>
|
||||
<option value={model.id} selected={model.id == @settings_editor.ai["offline_chat_model"]}><%= model.label %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Offline Title Model") %></label></div>
|
||||
<div class="setting-control">
|
||||
<select name="settings_ai[offline_title_model]">
|
||||
<option value=""></option>
|
||||
<%= for model <- @settings_editor.model_options do %>
|
||||
<option value={model.id} selected={model.id == @settings_editor.ai["offline_title_model"]}><%= model.label %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Offline Image Analysis Model") %></label></div>
|
||||
<div class="setting-control">
|
||||
<select name="settings_ai[offline_image_analysis_model]">
|
||||
<option value=""></option>
|
||||
<%= for model <- @settings_editor.image_model_options do %>
|
||||
<option value={model.id} selected={model.id == @settings_editor.ai["offline_image_analysis_model"]}><%= model.label %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("System Prompt") %></label></div>
|
||||
<div class="setting-control"><textarea name="settings_ai[system_prompt]" rows="12"><%= @settings_editor.ai["system_prompt"] %></textarea></div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_ai"><%= translated("Save") %></button><button class="secondary" type="button" phx-click="reset_settings_ai_prompt"><%= translated("Reset to Default") %></button></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @settings_editor.technology_visible? do %>
|
||||
<div class="setting-section" id="settings-section-technology">
|
||||
<div class="setting-section-header"><h3><%= translated("Technology") %></h3><p class="setting-section-description"><%= translated("Application-level runtime behavior and semantic indexing") %></p></div>
|
||||
<form class="setting-section-content" phx-change="change_settings_project">
|
||||
<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.technology["semantic_similarity_enabled"]} /> <%= translated("Enable duplicate search and related-post embeddings") %></label></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Scripting Runtime") %></label></div>
|
||||
<div class="setting-control"><p class="setting-description"><%= translated("Scripting capabilities are configured at the application layer in the rewrite and do not expose runtime switching here.") %></p></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.publishing_visible? do %>
|
||||
<div class="setting-section" id="settings-section-publishing">
|
||||
<div class="setting-section-header"><h3><%= translated("Publishing") %></h3></div>
|
||||
<div class="setting-section-header"><h3><%= translated("Publishing") %></h3><p class="setting-section-description"><%= translated("Deployment credentials for upload tasks") %></p></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>
|
||||
@@ -130,11 +322,37 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @settings_editor.mcp_visible? do %>
|
||||
<div class="setting-section" id="settings-section-mcp">
|
||||
<div class="setting-section-header"><h3><%= translated("MCP") %></h3><p class="setting-section-description"><%= translated("Agent configuration files for the built-in bDS MCP server") %></p></div>
|
||||
<div class="setting-section-content">
|
||||
<%= for agent <- @settings_editor.mcp do %>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label class="setting-label"><%= agent.label %></label>
|
||||
<p class="setting-description"><%= agent.config_path || translated("Not supported in the rewrite yet") %></p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button class="secondary" type="button" phx-click="toggle_settings_mcp_agent" phx-value-agent={agent.id} disabled={not agent.supported?}>
|
||||
<%= if agent.configured?, do: translated("Remove"), else: translated("Add") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</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-section-header"><h3><%= translated("Data Maintenance") %></h3><p class="setting-section-description"><%= translated("Rebuild filesystem-backed records and thumbnails") %></p></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_posts_from_files"><%= translated("Rebuild Posts From Files") %></button>
|
||||
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_media_from_files"><%= translated("Rebuild Media From Files") %></button>
|
||||
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_scripts_from_files"><%= translated("Rebuild Scripts From Files") %></button>
|
||||
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_templates_from_files"><%= translated("Rebuild Templates From Files") %></button>
|
||||
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_post_links"><%= translated("Rebuild Links") %></button>
|
||||
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="regenerate_missing_thumbnails"><%= translated("Regenerate Missing Thumbnails") %></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>
|
||||
|
||||
Reference in New Issue
Block a user