405 lines
13 KiB
Elixir
405 lines
13 KiB
Elixir
defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
|
@moduledoc false
|
|
|
|
use Phoenix.LiveComponent
|
|
use Gettext, backend: BDS.Gettext
|
|
|
|
import Ecto.Query
|
|
|
|
alias BDS.Repo
|
|
alias BDS.Templates.Template
|
|
|
|
alias BDS.Desktop.ShellLive.SettingsEditor.AISettings
|
|
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
|
|
alias BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories
|
|
alias BDS.Desktop.ShellLive.SettingsEditor.MCPConfig
|
|
alias BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings
|
|
alias BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings
|
|
alias BDS.Desktop.ShellLive.SettingsEditor.StyleEditor
|
|
|
|
embed_templates("settings_editor_html/*")
|
|
|
|
@settings_sections ~w(project editor content ai technology publishing data mcp)
|
|
@supported_languages ["en", "de", "fr", "it", "es"]
|
|
|
|
defdelegate theme_display_name(theme), to: StyleEditor
|
|
defdelegate protected_category?(category), to: ManagedCategories
|
|
|
|
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
|
|
@impl true
|
|
def update(%{action: :save_project} = assigns, socket) do
|
|
socket = assign(socket, Map.drop(assigns, [:action]))
|
|
socket = ProjectSettings.save_project(socket, reload_callback(), append_output_callback())
|
|
{:ok, socket}
|
|
end
|
|
|
|
def update(assigns, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(assigns)
|
|
|> initialize_state()
|
|
|> build_data()
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
|
|
@impl true
|
|
def render(assigns) do
|
|
case assigns.current_tab do
|
|
%{type: :settings} -> settings_editor(assigns)
|
|
%{type: :style} -> style_editor(assigns)
|
|
_other -> ~H""
|
|
end
|
|
end
|
|
|
|
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
|
|
{:noreply, Phoenix.LiveView.Socket.t()}
|
|
@impl true
|
|
def handle_event("change_settings_search", %{"query" => query}, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:settings_editor_search, to_string(query || ""))
|
|
|> build_data()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("change_settings_project", %{"settings_project" => params}, socket) do
|
|
{:noreply, ProjectSettings.update_project_draft(socket, params, reload_callback())}
|
|
end
|
|
|
|
def handle_event("change_settings_editor", %{"settings_editor" => params}, socket) do
|
|
{:noreply, EditorSettings.update_editor_draft(socket, params, reload_callback())}
|
|
end
|
|
|
|
def handle_event("save_settings_editor", _params, socket) do
|
|
{:noreply, EditorSettings.save_editor(socket, reload_callback(), append_output_callback())}
|
|
end
|
|
|
|
def handle_event("save_settings_project", _params, socket) do
|
|
socket = ProjectSettings.save_project(socket, reload_callback(), append_output_callback())
|
|
notify_parent(:settings_changed)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("change_settings_publishing", %{"settings_publishing" => params}, socket) do
|
|
{:noreply, PublishingSettings.update_publishing_draft(socket, params, reload_callback())}
|
|
end
|
|
|
|
def handle_event("change_settings_ai", %{"settings_ai" => params}, socket) do
|
|
{:noreply, AISettings.update_ai_draft(socket, params, reload_callback())}
|
|
end
|
|
|
|
def handle_event("refresh_settings_ai_models", %{"endpoint" => endpoint}, socket) do
|
|
case BDS.BoundedAtoms.ai_endpoint(endpoint) do
|
|
nil ->
|
|
{:noreply, build_data(socket)}
|
|
|
|
endpoint_key ->
|
|
{:noreply,
|
|
AISettings.refresh_ai_models(
|
|
socket,
|
|
endpoint_key,
|
|
reload_callback(),
|
|
append_output_callback()
|
|
)}
|
|
end
|
|
end
|
|
|
|
def handle_event("save_settings_ai", _params, socket) do
|
|
socket = AISettings.save_ai(socket, reload_callback(), append_output_callback())
|
|
notify_parent(:settings_changed)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("reset_settings_ai_prompt", _params, socket) do
|
|
{:noreply, AISettings.reset_ai_prompt(socket, reload_callback(), append_output_callback())}
|
|
end
|
|
|
|
def handle_event("save_settings_publishing", _params, socket) do
|
|
socket =
|
|
PublishingSettings.save_publishing(socket, reload_callback(), append_output_callback())
|
|
|
|
notify_parent(:settings_changed)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("clear_settings_publishing", _params, socket) do
|
|
{:noreply,
|
|
PublishingSettings.clear_publishing(socket, reload_callback(), append_output_callback())}
|
|
end
|
|
|
|
def handle_event("change_settings_new_category", %{"name" => name}, socket) do
|
|
{:noreply, ManagedCategories.update_new_category(socket, name, reload_callback())}
|
|
end
|
|
|
|
def handle_event("add_settings_category", _params, socket) do
|
|
socket = ManagedCategories.add_category(socket, reload_callback(), append_output_callback())
|
|
notify_parent(:settings_changed)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("reset_settings_categories", _params, socket) do
|
|
socket =
|
|
ManagedCategories.reset_categories(socket, reload_callback(), append_output_callback())
|
|
|
|
notify_parent(:settings_changed)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("save_settings_category", %{"category_settings" => params}, socket) do
|
|
socket =
|
|
ManagedCategories.save_category(socket, params, reload_callback(), append_output_callback())
|
|
|
|
notify_parent(:settings_changed)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("remove_settings_category", %{"category" => category}, socket) do
|
|
socket =
|
|
ManagedCategories.remove_category(
|
|
socket,
|
|
category,
|
|
reload_callback(),
|
|
append_output_callback()
|
|
)
|
|
|
|
notify_parent(:settings_changed)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("toggle_settings_mcp_agent", %{"agent" => agent}, socket) do
|
|
socket =
|
|
MCPConfig.toggle_mcp_agent(socket, agent, reload_callback(), append_output_callback())
|
|
|
|
notify_parent(:settings_changed)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("select_style_theme", %{"theme" => theme}, socket) do
|
|
{:noreply, StyleEditor.select_style_theme(socket, theme, reload_callback())}
|
|
end
|
|
|
|
def handle_event("change_style_preview_mode", %{"mode" => mode}, socket) do
|
|
{:noreply, StyleEditor.change_style_preview_mode(socket, mode, reload_callback())}
|
|
end
|
|
|
|
def handle_event("apply_style_theme", _params, socket) do
|
|
socket = StyleEditor.apply_style_theme(socket, reload_callback(), append_output_callback())
|
|
notify_parent(:settings_changed)
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp initialize_state(socket) do
|
|
defaults = %{
|
|
settings_editor_search: "",
|
|
settings_editor_project_draft: %{},
|
|
settings_editor_editor_draft: %{},
|
|
settings_editor_ai_draft: %{},
|
|
settings_editor_publishing_draft: %{},
|
|
settings_editor_new_category: "",
|
|
settings_editor_endpoint_models: %{},
|
|
style_editor_theme: nil,
|
|
style_editor_preview_mode: "auto"
|
|
}
|
|
|
|
Enum.reduce(defaults, socket, fn {key, default}, acc ->
|
|
if is_nil(Map.get(acc.assigns, key)) do
|
|
assign(acc, key, default)
|
|
else
|
|
acc
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp build_data(socket) do
|
|
socket
|
|
|> assign(:settings_editor, build_settings(socket.assigns))
|
|
|> assign(:style_editor, build_style(socket.assigns))
|
|
end
|
|
|
|
@spec build_settings(map()) :: term()
|
|
def build_settings(%{projects: %{active_project_id: nil}}), do: nil
|
|
|
|
def build_settings(assigns) do
|
|
metadata = ProjectSettings.project_metadata(assigns)
|
|
|
|
project_form =
|
|
Map.merge(
|
|
ProjectSettings.project_form(metadata),
|
|
Map.get(assigns, :settings_editor_project_draft, %{})
|
|
)
|
|
|
|
editor_form =
|
|
Map.merge(
|
|
EditorSettings.editor_form(),
|
|
Map.get(assigns, :settings_editor_editor_draft, %{})
|
|
)
|
|
|
|
ai_form =
|
|
Map.merge(AISettings.ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{}))
|
|
|
|
publishing_form =
|
|
Map.merge(
|
|
PublishingSettings.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: ManagedCategories.category_rows(metadata),
|
|
ai: ai_form,
|
|
technology: ProjectSettings.technology_form(project_form),
|
|
publishing: publishing_form,
|
|
mcp: MCPConfig.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),
|
|
online_endpoint_models: AISettings.endpoint_model_options(assigns, :online),
|
|
offline_endpoint_models: AISettings.endpoint_model_options(assigns, :airplane),
|
|
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 airplane offline online endpoint url api key chat title image)
|
|
),
|
|
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: ManagedCategories.protected_categories()
|
|
}
|
|
end
|
|
|
|
@spec build_style(map()) :: term()
|
|
def build_style(%{projects: %{active_project_id: nil}}), do: nil
|
|
|
|
def build_style(assigns) do
|
|
StyleEditor.build_style(assigns)
|
|
end
|
|
|
|
defp reload_callback do
|
|
fn socket, _workbench -> build_data(socket) end
|
|
end
|
|
|
|
defp append_output_callback do
|
|
fn socket, title, message, _details, level ->
|
|
send(self(), {:settings_output, title, message, level})
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp notify_parent(message) do
|
|
send(self(), message)
|
|
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 airplane offline online endpoint url api key chat title image)
|
|
)
|
|
|
|
"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 section_matches?("", _keywords), do: true
|
|
|
|
defp section_matches?(query, keywords),
|
|
do: Enum.any?(keywords, &String.contains?(&1, String.downcase(query)))
|
|
end
|