feat: better parity in layout for media and preferences

This commit is contained in:
2026-04-26 21:50:31 +02:00
parent c34c7cd3e0
commit 334ffe6f6a
14 changed files with 1786 additions and 340 deletions

View File

@@ -117,6 +117,56 @@ defmodule BDS.Desktop.ShellCommands do
end)
end
defp dispatch("rebuild_posts_from_files", project, _params) do
queue_task(project, "rebuild_posts_from_files", "Rebuild Posts From Files", "Maintenance", fn report ->
{:ok, posts} = Maintenance.rebuild_from_filesystem(project.id, "post", on_progress: report, rebuild_embeddings: false)
report.(1.0, "Post rebuild complete")
%{project_id: project.id, counts: %{posts: length(posts)}}
end)
end
defp dispatch("rebuild_media_from_files", project, _params) do
queue_task(project, "rebuild_media_from_files", "Rebuild Media From Files", "Maintenance", fn report ->
{:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
report.(1.0, "Media rebuild complete")
%{project_id: project.id, counts: %{media: length(media)}}
end)
end
defp dispatch("rebuild_scripts_from_files", project, _params) do
queue_task(project, "rebuild_scripts_from_files", "Rebuild Scripts From Files", "Maintenance", fn report ->
{:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
report.(1.0, "Script rebuild complete")
%{project_id: project.id, counts: %{scripts: length(scripts)}}
end)
end
defp dispatch("rebuild_templates_from_files", project, _params) do
queue_task(project, "rebuild_templates_from_files", "Rebuild Templates From Files", "Maintenance", fn report ->
{:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
report.(1.0, "Template rebuild complete")
%{project_id: project.id, counts: %{templates: length(templates)}}
end)
end
defp dispatch("rebuild_post_links", project, _params) do
queue_task(project, "rebuild_post_links", "Rebuild Post Links", "Maintenance", fn report ->
report.(0.0, "Rebuilding link graph")
:ok = Posts.rebuild_post_links(project.id)
report.(1.0, "Post links rebuilt")
%{project_id: project.id}
end)
end
defp dispatch("regenerate_missing_thumbnails", project, _params) do
queue_task(project, "regenerate_missing_thumbnails", "Regenerate Missing Thumbnails", "Maintenance", fn report ->
report.(0.0, "Checking missing thumbnails")
result = BDS.Media.regenerate_missing_thumbnails(project.id)
report.(1.0, "Missing thumbnails regenerated")
Map.put(result, :project_id, project.id)
end)
end
defp dispatch("rebuild_database", project, _params) do
group_id = task_group_id("rebuild_database")
attrs = %{group_id: group_id, group_name: "Maintenance"}

View File

@@ -509,6 +509,14 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, SettingsEditor.update_project_draft(socket, params, &reload_shell/2)}
end
def handle_event("change_settings_editor", %{"settings_editor" => params}, socket) do
{:noreply, SettingsEditor.update_editor_draft(socket, params, &reload_shell/2)}
end
def handle_event("save_settings_editor", _params, socket) do
{:noreply, SettingsEditor.save_editor(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("save_settings_project", _params, socket) do
{:noreply, SettingsEditor.save_project(socket, &reload_shell/2, &append_output_entry/5)}
end
@@ -517,6 +525,18 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, SettingsEditor.update_publishing_draft(socket, params, &reload_shell/2)}
end
def handle_event("change_settings_ai", %{"settings_ai" => params}, socket) do
{:noreply, SettingsEditor.update_ai_draft(socket, params, &reload_shell/2)}
end
def handle_event("save_settings_ai", _params, socket) do
{:noreply, SettingsEditor.save_ai(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("reset_settings_ai_prompt", _params, socket) do
{:noreply, SettingsEditor.reset_ai_prompt(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("save_settings_publishing", _params, socket) do
{:noreply, SettingsEditor.save_publishing(socket, &reload_shell/2, &append_output_entry/5)}
end
@@ -533,6 +553,10 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, SettingsEditor.add_category(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("reset_settings_categories", _params, socket) do
{:noreply, SettingsEditor.reset_categories(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("save_settings_category", %{"category_settings" => params}, socket) do
{:noreply, SettingsEditor.save_category(socket, params, &reload_shell/2, &append_output_entry/5)}
end
@@ -545,6 +569,10 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, apply_shell_command(socket, action)}
end
def handle_event("toggle_settings_mcp_agent", %{"agent" => agent}, socket) do
{:noreply, SettingsEditor.toggle_mcp_agent(socket, agent, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("select_style_theme", %{"theme" => theme}, socket) do
{:noreply, SettingsEditor.select_style_theme(socket, theme, &reload_shell/2)}
end
@@ -1326,6 +1354,7 @@ defmodule BDS.Desktop.ShellLive do
tab_meta =
Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{
sidebar_item_id: Map.get(params, "id"),
title: Map.get(params, "title", ""),
subtitle: Map.get(params, "subtitle", "")
})

View File

@@ -116,27 +116,28 @@
<% end %>
</div>
<form class="media-details media-editor-details-form" data-testid="media-editor-form" phx-change="change_media_editor">
<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("File Name") %></label>
<input class="post-editor-input is-readonly" type="text" value={@media_editor.original_name} readonly />
<input class="post-editor-input disabled" type="text" value={@media_editor.original_name} disabled />
</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 />
<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("Size") %></label>
<input class="post-editor-input is-readonly" type="text" value={@media_editor.file_size} readonly />
<input class="post-editor-input disabled" type="text" value={@media_editor.file_size} disabled />
</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 />
<input class="post-editor-input disabled" type="text" value={@media_editor.dimensions} disabled />
</div>
<% end %>
</div>
@@ -176,7 +177,6 @@
</select>
</div>
</form>
</div>
<%= if @media_editor.form["language"] not in [nil, ""] do %>
<div class="editor-field media-translations-section">
@@ -195,7 +195,7 @@
phx-value-id={@media_editor.id}
phx-value-language={translation.language}
>
<%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " - #{translation.title}" %>
<%= 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") %>
@@ -205,7 +205,6 @@
<% end %>
</div>
<% end %>
</div>
<% end %>
@@ -270,6 +269,8 @@
</div>
<% end %>
</div>
</div>
</div>
<%= if @media_editor.editing_translation do %>
<div class="translation-modal-backdrop">

View File

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

View File

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

View File

@@ -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.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>
<%= 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><p class="setting-section-description"><%= translated("Category defaults, rendering flags, and template wiring") %></p></div>
<div class="setting-section-content">
<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>

View File

@@ -249,5 +249,94 @@
"Ask the assistant about the active project or editor.": "Frage den Assistenten zum aktiven Projekt oder Editor.",
"Start chat": "Chat starten",
"You": "Du",
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "Die Chat-Oberfläche der Assistenten-Seitenleiste ist bereit, aber die Modellausführung ist noch nicht verbunden."
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "Die Chat-Oberfläche der Assistenten-Seitenleiste ist bereit, aber die Modellausführung ist noch nicht verbunden.",
"Search settings": "Einstellungen durchsuchen",
"No settings match the current search": "Keine Einstellungen entsprechen der aktuellen Suche",
"Blog identity, URLs, authoring defaults, and bookmarklet setup": "Blog-Identität, URLs, Autorenvorgaben und Bookmarklet-Einrichtung",
"Project Name": "Projektname",
"Description": "Beschreibung",
"Data Path": "Datenpfad",
"Public URL": "Öffentliche URL",
"Blog Languages": "Blog-Sprachen",
"Default Author": "Standardautor",
"Max Posts Per Page": "Maximale Beiträge pro Seite",
"Blogmark Category": "Blogmark-Kategorie",
"Blogmark Bookmarklet": "Blogmark-Bookmarklet",
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "Die Bookmarklet-Kopierfunktion ist über die Desktop-Laufzeit und die öffentliche Projekt-URL verdrahtet.",
"Default editing mode and diff presentation": "Standard-Bearbeitungsmodus und Diff-Darstellung",
"Default Editor Mode": "Standard-Bearbeitungsmodus",
"Diff View Style": "Diff-Ansicht",
"Inline": "Inline",
"Side by Side": "Nebeneinander",
"Wrap Long Lines": "Lange Zeilen umbrechen",
"Enable line wrapping in diffs": "Zeilenumbruch in Diffs aktivieren",
"Hide Unchanged Regions": "Unveränderte Bereiche ausblenden",
"Collapse unchanged diff hunks": "Unveränderte Diff-Blöcke einklappen",
"Content Categories": "Inhaltskategorien",
"Category defaults, rendering flags, and template wiring": "Kategorie-Standards, Render-Flags und Template-Zuordnung",
"Category": "Kategorie",
"Render in Lists": "In Listen rendern",
"Show Titles": "Titel anzeigen",
"Post Template": "Beitragsvorlage",
"List Template": "Listen-Vorlage",
"Default": "Standard",
"Add Category": "Kategorie hinzufügen",
"Reset to Defaults": "Auf Standard zurücksetzen",
"Provider keys, model preferences, airplane mode, and system prompt": "Provider-Schlüssel, Modellvorgaben, Flugmodus und System-Prompt",
"Anthropic / Online API Key": "Anthropic-/Online-API-Schlüssel",
"Mistral API Key": "Mistral-API-Schlüssel",
"Offline Mode": "Offline-Modus",
"Route AI tasks through the airplane endpoint": "KI-Aufgaben über den Flugmodus-Endpunkt leiten",
"Default Model": "Standardmodell",
"Title Model": "Titelmodell",
"Image Analysis Model": "Bildanalysemodell",
"Offline Chat Model": "Offline-Chatmodell",
"Offline Title Model": "Offline-Titelmodell",
"Offline Image Analysis Model": "Offline-Bildanalysemodell",
"System Prompt": "System-Prompt",
"Reset to Default": "Auf Standard zurücksetzen",
"Application-level runtime behavior and semantic indexing": "Anwendungsverhalten zur Laufzeit und semantische Indizierung",
"Semantic Similarity": "Semantische Ähnlichkeit",
"Enable duplicate search and related-post embeddings": "Duplikatsuche und Embeddings für verwandte Beiträge aktivieren",
"Scripting Runtime": "Scripting-Laufzeit",
"Scripting capabilities are configured at the application layer in the rewrite and do not expose runtime switching here.": "Scripting-Funktionen werden in der Neufassung auf Anwendungsebene konfiguriert und bieten hier keine Umschaltung der Laufzeit.",
"Deployment credentials for upload tasks": "Bereitstellungszugangsdaten für Upload-Aufgaben",
"SSH Mode": "SSH-Modus",
"Host": "Host",
"Username": "Benutzername",
"Remote Path": "Remote-Pfad",
"Agent configuration files for the built-in bDS MCP server": "Agent-Konfigurationsdateien für den integrierten bDS-MCP-Server",
"Not supported in the rewrite yet": "In der Neufassung noch nicht unterstützt",
"Remove": "Entfernen",
"Add": "Hinzufügen",
"Data Maintenance": "Datenwartung",
"Rebuild filesystem-backed records and thumbnails": "Dateisystemgestützte Datensätze und Vorschaubilder neu aufbauen",
"Rebuild Posts From Files": "Beiträge aus Dateien neu aufbauen",
"Rebuild Media From Files": "Medien aus Dateien neu aufbauen",
"Rebuild Scripts From Files": "Skripte aus Dateien neu aufbauen",
"Rebuild Templates From Files": "Vorlagen aus Dateien neu aufbauen",
"Rebuild Links": "Links neu aufbauen",
"Regenerate Missing Thumbnails": "Fehlende Vorschaubilder neu erzeugen",
"Rebuild Embedding Index": "Embedding-Index neu aufbauen",
"Quick Actions": "Schnellaktionen",
"Review title, alt text, and caption suggestions": "Titel-, Alt-Text- und Beschriftungsvorschläge prüfen",
"Detect Language": "Sprache erkennen",
"Persist the detected language for this media item": "Die erkannte Sprache für dieses Medium speichern",
"Select a target language for this media item": "Eine Zielsprache für dieses Medium auswählen",
"Replace File": "Datei ersetzen",
"File Name": "Dateiname",
"MIME Type": "MIME-Typ",
"Size": "Größe",
"Dimensions": "Abmessungen",
"Author": "Autor",
"Language": "Sprache",
"None": "Keine",
"No translations": "Keine Übersetzungen",
"Refresh": "Aktualisieren",
"Linked Posts": "Verknüpfte Beiträge",
"Link to Post": "Mit Beitrag verknüpfen",
"Search posts": "Beiträge durchsuchen",
"No posts to link": "Keine Beiträge zum Verknüpfen",
"and %{count} more": "und %{count} weitere",
"Not linked to any posts": "Mit keinen Beiträgen verknüpft"
}

View File

@@ -249,5 +249,94 @@
"Ask the assistant about the active project or editor.": "Ask the assistant about the active project or editor.",
"Start chat": "Start chat",
"You": "You",
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "The assistant sidebar chat surface is ready, but model execution is not connected yet."
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "The assistant sidebar chat surface is ready, but model execution is not connected yet.",
"Search settings": "Search settings",
"No settings match the current search": "No settings match the current search",
"Blog identity, URLs, authoring defaults, and bookmarklet setup": "Blog identity, URLs, authoring defaults, and bookmarklet setup",
"Project Name": "Project Name",
"Description": "Description",
"Data Path": "Data Path",
"Public URL": "Public URL",
"Blog Languages": "Blog Languages",
"Default Author": "Default Author",
"Max Posts Per Page": "Max Posts Per Page",
"Blogmark Category": "Blogmark Category",
"Blogmark Bookmarklet": "Blogmark Bookmarklet",
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "Bookmarklet copy support is wired through the desktop runtime and project public URL.",
"Default editing mode and diff presentation": "Default editing mode and diff presentation",
"Default Editor Mode": "Default Editor Mode",
"Diff View Style": "Diff View Style",
"Inline": "Inline",
"Side by Side": "Side by Side",
"Wrap Long Lines": "Wrap Long Lines",
"Enable line wrapping in diffs": "Enable line wrapping in diffs",
"Hide Unchanged Regions": "Hide Unchanged Regions",
"Collapse unchanged diff hunks": "Collapse unchanged diff hunks",
"Content Categories": "Content Categories",
"Category defaults, rendering flags, and template wiring": "Category defaults, rendering flags, and template wiring",
"Category": "Category",
"Render in Lists": "Render in Lists",
"Show Titles": "Show Titles",
"Post Template": "Post Template",
"List Template": "List Template",
"Default": "Default",
"Add Category": "Add Category",
"Reset to Defaults": "Reset to Defaults",
"Provider keys, model preferences, airplane mode, and system prompt": "Provider keys, model preferences, airplane mode, and system prompt",
"Anthropic / Online API Key": "Anthropic / Online API Key",
"Mistral API Key": "Mistral API Key",
"Offline Mode": "Offline Mode",
"Route AI tasks through the airplane endpoint": "Route AI tasks through the airplane endpoint",
"Default Model": "Default Model",
"Title Model": "Title Model",
"Image Analysis Model": "Image Analysis Model",
"Offline Chat Model": "Offline Chat Model",
"Offline Title Model": "Offline Title Model",
"Offline Image Analysis Model": "Offline Image Analysis Model",
"System Prompt": "System Prompt",
"Reset to Default": "Reset to Default",
"Application-level runtime behavior and semantic indexing": "Application-level runtime behavior and semantic indexing",
"Semantic Similarity": "Semantic Similarity",
"Enable duplicate search and related-post embeddings": "Enable duplicate search and related-post embeddings",
"Scripting Runtime": "Scripting Runtime",
"Scripting capabilities are configured at the application layer in the rewrite and do not expose runtime switching here.": "Scripting capabilities are configured at the application layer in the rewrite and do not expose runtime switching here.",
"Deployment credentials for upload tasks": "Deployment credentials for upload tasks",
"SSH Mode": "SSH Mode",
"Host": "Host",
"Username": "Username",
"Remote Path": "Remote Path",
"Agent configuration files for the built-in bDS MCP server": "Agent configuration files for the built-in bDS MCP server",
"Not supported in the rewrite yet": "Not supported in the rewrite yet",
"Remove": "Remove",
"Add": "Add",
"Data Maintenance": "Data Maintenance",
"Rebuild filesystem-backed records and thumbnails": "Rebuild filesystem-backed records and thumbnails",
"Rebuild Posts From Files": "Rebuild Posts From Files",
"Rebuild Media From Files": "Rebuild Media From Files",
"Rebuild Scripts From Files": "Rebuild Scripts From Files",
"Rebuild Templates From Files": "Rebuild Templates From Files",
"Rebuild Links": "Rebuild Links",
"Regenerate Missing Thumbnails": "Regenerate Missing Thumbnails",
"Rebuild Embedding Index": "Rebuild Embedding Index",
"Quick Actions": "Quick Actions",
"Review title, alt text, and caption suggestions": "Review title, alt text, and caption suggestions",
"Detect Language": "Detect Language",
"Persist the detected language for this media item": "Persist the detected language for this media item",
"Select a target language for this media item": "Select a target language for this media item",
"Replace File": "Replace File",
"File Name": "File Name",
"MIME Type": "MIME Type",
"Size": "Size",
"Dimensions": "Dimensions",
"Author": "Author",
"Language": "Language",
"None": "None",
"No translations": "No translations",
"Refresh": "Refresh",
"Linked Posts": "Linked Posts",
"Link to Post": "Link to Post",
"Search posts": "Search posts",
"No posts to link": "No posts to link",
"and %{count} more": "and %{count} more",
"Not linked to any posts": "Not linked to any posts"
}

View File

@@ -249,5 +249,94 @@
"Ask the assistant about the active project or editor.": "Pregunta al asistente sobre el proyecto o editor activo.",
"Start chat": "Iniciar chat",
"You": "Tú",
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "La superficie de chat de la barra lateral del asistente está lista, pero la ejecución del modelo aún no está conectada."
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "La superficie de chat de la barra lateral del asistente está lista, pero la ejecución del modelo aún no está conectada.",
"Search settings": "Buscar en la configuración",
"No settings match the current search": "Ninguna configuración coincide con la búsqueda actual",
"Blog identity, URLs, authoring defaults, and bookmarklet setup": "Identidad del blog, URL, valores predeterminados de autoría y configuración del bookmarklet",
"Project Name": "Nombre del proyecto",
"Description": "Descripción",
"Data Path": "Ruta de datos",
"Public URL": "URL pública",
"Blog Languages": "Idiomas del blog",
"Default Author": "Autor predeterminado",
"Max Posts Per Page": "Máximo de publicaciones por página",
"Blogmark Category": "Categoría de blogmark",
"Blogmark Bookmarklet": "Bookmarklet de blogmark",
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "La copia del bookmarklet está conectada mediante el entorno de escritorio y la URL pública del proyecto.",
"Default editing mode and diff presentation": "Modo de edición predeterminado y presentación de diff",
"Default Editor Mode": "Modo de editor predeterminado",
"Diff View Style": "Estilo de vista diff",
"Inline": "En línea",
"Side by Side": "Lado a lado",
"Wrap Long Lines": "Ajustar líneas largas",
"Enable line wrapping in diffs": "Activar ajuste de línea en diffs",
"Hide Unchanged Regions": "Ocultar regiones sin cambios",
"Collapse unchanged diff hunks": "Contraer bloques de diff sin cambios",
"Content Categories": "Categorías de contenido",
"Category defaults, rendering flags, and template wiring": "Valores predeterminados de categoría, opciones de renderizado y conexión de plantillas",
"Category": "Categoría",
"Render in Lists": "Mostrar en listas",
"Show Titles": "Mostrar títulos",
"Post Template": "Plantilla de publicación",
"List Template": "Plantilla de lista",
"Default": "Predeterminado",
"Add Category": "Agregar categoría",
"Reset to Defaults": "Restablecer valores predeterminados",
"Provider keys, model preferences, airplane mode, and system prompt": "Claves del proveedor, preferencias de modelo, modo avión y prompt del sistema",
"Anthropic / Online API Key": "Clave API de Anthropic / en línea",
"Mistral API Key": "Clave API de Mistral",
"Offline Mode": "Modo sin conexión",
"Route AI tasks through the airplane endpoint": "Enviar tareas de IA mediante el endpoint de modo avión",
"Default Model": "Modelo predeterminado",
"Title Model": "Modelo de título",
"Image Analysis Model": "Modelo de análisis de imágenes",
"Offline Chat Model": "Modelo de chat sin conexión",
"Offline Title Model": "Modelo de título sin conexión",
"Offline Image Analysis Model": "Modelo de análisis de imágenes sin conexión",
"System Prompt": "Prompt del sistema",
"Reset to Default": "Restablecer al predeterminado",
"Application-level runtime behavior and semantic indexing": "Comportamiento de ejecución a nivel de aplicación e indexación semántica",
"Semantic Similarity": "Similitud semántica",
"Enable duplicate search and related-post embeddings": "Activar búsqueda de duplicados y embeddings de publicaciones relacionadas",
"Scripting Runtime": "Entorno de scripts",
"Scripting capabilities are configured at the application layer in the rewrite and do not expose runtime switching here.": "Las capacidades de scripts se configuran en la capa de aplicación en la reescritura y no exponen aquí el cambio de entorno.",
"Deployment credentials for upload tasks": "Credenciales de despliegue para tareas de subida",
"SSH Mode": "Modo SSH",
"Host": "Host",
"Username": "Nombre de usuario",
"Remote Path": "Ruta remota",
"Agent configuration files for the built-in bDS MCP server": "Archivos de configuración de agentes para el servidor MCP integrado de bDS",
"Not supported in the rewrite yet": "Todavía no compatible en la reescritura",
"Remove": "Quitar",
"Add": "Agregar",
"Data Maintenance": "Mantenimiento de datos",
"Rebuild filesystem-backed records and thumbnails": "Reconstruir registros respaldados por el sistema de archivos y miniaturas",
"Rebuild Posts From Files": "Reconstruir publicaciones desde archivos",
"Rebuild Media From Files": "Reconstruir medios desde archivos",
"Rebuild Scripts From Files": "Reconstruir scripts desde archivos",
"Rebuild Templates From Files": "Reconstruir plantillas desde archivos",
"Rebuild Links": "Reconstruir enlaces",
"Regenerate Missing Thumbnails": "Regenerar miniaturas faltantes",
"Rebuild Embedding Index": "Reconstruir índice de embeddings",
"Quick Actions": "Acciones rápidas",
"Review title, alt text, and caption suggestions": "Revisar sugerencias de título, texto alternativo y leyenda",
"Detect Language": "Detectar idioma",
"Persist the detected language for this media item": "Guardar el idioma detectado para este medio",
"Select a target language for this media item": "Seleccionar un idioma de destino para este medio",
"Replace File": "Reemplazar archivo",
"File Name": "Nombre del archivo",
"MIME Type": "Tipo MIME",
"Size": "Tamaño",
"Dimensions": "Dimensiones",
"Author": "Autor",
"Language": "Idioma",
"None": "Ninguno",
"No translations": "Sin traducciones",
"Refresh": "Actualizar",
"Linked Posts": "Publicaciones enlazadas",
"Link to Post": "Enlazar con publicación",
"Search posts": "Buscar publicaciones",
"No posts to link": "No hay publicaciones para enlazar",
"and %{count} more": "y %{count} más",
"Not linked to any posts": "No está enlazado a ninguna publicación"
}

View File

@@ -249,5 +249,94 @@
"Ask the assistant about the active project or editor.": "Interrogez lassistant sur le projet ou léditeur actif.",
"Start chat": "Démarrer la conversation",
"You": "Vous",
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "La surface de discussion de la barre latérale de lassistant est prête, mais lexécution du modèle nest pas encore connectée."
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "La surface de discussion de la barre latérale de lassistant est prête, mais lexécution du modèle nest pas encore connectée.",
"Search settings": "Rechercher dans les paramètres",
"No settings match the current search": "Aucun paramètre ne correspond à la recherche actuelle",
"Blog identity, URLs, authoring defaults, and bookmarklet setup": "Identité du blog, URLs, valeurs dauteur par défaut et configuration du bookmarklet",
"Project Name": "Nom du projet",
"Description": "Description",
"Data Path": "Chemin des données",
"Public URL": "URL publique",
"Blog Languages": "Langues du blog",
"Default Author": "Auteur par défaut",
"Max Posts Per Page": "Nombre maximal darticles par page",
"Blogmark Category": "Catégorie de blogmark",
"Blogmark Bookmarklet": "Bookmarklet blogmark",
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "La copie du bookmarklet est reliée via lenvironnement desktop et lURL publique du projet.",
"Default editing mode and diff presentation": "Mode dédition par défaut et présentation des diffs",
"Default Editor Mode": "Mode dédition par défaut",
"Diff View Style": "Style de vue diff",
"Inline": "En ligne",
"Side by Side": "Côte à côte",
"Wrap Long Lines": "Renvoyer les longues lignes",
"Enable line wrapping in diffs": "Activer le retour à la ligne dans les diffs",
"Hide Unchanged Regions": "Masquer les zones inchangées",
"Collapse unchanged diff hunks": "Réduire les blocs de diff inchangés",
"Content Categories": "Catégories de contenu",
"Category defaults, rendering flags, and template wiring": "Valeurs par défaut des catégories, options de rendu et liaison des modèles",
"Category": "Catégorie",
"Render in Lists": "Afficher dans les listes",
"Show Titles": "Afficher les titres",
"Post Template": "Modèle darticle",
"List Template": "Modèle de liste",
"Default": "Par défaut",
"Add Category": "Ajouter une catégorie",
"Reset to Defaults": "Réinitialiser par défaut",
"Provider keys, model preferences, airplane mode, and system prompt": "Clés fournisseur, préférences de modèle, mode avion et prompt système",
"Anthropic / Online API Key": "Clé API Anthropic / en ligne",
"Mistral API Key": "Clé API Mistral",
"Offline Mode": "Mode hors ligne",
"Route AI tasks through the airplane endpoint": "Acheminer les tâches IA via le point daccès du mode avion",
"Default Model": "Modèle par défaut",
"Title Model": "Modèle pour les titres",
"Image Analysis Model": "Modèle danalyse dimage",
"Offline Chat Model": "Modèle de chat hors ligne",
"Offline Title Model": "Modèle de titre hors ligne",
"Offline Image Analysis Model": "Modèle danalyse dimage hors ligne",
"System Prompt": "Prompt système",
"Reset to Default": "Réinitialiser par défaut",
"Application-level runtime behavior and semantic indexing": "Comportement dexécution applicatif et indexation sémantique",
"Semantic Similarity": "Similarité sémantique",
"Enable duplicate search and related-post embeddings": "Activer la recherche de doublons et les embeddings darticles liés",
"Scripting Runtime": "Environnement de script",
"Scripting capabilities are configured at the application layer in the rewrite and do not expose runtime switching here.": "Les capacités de script sont configurées au niveau de lapplication dans la réécriture et nexposent pas de changement denvironnement ici.",
"Deployment credentials for upload tasks": "Identifiants de déploiement pour les tâches denvoi",
"SSH Mode": "Mode SSH",
"Host": "Hôte",
"Username": "Nom dutilisateur",
"Remote Path": "Chemin distant",
"Agent configuration files for the built-in bDS MCP server": "Fichiers de configuration dagent pour le serveur MCP bDS intégré",
"Not supported in the rewrite yet": "Pas encore pris en charge dans la réécriture",
"Remove": "Retirer",
"Add": "Ajouter",
"Data Maintenance": "Maintenance des données",
"Rebuild filesystem-backed records and thumbnails": "Reconstruire les enregistrements basés sur le système de fichiers et les vignettes",
"Rebuild Posts From Files": "Reconstruire les articles depuis les fichiers",
"Rebuild Media From Files": "Reconstruire les médias depuis les fichiers",
"Rebuild Scripts From Files": "Reconstruire les scripts depuis les fichiers",
"Rebuild Templates From Files": "Reconstruire les modèles depuis les fichiers",
"Rebuild Links": "Reconstruire les liens",
"Regenerate Missing Thumbnails": "Régénérer les vignettes manquantes",
"Rebuild Embedding Index": "Reconstruire lindex dembeddings",
"Quick Actions": "Actions rapides",
"Review title, alt text, and caption suggestions": "Vérifier les suggestions de titre, texte alternatif et légende",
"Detect Language": "Détecter la langue",
"Persist the detected language for this media item": "Enregistrer la langue détectée pour ce média",
"Select a target language for this media item": "Sélectionner une langue cible pour ce média",
"Replace File": "Remplacer le fichier",
"File Name": "Nom du fichier",
"MIME Type": "Type MIME",
"Size": "Taille",
"Dimensions": "Dimensions",
"Author": "Auteur",
"Language": "Langue",
"None": "Aucune",
"No translations": "Aucune traduction",
"Refresh": "Actualiser",
"Linked Posts": "Articles liés",
"Link to Post": "Lier à un article",
"Search posts": "Rechercher des articles",
"No posts to link": "Aucun article à lier",
"and %{count} more": "et %{count} de plus",
"Not linked to any posts": "Lié à aucun article"
}

View File

@@ -249,5 +249,94 @@
"Ask the assistant about the active project or editor.": "Chiedi allassistente del progetto o editor attivo.",
"Start chat": "Avvia chat",
"You": "Tu",
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "La superficie chat della barra laterale assistente è pronta, ma lesecuzione del modello non è ancora collegata."
"The assistant sidebar chat surface is ready, but model execution is not connected yet.": "La superficie chat della barra laterale assistente è pronta, ma lesecuzione del modello non è ancora collegata.",
"Search settings": "Cerca nelle impostazioni",
"No settings match the current search": "Nessuna impostazione corrisponde alla ricerca corrente",
"Blog identity, URLs, authoring defaults, and bookmarklet setup": "Identità del blog, URL, valori di autore predefiniti e configurazione del bookmarklet",
"Project Name": "Nome progetto",
"Description": "Descrizione",
"Data Path": "Percorso dati",
"Public URL": "URL pubblica",
"Blog Languages": "Lingue del blog",
"Default Author": "Autore predefinito",
"Max Posts Per Page": "Numero massimo di post per pagina",
"Blogmark Category": "Categoria blogmark",
"Blogmark Bookmarklet": "Bookmarklet blogmark",
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "La copia del bookmarklet è collegata tramite il runtime desktop e lURL pubblica del progetto.",
"Default editing mode and diff presentation": "Modalità di modifica predefinita e presentazione dei diff",
"Default Editor Mode": "Modalità editor predefinita",
"Diff View Style": "Stile vista diff",
"Inline": "In linea",
"Side by Side": "Affiancato",
"Wrap Long Lines": "A capo per linee lunghe",
"Enable line wrapping in diffs": "Abilita il ritorno a capo nei diff",
"Hide Unchanged Regions": "Nascondi regioni invariate",
"Collapse unchanged diff hunks": "Comprimi i blocchi diff invariati",
"Content Categories": "Categorie di contenuto",
"Category defaults, rendering flags, and template wiring": "Valori predefiniti delle categorie, opzioni di rendering e collegamento dei template",
"Category": "Categoria",
"Render in Lists": "Mostra nelle liste",
"Show Titles": "Mostra titoli",
"Post Template": "Template del post",
"List Template": "Template della lista",
"Default": "Predefinito",
"Add Category": "Aggiungi categoria",
"Reset to Defaults": "Ripristina valori predefiniti",
"Provider keys, model preferences, airplane mode, and system prompt": "Chiavi provider, preferenze modello, modalità aereo e prompt di sistema",
"Anthropic / Online API Key": "Chiave API Anthropic / online",
"Mistral API Key": "Chiave API Mistral",
"Offline Mode": "Modalità offline",
"Route AI tasks through the airplane endpoint": "Instrada i task IA tramite lendpoint modalità aereo",
"Default Model": "Modello predefinito",
"Title Model": "Modello titolo",
"Image Analysis Model": "Modello analisi immagini",
"Offline Chat Model": "Modello chat offline",
"Offline Title Model": "Modello titolo offline",
"Offline Image Analysis Model": "Modello analisi immagini offline",
"System Prompt": "Prompt di sistema",
"Reset to Default": "Ripristina predefinito",
"Application-level runtime behavior and semantic indexing": "Comportamento runtime a livello applicativo e indicizzazione semantica",
"Semantic Similarity": "Somiglianza semantica",
"Enable duplicate search and related-post embeddings": "Abilita ricerca duplicati ed embeddings per post correlati",
"Scripting Runtime": "Runtime scripting",
"Scripting capabilities are configured at the application layer in the rewrite and do not expose runtime switching here.": "Le capacità di scripting sono configurate a livello applicativo nella riscrittura e non espongono qui il cambio di runtime.",
"Deployment credentials for upload tasks": "Credenziali di distribuzione per i task di upload",
"SSH Mode": "Modalità SSH",
"Host": "Host",
"Username": "Nome utente",
"Remote Path": "Percorso remoto",
"Agent configuration files for the built-in bDS MCP server": "File di configurazione degli agenti per il server MCP bDS integrato",
"Not supported in the rewrite yet": "Non ancora supportato nella riscrittura",
"Remove": "Rimuovi",
"Add": "Aggiungi",
"Data Maintenance": "Manutenzione dati",
"Rebuild filesystem-backed records and thumbnails": "Ricostruisci record basati su filesystem e miniature",
"Rebuild Posts From Files": "Ricostruisci i post dai file",
"Rebuild Media From Files": "Ricostruisci i media dai file",
"Rebuild Scripts From Files": "Ricostruisci gli script dai file",
"Rebuild Templates From Files": "Ricostruisci i template dai file",
"Rebuild Links": "Ricostruisci i link",
"Regenerate Missing Thumbnails": "Rigenera miniature mancanti",
"Rebuild Embedding Index": "Ricostruisci indice embeddings",
"Quick Actions": "Azioni rapide",
"Review title, alt text, and caption suggestions": "Rivedi i suggerimenti per titolo, testo alternativo e didascalia",
"Detect Language": "Rileva lingua",
"Persist the detected language for this media item": "Salva la lingua rilevata per questo media",
"Select a target language for this media item": "Seleziona una lingua di destinazione per questo media",
"Replace File": "Sostituisci file",
"File Name": "Nome file",
"MIME Type": "Tipo MIME",
"Size": "Dimensione",
"Dimensions": "Dimensioni",
"Author": "Autore",
"Language": "Lingua",
"None": "Nessuno",
"No translations": "Nessuna traduzione",
"Refresh": "Aggiorna",
"Linked Posts": "Post collegati",
"Link to Post": "Collega al post",
"Search posts": "Cerca post",
"No posts to link": "Nessun post da collegare",
"and %{count} more": "e altri %{count}",
"Not linked to any posts": "Non collegato ad alcun post"
}

View File

@@ -2598,18 +2598,339 @@ button svg * {
font: inherit;
}
.media-editor {
display: grid;
grid-template-columns: minmax(320px, 0.95fr) minmax(360px, 1.05fr);
gap: 20px;
padding: 20px;
align-items: start;
}
.media-editor-details-form {
[data-testid="media-editor"] {
flex: 1;
display: flex;
flex-direction: column;
gap: 14px;
background-color: var(--vscode-editor-background);
overflow: hidden;
}
[data-testid="media-editor"] .editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 12px;
min-height: 35px;
background-color: var(--vscode-tab-activeBackground);
border-bottom: 1px solid var(--vscode-panel-border);
}
[data-testid="media-editor"] .editor-tabs {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
[data-testid="media-editor"] .editor-tab {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 6px 12px;
background-color: var(--vscode-tab-inactiveBackground);
color: var(--vscode-tab-inactiveForeground);
font-size: 13px;
border-radius: 4px 4px 0 0;
}
[data-testid="media-editor"] .editor-tab.active {
background-color: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
[data-testid="media-editor"] .editor-tab-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-testid="media-editor"] .editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
font-size: 10px;
}
[data-testid="media-editor"] .editor-actions {
display: flex;
align-items: center;
gap: 8px;
}
[data-testid="media-editor"] .editor-actions button {
padding: 4px 10px;
font-size: 12px;
}
[data-testid="media-editor"] .editor-actions button.danger:hover {
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
[data-testid="media-editor"] .auto-save-indicator {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
[data-testid="media-editor"] .editor-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
overflow-y: auto;
gap: 16px;
}
[data-testid="media-editor"] > .editor-content.media-editor {
flex-direction: row;
align-items: stretch;
gap: 24px;
}
[data-testid="media-editor"] .editor-field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
[data-testid="media-editor"] .editor-field label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
[data-testid="media-editor"] .editor-field-row {
display: flex;
gap: 12px;
width: 100%;
margin-bottom: 0;
}
[data-testid="media-editor"] .post-editor-input,
[data-testid="media-editor"] .post-editor-textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
border-radius: 4px;
background: var(--vscode-input-background, rgba(255, 255, 255, 0.06));
color: var(--vscode-input-foreground, var(--vscode-foreground));
font: inherit;
}
[data-testid="media-editor"] .post-editor-input.disabled,
[data-testid="media-editor"] .post-editor-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
[data-testid="media-editor"] .post-editor-textarea {
line-height: 1.5;
resize: vertical;
}
[data-testid="media-editor"] .media-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-input-background);
border-radius: 8px;
min-height: 300px;
}
[data-testid="media-editor"] .media-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vscode-descriptionForeground);
}
[data-testid="media-editor"] .media-preview-image {
max-width: 100%;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
[data-testid="media-editor"] .media-preview-image img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
}
[data-testid="media-editor"] .media-details {
width: 320px;
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
}
[data-testid="media-editor"] .media-editor-details-form {
display: flex;
flex-direction: column;
gap: 12px;
}
[data-testid="media-editor"] .media-details textarea {
resize: vertical;
}
[data-testid="media-editor"] .linked-posts-section label {
display: flex;
justify-content: space-between;
align-items: center;
}
[data-testid="media-editor"] .add-link-btn {
background: var(--vscode-button-secondaryBackground);
border: none;
color: var(--vscode-button-secondaryForeground);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
[data-testid="media-editor"] .add-link-btn:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
[data-testid="media-editor"] .post-picker {
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 4px;
margin-top: 8px;
max-height: 250px;
overflow-y: auto;
}
[data-testid="media-editor"] .post-picker-search {
padding: 8px;
border-bottom: 1px solid var(--vscode-dropdown-border);
position: sticky;
top: 0;
background: var(--vscode-dropdown-background);
}
[data-testid="media-editor"] .post-picker-search input {
width: 100%;
padding: 6px 10px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
color: var(--vscode-input-foreground);
font-size: 12px;
}
[data-testid="media-editor"] .post-picker-search input:focus {
outline: none;
border-color: var(--vscode-focusBorder);
}
[data-testid="media-editor"] .post-picker-list {
padding: 4px;
}
[data-testid="media-editor"] .post-picker-item {
width: 100%;
padding: 6px 8px;
cursor: pointer;
border: none;
border-radius: 3px;
background: transparent;
color: inherit;
font-size: 12px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-testid="media-editor"] .post-picker-item:hover {
background: var(--vscode-list-hoverBackground);
}
[data-testid="media-editor"] .post-picker-more {
padding: 6px 8px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-style: italic;
}
[data-testid="media-editor"] .no-posts,
[data-testid="media-editor"] .no-linked-posts {
padding: 12px 8px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
font-style: italic;
}
[data-testid="media-editor"] .linked-posts-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
[data-testid="media-editor"] .linked-post-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: var(--vscode-sideBar-background);
border-radius: 4px;
}
[data-testid="media-editor"] .linked-post-title,
[data-testid="media-editor"] .linked-post-link {
flex: 1;
min-width: 0;
border: none;
background: transparent;
padding: 0;
color: inherit;
text-align: left;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-testid="media-editor"] .linked-post-title:hover,
[data-testid="media-editor"] .linked-post-link:hover {
color: var(--vscode-textLink-foreground);
text-decoration: underline;
}
[data-testid="media-editor"] .linked-post-item .unlink-btn {
background: none;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
opacity: 0;
transition: opacity 0.1s;
}
[data-testid="media-editor"] .linked-post-item:hover .unlink-btn {
opacity: 1;
}
[data-testid="media-editor"] .linked-post-item .unlink-btn:hover {
color: var(--vscode-errorForeground);
}
.translation-modal-backdrop {
@@ -3091,7 +3412,7 @@ button svg * {
}
@media (max-width: 1100px) {
.media-editor,
[data-testid="media-editor"] > .editor-content.media-editor,
.setting-row,
.tag-form-row,
.editor-field-row,
@@ -3100,6 +3421,16 @@ button svg * {
grid-template-columns: 1fr;
}
[data-testid="media-editor"] > .editor-content.media-editor,
[data-testid="media-editor"] .editor-field-row {
display: flex;
flex-direction: column;
}
[data-testid="media-editor"] .media-details {
width: 100%;
}
.style-theme-picker {
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
}
@@ -3340,171 +3671,6 @@ button svg * {
.lightbox-image-container {
max-width: 90%;
.media-editor {
display: flex;
flex-direction: column;
gap: 18px;
}
.media-editor-form {
display: grid;
grid-template-columns: minmax(240px, 0.9fr) minmax(0, 1.1fr);
gap: 20px;
align-items: start;
}
.media-preview,
.media-translations-section,
.linked-posts-section {
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
background: rgba(255, 255, 255, 0.84);
}
.media-preview {
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.media-preview-image,
.media-preview-image img {
width: 100%;
}
.media-preview-image img {
display: block;
max-height: 460px;
object-fit: contain;
border-radius: 10px;
}
.media-preview-placeholder {
min-height: 220px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #64748b;
}
.media-details {
display: flex;
flex-direction: column;
gap: 14px;
}
.media-translations-section,
.linked-posts-section {
margin-top: 2px;
padding: 14px 16px;
}
.linked-posts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.linked-post-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.linked-post-link {
border: 0;
background: transparent;
padding: 0;
color: #0f172a;
text-align: left;
cursor: pointer;
}
.unlink-btn,
.add-link-btn {
border: 0;
background: transparent;
cursor: pointer;
}
.add-link-btn {
margin-left: 10px;
color: #2563eb;
font-weight: 600;
}
.unlink-btn {
color: #b91c1c;
font-size: 1rem;
}
.post-picker {
margin-top: 10px;
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(248, 250, 252, 0.9);
}
.post-picker-search input,
.translation-inline-form .post-editor-input,
.translation-inline-form .post-editor-textarea {
width: 100%;
}
.post-picker-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.post-picker-item,
.post-picker-more,
.no-linked-posts,
.no-posts {
padding: 8px 10px;
border-radius: 8px;
}
.post-picker-item {
border: 0;
background: rgba(37, 99, 235, 0.08);
text-align: left;
cursor: pointer;
}
.post-picker-more,
.no-linked-posts,
.no-posts {
color: #64748b;
background: rgba(226, 232, 240, 0.4);
}
.translation-inline-form {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.translation-inline-actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 1100px) {
.media-editor-form {
grid-template-columns: 1fr;
}
}
max-height: 78%;
}

View File

@@ -860,6 +860,9 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(class="editor-content media-editor")
assert html =~ ~s(class="quick-actions-wrapper")
refute html =~ ~s(class="media-editor-form")
assert has_element?(view, "[data-testid='media-editor'] .editor-content.media-editor .media-details")
assert has_element?(view, "[data-testid='media-editor'] .editor-content.media-editor .media-details .media-translations-section")
assert has_element?(view, "[data-testid='media-editor'] .editor-content.media-editor .media-details .linked-posts-section")
html = render_click(view, "edit_media_translation", %{"id" => media.id, "language" => "de"})
@@ -870,6 +873,54 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(name="media_translation[caption]")
end
test "settings and media editors render localized labels when the UI language changes", %{project: project, temp_dir: temp_dir} do
source_path = Path.join(temp_dir, "localized-hero.txt")
File.write!(source_path, "media body")
assert {:ok, media} =
Media.import_media(%{
project_id: project.id,
source_path: source_path,
title: "Lokales Bild",
alt: "Alt",
caption: "Beschriftung",
language: "de"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
view
|> form("[data-testid='status-language-form']", %{ui_language: "de"})
|> render_change()
assert html =~ "Beiträge durchsuchen..."
settings_html =
render_click(view, "pin_sidebar_item", %{
"route" => "settings",
"id" => "settings-editor",
"title" => "Editor",
"subtitle" => "Editor settings"
})
assert settings_html =~ "Standard-Bearbeitungsmodus"
refute settings_html =~ "Default Editor Mode"
media_html =
render_click(view, "pin_sidebar_item", %{
"route" => "media",
"id" => media.id,
"title" => media.title,
"subtitle" => media.original_name
})
assert media_html =~ "Dateiname"
assert media_html =~ "Verknüpfte Beiträge"
refute media_html =~ "File Name"
refute media_html =~ "Linked Posts"
end
test "remaining step-5 routes render dedicated editors instead of the generic shell placeholder", %{project: project} do
assert {:ok, script} =
Scripts.create_script(%{
@@ -965,6 +1016,28 @@ defmodule BDS.Desktop.ShellLiveTest do
refute chat_html =~ "Desktop workbench content routed through the Elixir shell."
end
test "settings sidebar categories render the full old-app section model and target the requested section" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "settings",
"id" => "settings-ai",
"title" => "AI",
"subtitle" => "Assistant settings"
})
assert html =~ ~s(id="settings-section-project")
assert html =~ ~s(id="settings-section-editor")
assert html =~ ~s(id="settings-section-content")
assert html =~ ~s(id="settings-section-ai")
assert html =~ ~s(id="settings-section-technology")
assert html =~ ~s(id="settings-section-publishing")
assert html =~ ~s(id="settings-section-data")
assert html =~ ~s(id="settings-section-mcp")
assert html =~ ~s(data-selected-settings-section="ai")
end
test "template sidebar exposes old-app style delete control and removes template rows", %{project: project} do
assert {:ok, template} =
BDS.Templates.create_template(%{

View File

@@ -218,6 +218,27 @@ defmodule BDS.UI.ShellTest do
assert live_ex =~ "titlebar_menu_item_index"
end
test "desktop shell css keeps the old media editor layout contract" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
assert css =~ ".media-preview {"
assert css =~ "min-height: 300px;"
assert css =~ ".media-details {"
assert css =~ "width: 320px;"
assert css =~ ".media-details textarea {"
assert css =~ "resize: vertical;"
assert css =~ ".linked-posts-section label {"
assert css =~ "justify-content: space-between;"
assert css =~ ".add-link-btn {"
assert css =~ "font-size: 11px;"
assert css =~ ".post-picker {"
assert css =~ "max-height: 250px;"
assert css =~ ".post-picker-search input {"
assert css =~ "padding: 6px 10px;"
assert css =~ ".linked-post-item:hover .unlink-btn {"
assert css =~ "opacity: 1;"
end
test "desktop shell status task area keeps the compact running-task markup" do
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")