diff --git a/PLAN.md b/PLAN.md
index dc96ffc..f2f5bb5 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -13,7 +13,7 @@ The rewrite already implements most of the backend and compatibility-critical su
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
-- Desktop shell foundation: native menu definitions, shell HTML/CSS/JS bundle, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, project switcher, and shell command routing.
+- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, project switcher, and shell command routing.
### Implemented But Not Yet At Parity
@@ -38,7 +38,7 @@ Ordered from base contracts upward:
| Persistence and file contracts | `schema`, `frontmatter`, `project`, `post`, `translation`, `media`, `tag`, `template`, `script`, `menu`, `metadata` | Implemented | Core schemas, file formats, publish flows, sidecars, rebuild, and metadata diff are present and tested. |
| Rendering and output pipelines | `template_context`, `search`, `generation`, `preview`, `publishing`, `task`, `i18n` | Implemented | Rendering, generation, preview, publishing, task tracking, and localization are in place. |
| Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. |
-| Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and shell frame parity are in place; route bodies remain generic until the editor UX phase. |
+| Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and a LiveView-owned shell frame are in place; route bodies remain generic until the editor UX phase. |
| Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. |
| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial to missing | Route registration exists, but feature-complete editors and modal workflows are not done. |
diff --git a/config/config.exs b/config/config.exs
index 3a7be60..dfef5ee 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -20,6 +20,13 @@ config :bds, :desktop,
title: "Blogging Desktop Server",
secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
+config :bds, BDS.Desktop.Endpoint,
+ url: [host: "127.0.0.1"],
+ adapter: Bandit.PhoenixAdapter,
+ render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
+ pubsub_server: BDS.PubSub,
+ live_view: [signing_salt: "desktop-live-view"]
+
config :bds, :scripting,
runtime: BDS.Scripting.Lua,
timeout: 300_000,
diff --git a/lib/bds/application.ex b/lib/bds/application.ex
index 18aa35a..72df72b 100644
--- a/lib/bds/application.ex
+++ b/lib/bds/application.ex
@@ -26,6 +26,8 @@ defmodule BDS.Application do
@impl true
def start(_type, _args) do
children = [
+ {Phoenix.PubSub, name: BDS.PubSub},
+ {BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
BDS.Repo,
BDS.RepoBootstrap,
BDS.Tasks,
@@ -67,4 +69,9 @@ defmodule BDS.Application do
defp desktop_automation? do
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
end
+
+ defp desktop_secret_key_base do
+ Application.get_env(:bds, :desktop)[:secret_key_base] ||
+ raise "missing :desktop secret_key_base configuration"
+ end
end
diff --git a/lib/bds/desktop/endpoint.ex b/lib/bds/desktop/endpoint.ex
new file mode 100644
index 0000000..2091c50
--- /dev/null
+++ b/lib/bds/desktop/endpoint.ex
@@ -0,0 +1,42 @@
+defmodule BDS.Desktop.Endpoint do
+ @moduledoc false
+
+ use Phoenix.Endpoint, otp_app: :bds
+
+ @session_options [
+ store: :cookie,
+ key: "_bds_desktop_key",
+ signing_salt: "desktop-shell"
+ ]
+
+ socket "/live", Phoenix.LiveView.Socket,
+ websocket: [connect_info: [session: @session_options]]
+
+ plug Plug.Session, @session_options
+ plug :maybe_require_desktop_auth
+
+ plug Plug.Static,
+ at: "/assets",
+ from: {:bds, "priv/ui"},
+ only: ["app.css", "live.js"]
+
+ plug Plug.Static,
+ at: "/vendor/phoenix",
+ from: {:phoenix, "priv/static"},
+ only: ["phoenix.min.js"]
+
+ plug Plug.Static,
+ at: "/vendor/live_view",
+ from: {:phoenix_live_view, "priv/static"},
+ only: ["phoenix_live_view.min.js"]
+
+ plug BDS.Desktop.Router
+
+ defp maybe_require_desktop_auth(conn, _opts) do
+ if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
+ conn
+ else
+ Desktop.Auth.call(conn, [])
+ end
+ end
+end
diff --git a/lib/bds/desktop/error_html.ex b/lib/bds/desktop/error_html.ex
new file mode 100644
index 0000000..b2fb2e5
--- /dev/null
+++ b/lib/bds/desktop/error_html.ex
@@ -0,0 +1,9 @@
+defmodule BDS.Desktop.ErrorHTML do
+ @moduledoc false
+
+ use Phoenix.Component
+
+ def render(_template, _assigns) do
+ "not found"
+ end
+end
diff --git a/lib/bds/desktop/health_controller.ex b/lib/bds/desktop/health_controller.ex
new file mode 100644
index 0000000..b1a09e3
--- /dev/null
+++ b/lib/bds/desktop/health_controller.ex
@@ -0,0 +1,9 @@
+defmodule BDS.Desktop.HealthController do
+ @moduledoc false
+
+ use Phoenix.Controller, formats: [:html]
+
+ def show(conn, _params) do
+ text(conn, "ok")
+ end
+end
diff --git a/lib/bds/desktop/layouts.ex b/lib/bds/desktop/layouts.ex
new file mode 100644
index 0000000..967d15c
--- /dev/null
+++ b/lib/bds/desktop/layouts.ex
@@ -0,0 +1,26 @@
+defmodule BDS.Desktop.Layouts do
+ @moduledoc false
+
+ use Phoenix.Component
+
+ def root(assigns) do
+ ~H"""
+
+
+
+
+
+ <%= @page_title || "Blogging Desktop Server" %>
+
+
+
+
+ <%= @inner_content %>
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/bds/desktop/media_controller.ex b/lib/bds/desktop/media_controller.ex
new file mode 100644
index 0000000..c48536e
--- /dev/null
+++ b/lib/bds/desktop/media_controller.ex
@@ -0,0 +1,59 @@
+defmodule BDS.Desktop.MediaController do
+ @moduledoc false
+
+ use Phoenix.Controller, formats: [:html]
+
+ alias BDS.Media
+ alias BDS.Media.Media, as: MediaRecord
+ alias BDS.Projects
+ alias BDS.Repo
+
+ def thumbnail(conn, %{"media_id" => media_id} = params) do
+ case active_media_thumbnail(media_id, Map.get(params, "size")) do
+ {:ok, content_type, path} ->
+ conn
+ |> Plug.Conn.put_resp_content_type(content_type)
+ |> Plug.Conn.send_file(200, path)
+
+ :error ->
+ send_resp(conn, 404, "not found")
+ end
+ end
+
+ defp active_media_thumbnail(media_id, size) do
+ with %{} = project <- Projects.get_active_project(),
+ %MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
+ true <- media.project_id == project.id,
+ relative_path when is_binary(relative_path) <- Media.thumbnail_paths(media)[thumbnail_size(size)],
+ absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
+ true <- File.exists?(absolute_path) do
+ {:ok, thumbnail_content_type(relative_path), absolute_path}
+ else
+ _other -> :error
+ end
+ rescue
+ error in [Exqlite.Error, DBConnection.OwnershipError] ->
+ if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
+ reraise error, __STACKTRACE__
+ end
+
+ :error
+ end
+
+ defp thumbnail_size(size) do
+ case to_string(size || "small") do
+ "medium" -> :medium
+ "large" -> :large
+ "ai" -> :ai
+ _other -> :small
+ end
+ end
+
+ defp thumbnail_content_type(path) do
+ case Path.extname(path) do
+ ".jpg" -> "image/jpeg"
+ ".jpeg" -> "image/jpeg"
+ _other -> "image/webp"
+ end
+ end
+end
diff --git a/lib/bds/desktop/router.ex b/lib/bds/desktop/router.ex
index efd854e..b5d1590 100644
--- a/lib/bds/desktop/router.ex
+++ b/lib/bds/desktop/router.ex
@@ -1,109 +1,28 @@
defmodule BDS.Desktop.Router do
@moduledoc false
- use Plug.Router
+ use Phoenix.Router
- plug :put_secret_key_base
+ import Phoenix.LiveView.Router
- plug Plug.Session,
- store: :cookie,
- key: "_bds_desktop_key",
- signing_salt: "desktop-shell"
-
- plug :match
- plug :maybe_require_desktop_auth
-
- plug Plug.Static,
- at: "/assets",
- from: {:bds, "priv/ui"},
- only: ["app.css", "app.js"]
-
- plug :dispatch
-
- get "/" do
- conn
- |> Plug.Conn.put_resp_content_type("text/html")
- |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.index_html())
+ pipeline :browser do
+ plug :accepts, ["html"]
+ plug :fetch_session
+ plug :fetch_live_flash
+ plug :put_root_layout, html: {BDS.Desktop.Layouts, :root}
+ plug :protect_from_forgery
+ plug :put_secure_browser_headers
end
- get "/health" do
- Plug.Conn.send_resp(conn, 200, "ok")
- end
+ scope "/", BDS.Desktop do
+ pipe_through :browser
- get "/api/tasks" do
- conn
- |> Plug.Conn.put_resp_content_type("application/json")
- |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.task_status_json())
- end
+ get "/health", HealthController, :show
+ get "/media-thumbnail/:media_id", MediaController, :thumbnail
- get "/api/projects" do
- conn
- |> Plug.Conn.put_resp_content_type("application/json")
- |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.projects_json())
- end
-
- get "/api/media-thumbnail/:media_id" do
- BDS.Desktop.ShellController.media_thumbnail(conn, media_id, conn.params)
- end
-
- post "/api/sidebar" do
- {:ok, body, conn} = Plug.Conn.read_body(conn)
- payload = if body == "", do: %{}, else: Jason.decode!(body)
-
- conn
- |> Plug.Conn.put_resp_content_type("application/json")
- |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.sidebar_json(payload))
- end
-
- post "/api/projects" do
- {:ok, body, conn} = Plug.Conn.read_body(conn)
- payload = if body == "", do: %{}, else: Jason.decode!(body)
-
- conn
- |> Plug.Conn.put_resp_content_type("application/json")
- |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.upsert_project_json(payload))
- end
-
- post "/api/project-folder" do
- {:ok, body, conn} = Plug.Conn.read_body(conn)
- payload = if body == "", do: %{}, else: Jason.decode!(body)
-
- conn
- |> Plug.Conn.put_resp_content_type("application/json")
- |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.choose_project_folder_json(payload))
- end
-
- post "/api/commands" do
- {:ok, body, conn} = Plug.Conn.read_body(conn)
- payload = if body == "", do: %{}, else: Jason.decode!(body)
-
- conn
- |> Plug.Conn.put_resp_content_type("application/json")
- |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.command_json(payload))
- end
-
- match _ do
- Plug.Conn.send_resp(conn, 404, "not found")
- end
-
- defp put_secret_key_base(conn, _opts) do
- if conn.secret_key_base do
- conn
- else
- %{conn | secret_key_base: desktop_secret_key_base()}
- end
- end
-
- defp desktop_secret_key_base do
- Application.get_env(:bds, :desktop)[:secret_key_base] ||
- raise "missing :desktop secret_key_base configuration"
- end
-
- defp maybe_require_desktop_auth(conn, _opts) do
- if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
- conn
- else
- Desktop.Auth.call(conn, [])
+ live_session :desktop_shell,
+ root_layout: {BDS.Desktop.Layouts, :root} do
+ live "/", ShellLive, :index
end
end
end
diff --git a/lib/bds/desktop/server.ex b/lib/bds/desktop/server.ex
index 591d19f..8cef025 100644
--- a/lib/bds/desktop/server.ex
+++ b/lib/bds/desktop/server.ex
@@ -29,7 +29,7 @@ defmodule BDS.Desktop.Server do
def init(_opts) do
{:ok, bandit_pid} =
Bandit.start_link(
- plug: BDS.Desktop.Router,
+ plug: BDS.Desktop.Endpoint,
scheme: :http,
ip: {127, 0, 0, 1},
port: port(),
diff --git a/lib/bds/desktop/shell_controller.ex b/lib/bds/desktop/shell_controller.ex
deleted file mode 100644
index b0f5db7..0000000
--- a/lib/bds/desktop/shell_controller.ex
+++ /dev/null
@@ -1,249 +0,0 @@
-defmodule BDS.Desktop.ShellController do
- @moduledoc false
-
- alias BDS.Media
- alias BDS.Media.Media, as: MediaRecord
- alias BDS.Projects
- alias BDS.Repo
- alias BDS.UI.Sidebar
-
- def index_html do
- BDS.UI.ShellPage.render()
- end
-
- def task_status_json do
- Jason.encode!(BDS.Tasks.status_snapshot())
- end
-
- def projects_json do
- Jason.encode!(BDS.Projects.shell_snapshot())
- rescue
- error in [Exqlite.Error] ->
- if String.contains?(Exception.message(error), "no such table: projects") do
- Jason.encode!(default_project_snapshot())
- else
- reraise error, __STACKTRACE__
- end
- end
-
- def upsert_project_json(payload) when is_map(payload) do
- case normalize_project_request(payload) do
- {:create, attrs} -> create_project_json(attrs)
- {:select, project_id} -> select_project_json(project_id)
- :error -> Jason.encode!(%{status: "error", error: %{message: "Missing project name or project_id"}})
- end
- end
-
- def choose_project_folder_json(payload \\ %{}) when is_map(payload) do
- prompt = Map.get(payload, "prompt") || Map.get(payload, :prompt) || "Select existing blog folder"
-
- case folder_picker().choose_directory(prompt) do
- {:ok, path} -> Jason.encode!(project_folder_payload(path))
- :cancel -> Jason.encode!(%{status: "cancel"})
- {:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)})
- end
- end
-
- def command_json(payload) when is_map(payload) do
- action = Map.get(payload, "action") || Map.get(payload, :action)
- params = Map.get(payload, "params") || Map.get(payload, :params) || %{}
-
- case BDS.Desktop.ShellCommands.execute(action, params) do
- {:ok, result} -> Jason.encode!(%{status: "ok", result: result})
- {:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)})
- end
- end
-
- def media_thumbnail(conn, media_id, params \\ %{}) when is_binary(media_id) do
- case active_media_thumbnail(media_id, Map.get(params, "size") || Map.get(params, :size)) do
- {:ok, content_type, path} ->
- conn
- |> Plug.Conn.put_resp_content_type(content_type)
- |> Plug.Conn.send_file(200, path)
-
- :error ->
- Plug.Conn.send_resp(conn, 404, "not found")
- end
- end
-
- def sidebar_json(payload) when is_map(payload) do
- view = Map.get(payload, "view") || Map.get(payload, :view)
- filters = Map.get(payload, "filters") || Map.get(payload, :filters) || %{}
-
- data =
- try do
- case active_project_id() do
- nil -> Sidebar.view(nil, view, filters)
- project_id -> Sidebar.view(project_id, view, filters)
- end
- rescue
- error in [Exqlite.Error, DBConnection.OwnershipError] ->
- if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
- reraise error, __STACKTRACE__
- end
-
- Sidebar.view(nil, view, filters)
- end
-
- Jason.encode!(%{status: "ok", view: view, data: data})
- end
-
- defp normalize_error(error) when is_map(error), do: error
- defp normalize_error(error), do: %{message: inspect(error)}
-
- defp normalize_project_request(payload) do
- cond do
- present?(Map.get(payload, "name") || Map.get(payload, :name)) ->
- {:create,
- %{
- name: String.trim(Map.get(payload, "name") || Map.get(payload, :name)),
- description: blank_to_nil(Map.get(payload, "description") || Map.get(payload, :description)),
- data_path: blank_to_nil(Map.get(payload, "data_path") || Map.get(payload, :data_path))
- }}
-
- present?(Map.get(payload, "project_id") || Map.get(payload, :project_id)) ->
- {:select, Map.get(payload, "project_id") || Map.get(payload, :project_id)}
-
- true ->
- :error
- end
- end
-
- defp create_project_json(attrs) do
- with {:ok, project} <- BDS.Projects.create_project(attrs),
- {:ok, active_project} <- BDS.Projects.set_active_project(project.id) do
- Jason.encode!(%{status: "ok", project: project_response(active_project), active_project_id: active_project.id})
- else
- {:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)})
- end
- end
-
- defp select_project_json(project_id) do
- case BDS.Projects.set_active_project(project_id) do
- {:ok, project} ->
- Jason.encode!(%{status: "ok", project: project_response(project), active_project_id: project.id})
-
- {:error, :not_found} ->
- Jason.encode!(%{status: "error", error: %{message: "Project not found"}})
-
- {:error, error} ->
- Jason.encode!(%{status: "error", error: normalize_error(error)})
- end
- end
-
- defp project_response(project) do
- %{id: project.id, name: project.name, slug: project.slug, data_path: project.data_path, is_active: project.is_active}
- end
-
- defp project_folder_payload(path) do
- normalized_path = Path.expand(path)
- project_metadata = read_project_metadata(normalized_path)
- existing_project = find_project_by_data_path(normalized_path)
-
- %{
- status: "ok",
- path: normalized_path,
- name: Map.get(project_metadata, "name") || Path.basename(normalized_path),
- description: Map.get(project_metadata, "description"),
- existing_project_id: existing_project && existing_project.id
- }
- end
-
- defp read_project_metadata(path) do
- project_json_path = Path.join([path, "meta", "project.json"])
-
- case File.read(project_json_path) do
- {:ok, contents} -> Jason.decode!(contents)
- {:error, :enoent} -> %{}
- end
- end
-
- defp find_project_by_data_path(path) do
- normalized_path = Path.expand(path)
-
- BDS.Projects.list_projects()
- |> Enum.find(fn project ->
- case project.data_path do
- value when is_binary(value) -> Path.expand(value) == normalized_path
- _other -> false
- end
- end)
- end
-
- defp folder_picker do
- Application.get_env(:bds, :desktop, [])[:folder_picker] || BDS.Desktop.FolderPicker
- end
-
- defp default_project_snapshot do
- %{
- active_project_id: "default",
- projects: [
- %{
- id: "default",
- name: "My Blog",
- slug: "my-blog",
- data_path: nil,
- is_active: true
- }
- ]
- }
- end
-
- defp active_project_id do
- BDS.Projects.shell_snapshot().active_project_id
- rescue
- error in [Exqlite.Error, DBConnection.OwnershipError] ->
- if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
- reraise error, __STACKTRACE__
- end
-
- nil
- end
-
- defp present?(value) when is_binary(value), do: String.trim(value) != ""
- defp present?(_value), do: false
-
- defp active_media_thumbnail(media_id, size) do
- with %{} = project <- Projects.get_active_project(),
- %MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
- true <- media.project_id == project.id,
- relative_path when is_binary(relative_path) <- Media.thumbnail_paths(media)[thumbnail_size(size)],
- absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
- true <- File.exists?(absolute_path) do
- {:ok, thumbnail_content_type(relative_path), absolute_path}
- else
- _other -> :error
- end
- rescue
- error in [Exqlite.Error, DBConnection.OwnershipError] ->
- if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
- reraise error, __STACKTRACE__
- end
-
- :error
- end
-
- defp thumbnail_size(size) do
- case to_string(size || "small") do
- "medium" -> :medium
- "large" -> :large
- "ai" -> :ai
- _other -> :small
- end
- end
-
- defp thumbnail_content_type(path) do
- case Path.extname(path) do
- ".jpg" -> "image/jpeg"
- ".jpeg" -> "image/jpeg"
- _other -> "image/webp"
- end
- end
-
- defp blank_to_nil(value) when is_binary(value) do
- trimmed = String.trim(value)
- if trimmed == "", do: nil, else: trimmed
- end
-
- defp blank_to_nil(_value), do: nil
-end
diff --git a/lib/bds/desktop/shell_data.ex b/lib/bds/desktop/shell_data.ex
new file mode 100644
index 0000000..d10afff
--- /dev/null
+++ b/lib/bds/desktop/shell_data.ex
@@ -0,0 +1,246 @@
+defmodule BDS.Desktop.ShellData do
+ @moduledoc false
+
+ alias BDS.I18n
+ alias BDS.Projects
+ alias BDS.UI.Dashboard
+ alias BDS.UI.Sidebar
+ alias BDS.UI.Workbench
+
+ def title do
+ Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server"
+ end
+
+ def ui_language do
+ I18n.current_ui_locale()
+ end
+
+ def translations do
+ I18n.get_ui_translations(ui_language())
+ end
+
+ def supported_ui_languages do
+ Enum.map(I18n.supported_languages(), fn language ->
+ %{code: language.code, flag: I18n.flag(language.code)}
+ end)
+ end
+
+ def translate(key, bindings \\ %{}) do
+ text = Map.get(translations(), to_string(key), to_string(key))
+
+ Enum.reduce(bindings, text, fn {binding, value}, acc ->
+ String.replace(acc, "%{#{binding}}", to_string(value))
+ end)
+ end
+
+ def project_snapshot do
+ Projects.shell_snapshot()
+ rescue
+ error in [Exqlite.Error, DBConnection.OwnershipError] ->
+ if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table: projects") do
+ reraise error, __STACKTRACE__
+ end
+
+ default_project_snapshot()
+ end
+
+ def current_project(projects_snapshot) do
+ Enum.find(projects_snapshot.projects, &(&1.id == projects_snapshot.active_project_id)) ||
+ List.first(projects_snapshot.projects)
+ end
+
+ def dashboard(project_id) do
+ Dashboard.snapshot(project_id)
+ rescue
+ error in [Exqlite.Error, DBConnection.OwnershipError] ->
+ if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
+ reraise error, __STACKTRACE__
+ end
+
+ Dashboard.empty_snapshot()
+ end
+
+ def sidebar_view(project_id, view_id) do
+ Sidebar.view(project_id, view_id, %{})
+ rescue
+ error in [Exqlite.Error, DBConnection.OwnershipError] ->
+ if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
+ reraise error, __STACKTRACE__
+ end
+
+ Sidebar.view(nil, view_id, %{})
+ end
+
+ def assistant_cards do
+ [
+ %{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
+ %{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."},
+ %{label: "Desktop Runtime", text: "The app window is now served from LiveView state."}
+ ]
+ end
+
+ def editor_meta(task_status) do
+ [
+ %{label: "Status", value: task_status.running_task_message || "Idle"},
+ %{label: "Mode", value: "Offline"},
+ %{label: "Main Language", value: ui_language()}
+ ]
+ end
+
+ def status_bar(workbench, task_status, dashboard) do
+ Workbench.status_bar(workbench,
+ post_count: dashboard.post_stats.total_posts,
+ media_count: dashboard.media_stats.media_count,
+ theme_badge: "desktop-shell",
+ ui_language: ui_language(),
+ offline_mode: true,
+ running_task_message: task_status.running_task_message,
+ running_task_overflow: task_status.running_task_overflow,
+ active_post_status: nil
+ )
+ end
+
+ def panel_tabs(workbench) do
+ [:tasks, :output, :git_log, workbench.panel.active_tab]
+ |> Enum.uniq()
+ end
+
+ def activity_icon(id) do
+ case to_string(id) do
+ "posts" -> ~s( )
+ "pages" -> ~s( )
+ "media" -> ~s( )
+ "scripts" -> ~s( )
+ "templates" -> ~s( )
+ "tags" -> ~s( )
+ "chat" -> ~s( )
+ "import" -> ~s( )
+ "git" -> ~s( )
+ "settings" -> ~s( )
+ _other -> activity_icon("posts")
+ end
+ end
+
+ def dashboard_status_label(status) do
+ case to_string(status) do
+ "draft" -> translate("dashboard.status.draft")
+ "published" -> translate("dashboard.status.published")
+ "archived" -> translate("dashboard.status.archived")
+ other -> other |> String.replace("_", " ") |> String.capitalize()
+ end
+ end
+
+ def dashboard_post_count_label(count) do
+ normalized_count = count || 0
+ key = if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
+ translate(key, %{count: normalized_count})
+ end
+
+ def dashboard_tag_cloud_items(items) when is_list(items) do
+ top_items =
+ items
+ |> Enum.sort_by(fn item -> -(item.count || 0) end)
+ |> Enum.take(40)
+
+ counts = Enum.map(top_items, &(&1.count || 0))
+ max_count = Enum.max([1 | counts])
+ min_count = Enum.min([max_count | counts])
+ range = max(max_count - min_count, 1)
+
+ top_items
+ |> Enum.map(fn item ->
+ font_size = 11 + (((item.count || 0) - min_count) / range) * 11
+ Map.merge(item, %{font_size: font_size, color: normalize_dashboard_tag_color(item.color)})
+ end)
+ |> Enum.sort_by(&String.downcase(to_string(&1.tag || "")))
+ end
+
+ def render_dashboard_tag_style(item) do
+ declarations = ["font-size: #{Float.round(item.font_size || 11, 1)}px;"]
+
+ declarations =
+ if item.color do
+ declarations ++ [
+ "background-color: #{item.color};",
+ "color: #{dashboard_contrast_color(item.color)};"
+ ]
+ else
+ declarations
+ end
+
+ Enum.join(declarations, " ")
+ end
+
+ def format_dashboard_month(year, month) do
+ {:ok, date} = Date.new(year, month, 1)
+ Calendar.strftime(date, "%b")
+ end
+
+ def format_dashboard_date(nil), do: ""
+
+ def format_dashboard_date(timestamp) do
+ timestamp
+ |> DateTime.from_unix!(:millisecond)
+ |> Calendar.strftime("%x")
+ end
+
+ def route_label(route) do
+ case to_string(route) do
+ "git_log" -> "Git Log"
+ "post_links" -> "Post Links"
+ other -> other |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1)
+ end
+ end
+
+ def format_bytes(bytes) do
+ normalized_bytes = max(bytes || 0, 0)
+
+ cond do
+ normalized_bytes == 0 ->
+ "0 B"
+
+ true ->
+ units = ["B", "KB", "MB", "GB"]
+ unit_index = min(trunc(:math.log(normalized_bytes) / :math.log(1024)), length(units) - 1)
+ value = normalized_bytes / :math.pow(1024, unit_index)
+ decimals = if value >= 10 or unit_index == 0, do: 0, else: 1
+ unit = Enum.at(units, unit_index)
+ :erlang.float_to_binary(value, decimals: decimals) <> " " <> unit
+ end
+ end
+
+ defp default_project_snapshot do
+ %{
+ active_project_id: "default",
+ projects: [
+ %{
+ id: "default",
+ name: "My Blog",
+ slug: "my-blog",
+ data_path: nil,
+ is_active: true
+ }
+ ]
+ }
+ end
+
+ defp normalize_dashboard_tag_color(nil), do: nil
+ defp normalize_dashboard_tag_color(""), do: nil
+
+ defp normalize_dashboard_tag_color("#" <> rest = color) when byte_size(rest) == 6 do
+ if String.match?(rest, ~r/\A[0-9a-fA-F]{6}\z/), do: color, else: nil
+ end
+
+ defp normalize_dashboard_tag_color(_color), do: nil
+
+ defp dashboard_contrast_color("#" <> rgb) do
+ <> = rgb
+ {red, _} = Integer.parse(r, 16)
+ {green, _} = Integer.parse(g, 16)
+ {blue, _} = Integer.parse(b, 16)
+ luminance = (red * 299 + green * 587 + blue * 114) / 1000
+ if luminance > 150, do: "#1e1e1e", else: "#ffffff"
+ end
+
+ defp dashboard_contrast_color(_color), do: "#ffffff"
+end
diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex
new file mode 100644
index 0000000..93dcee8
--- /dev/null
+++ b/lib/bds/desktop/shell_live.ex
@@ -0,0 +1,630 @@
+defmodule BDS.Desktop.ShellLive do
+ @moduledoc false
+
+ use Phoenix.LiveView
+
+ import Phoenix.HTML
+
+ alias BDS.Desktop.ShellData
+ alias BDS.UI.Workbench
+
+ @refresh_interval 1_500
+
+ @impl true
+ def mount(_params, _session, socket) do
+ if connected?(socket) do
+ :timer.send_interval(@refresh_interval, :refresh_task_status)
+ end
+
+ workbench = Workbench.new()
+
+ {:ok,
+ socket
+ |> assign(:page_title, ShellData.title())
+ |> assign(:page_language, ShellData.ui_language())
+ |> reload_shell(workbench)}
+ end
+
+ @impl true
+ def handle_event("toggle_sidebar", _params, socket) do
+ {:noreply, reload_shell(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
+ end
+
+ def handle_event("toggle_panel", _params, socket) do
+ {:noreply, reload_shell(socket, Workbench.toggle_panel(socket.assigns.workbench))}
+ end
+
+ def handle_event("toggle_assistant_sidebar", _params, socket) do
+ {:noreply, reload_shell(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
+ end
+
+ def handle_event("select_view", %{"view" => view_id}, socket) do
+ workbench = Workbench.click_activity(socket.assigns.workbench, String.to_existing_atom(view_id))
+ {:noreply, reload_shell(socket, workbench)}
+ end
+
+ def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
+ workbench =
+ socket.assigns.workbench
+ |> Workbench.set_panel_visible(true)
+ |> Workbench.set_panel_tab(String.to_existing_atom(tab))
+
+ {:noreply, reload_shell(socket, workbench)}
+ end
+
+ @impl true
+ def handle_info(:refresh_task_status, socket) do
+ task_status = BDS.Tasks.status_snapshot()
+
+ {:noreply,
+ socket
+ |> assign(:task_status, task_status)
+ |> assign(:editor_meta, ShellData.editor_meta(task_status))
+ |> assign(:status, ShellData.status_bar(socket.assigns.workbench, task_status, socket.assigns.dashboard))}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
<%= @page_title %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
+
+ <%= raw(ShellData.activity_icon(button.id)) %>
+
+ <% end %>
+
+
+ <%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
+
+ <%= raw(ShellData.activity_icon(button.id)) %>
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
<%= translated("dashboard.title") %>
+
<%= translated("dashboard.subtitle") %>
+
+
+
+
<%= @dashboard.post_stats.total_posts || 0 %>
+
<%= translated("dashboard.stats.totalPosts") %>
+
+ <%= translated("dashboard.stats.published", %{count: @dashboard.post_stats.published_count || 0}) %>
+ <%= translated("dashboard.stats.drafts", %{count: @dashboard.post_stats.draft_count || 0}) %>
+ <%= if (@dashboard.post_stats.archived_count || 0) > 0 do %>
+ <%= translated("dashboard.stats.archived", %{count: @dashboard.post_stats.archived_count || 0}) %>
+ <% end %>
+
+
+
+
<%= @dashboard.media_stats.media_count || 0 %>
+
<%= translated("dashboard.stats.mediaFiles") %>
+
+ <%= translated("dashboard.stats.images", %{count: @dashboard.media_stats.image_count || 0}) %>
+ <%= ShellData.format_bytes(@dashboard.media_stats.total_bytes || 0) %>
+
+
+
+
<%= length(@dashboard.tag_cloud_items || []) %>
+
<%= translated("dashboard.stats.tags") %>
+
+ <%= translated("dashboard.stats.categories", %{count: length(@dashboard.category_counts || [])}) %>
+
+
+
+
+ <%= if Enum.any?(@dashboard.timeline_entries || []) do %>
+
+
<%= translated("dashboard.section.postsOverTime") %>
+
+ <%= for entry <- @dashboard.timeline_entries || [] do %>
+
+
+ <%= entry.count || 0 %>
+
+
+ <%= ShellData.format_dashboard_month(entry.year, entry.month) %>
+ <%= entry.year %>
+
+
+ <% end %>
+
+
+ <% end %>
+
+ <%= if Enum.any?(@dashboard_tag_cloud_items) do %>
+
+
<%= translated("dashboard.section.tags") %>
+
+ <%= for item <- @dashboard_tag_cloud_items do %>
+ <%= item.tag %>
+ <% end %>
+ <%= if length(@dashboard.tag_cloud_items || []) > 40 do %>
+ <%= translated("dashboard.tagCloud.more", %{count: length(@dashboard.tag_cloud_items) - 40}) %>
+ <% end %>
+
+
+ <% end %>
+
+ <%= if Enum.any?(@dashboard.category_counts || []) do %>
+
+
<%= translated("dashboard.section.categories") %>
+
+ <%= for category <- @dashboard.category_counts || [] do %>
+
+ <%= category.category || "" %>
+ <%= category.count || 0 %>
+
+ <% end %>
+
+
+ <% end %>
+
+ <%= if Enum.any?(@dashboard.recent_posts || []) do %>
+
+
<%= translated("dashboard.section.recentlyUpdated") %>
+
+ <%= for post <- @dashboard.recent_posts || [] do %>
+
+ <%= post.title || "" %>
+ <%= ShellData.dashboard_status_label(post.status || "draft") %>
+ <%= ShellData.format_dashboard_date(post.updated_at) %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= for item <- @editor_meta do %>
+
+ <% end %>
+
+
+
+
+
+
+
+
+ <%= render_panel_body(assigns) %>
+
+
+
+
+
+
+
+
+
+ """
+ end
+
+ defp reload_shell(socket, workbench) do
+ projects = ShellData.project_snapshot()
+ dashboard = ShellData.dashboard(projects.active_project_id)
+ sidebar_data = ShellData.sidebar_view(projects.active_project_id, Atom.to_string(workbench.active_view))
+ task_status = BDS.Tasks.status_snapshot()
+ activity_buttons = Workbench.activity_buttons(workbench, 0)
+
+ socket
+ |> assign(:workbench, workbench)
+ |> assign(:projects, projects)
+ |> assign(:current_project, ShellData.current_project(projects))
+ |> assign(:dashboard, dashboard)
+ |> assign(:dashboard_tag_cloud_items, ShellData.dashboard_tag_cloud_items(dashboard.tag_cloud_items || []))
+ |> assign(:sidebar_data, sidebar_data)
+ |> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
+ |> assign(:assistant_cards, ShellData.assistant_cards())
+ |> assign(:editor_meta, ShellData.editor_meta(task_status))
+ |> assign(:task_status, task_status)
+ |> assign(:status, ShellData.status_bar(workbench, task_status, dashboard))
+ |> assign(:activity_buttons, activity_buttons)
+ |> assign(:panel_tabs, ShellData.panel_tabs(workbench))
+ |> assign(:supported_ui_languages, ShellData.supported_ui_languages())
+ end
+
+ defp render_sidebar_body(assigns) do
+ case assigns.sidebar_data.layout do
+ "post_list" -> render_post_sidebar(assigns)
+ "media_grid" -> render_media_sidebar(assigns)
+ "entity_list" -> render_entity_sidebar(assigns)
+ "nav_list" -> render_nav_sidebar(assigns)
+ _other -> render_default_sidebar(assigns)
+ end
+ end
+
+ defp render_post_sidebar(assigns) do
+ ~H"""
+ <%= for section <- @sidebar_data.sections || [] do %>
+
+ <% end %>
+ <%= if Enum.empty?(@sidebar_data.sections || []) do %>
+
+ <% end %>
+ """
+ end
+
+ defp render_media_sidebar(assigns) do
+ ~H"""
+ <%= if Enum.any?(@sidebar_data.items || []) do %>
+
+ <% else %>
+
+ <% end %>
+ """
+ end
+
+ defp render_entity_sidebar(assigns) do
+ ~H"""
+ <%= if Enum.any?(@sidebar_data.items || []) do %>
+
+ <%= for item <- @sidebar_data.items || [] do %>
+
+
+ <%= item.title || "" %>
+ <%= translated(item.meta || "") %>
+
+
+ <% end %>
+
+ <% else %>
+
+ <% end %>
+ """
+ end
+
+ defp render_nav_sidebar(assigns) do
+ ~H"""
+
+ <%= for item <- @sidebar_data.items || [] do %>
+
+ <%= item.icon || "" %>
+ <%= translated(item.title || "") %>
+
+ <% end %>
+
+ """
+ end
+
+ defp render_default_sidebar(assigns) do
+ ~H"""
+ <%= for section <- @sidebar_data.sections || [] do %>
+
+ <% end %>
+ """
+ end
+
+ defp render_panel_body(assigns) do
+ case assigns.workbench.panel.active_tab do
+ :tasks -> render_task_entries(assigns)
+ :output -> render_output_entries(assigns)
+ :git_log -> render_git_log(assigns)
+ other -> render_generic_panel(assigns, other)
+ end
+ end
+
+ defp render_task_entries(assigns) do
+ ~H"""
+ <%= if Enum.empty?(@task_status.tasks || []) do %>
+
+ <%= translated("Tasks") %>
+ <%= translated("No background tasks running") %>
+
+ <% else %>
+
+ <%= for task <- @task_status.tasks || [] do %>
+
+
+ <%= task.message || task.group_name || "" %>
+
+ <% end %>
+
+ <% end %>
+ """
+ end
+
+ defp render_output_entries(assigns) do
+ ~H"""
+
+ <%= translated("Output") %>
+ <%= translated("No shell output yet") %>
+
+ """
+ end
+
+ defp render_git_log(assigns) do
+ ~H"""
+
+
+ <%= translated("Git Log") %>
+ <%= translated("Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.") %>
+
+
+ """
+ end
+
+ defp render_generic_panel(assigns, tab) do
+ assigns = assign(assigns, :panel_label, ShellData.route_label(tab))
+
+ ~H"""
+
+ <%= @panel_label %>
+ <%= translated("The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.") %>
+
+ """
+ end
+
+ defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings)
+
+ defp panel_tab_label(:tasks), do: translated("Tasks")
+ defp panel_tab_label(:output), do: translated("Output")
+ defp panel_tab_label(:git_log), do: translated("Git Log")
+ defp panel_tab_label(tab), do: ShellData.route_label(tab)
+
+ defp activity_label("AI Assistant"), do: "Chat"
+ defp activity_label("Source Control"), do: "Git"
+ defp activity_label(label), do: translated(label)
+
+ defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
+ Enum.find_value(activity_buttons, translated(sidebar_data.title || ""), fn button ->
+ if button.id == active_view, do: activity_label(button.label), else: nil
+ end)
+ end
+
+ defp sidebar_header_label(label), do: translated(label)
+
+ defp timeline_height(entry, entries) do
+ max_count =
+ entries
+ |> Enum.map(&(&1.count || 0))
+ |> Enum.max(fn -> 1 end)
+
+ max(4, ((entry.count || 0) / max_count) * 100)
+ end
+
+ defp format_sidebar_timestamp(nil), do: ""
+
+ defp format_sidebar_timestamp(timestamp) do
+ timestamp
+ |> DateTime.from_unix!(:millisecond)
+ |> Calendar.strftime("%x")
+ end
+
+ defp image_media?(item), do: String.starts_with?(to_string(item.mime_type || ""), "image/")
+
+ defp media_thumbnail_class(item) do
+ if image_media?(item), do: "media-thumbnail has-image", else: "media-thumbnail"
+ end
+
+ defp media_thumbnail_glyph(mime_type) do
+ case String.split(to_string(mime_type || ""), "/", parts: 2) do
+ ["image", _rest] -> "IMG"
+ ["video", _rest] -> "VID"
+ ["audio", _rest] -> "AUD"
+ ["application", _rest] -> "DOC"
+ _other -> "FILE"
+ end
+ end
+end
diff --git a/lib/bds/ui/shell_page.ex b/lib/bds/ui/shell_page.ex
deleted file mode 100644
index dfae62b..0000000
--- a/lib/bds/ui/shell_page.ex
+++ /dev/null
@@ -1,219 +0,0 @@
-defmodule BDS.UI.ShellPage do
- @moduledoc false
-
- alias BDS.I18n
- alias BDS.Projects
- alias BDS.UI.Dashboard
- alias BDS.UI.MenuBar
- alias BDS.UI.Registry
- alias BDS.UI.Sidebar
- alias BDS.UI.Session
- alias BDS.UI.Workbench
-
- def render do
- bootstrap = bootstrap()
- ui_language = get_in(bootstrap, [:i18n, :ui_language]) || "en"
-
- [
- "",
- "",
- "",
- " ",
- " ",
- " Blogging Desktop Server ",
- " ",
- "",
- "",
- " ",
- "
",
- "
",
- "
",
- " ",
- "
",
- "
",
- " ",
- " ",
- " ",
- " ",
- "
",
- "
",
- "
",
- " ",
- " ",
- "",
- ""
- ]
- |> Enum.join("\n")
- end
-
- defp bootstrap do
- workbench = Workbench.new()
- task_status = BDS.Tasks.status_snapshot()
- ui_language = I18n.current_ui_locale()
- projects = project_snapshot()
- dashboard = dashboard_content(projects.active_project_id)
-
- %{
- title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
- i18n: %{
- ui_language: ui_language,
- catalogs:
- Enum.into(I18n.supported_languages(), %{}, fn language ->
- {language.code, I18n.get_ui_translations(language.code)}
- end),
- supported_ui_languages:
- Enum.map(I18n.supported_languages(), fn language ->
- %{
- code: language.code,
- flag: I18n.flag(language.code)
- }
- end)
- },
- registry: %{
- sidebar_views: Enum.map(Registry.sidebar_views(), &encode_sidebar_view/1),
- editor_routes: Enum.map(Registry.editor_routes(), &encode_editor_route/1),
- default_sidebar_view: Atom.to_string(Registry.default_sidebar_view())
- },
- menu_groups: Enum.map(MenuBar.default_groups(), &encode_menu_group/1),
- projects: projects,
- session: Session.serialize(workbench),
- task_status: task_status,
- content: %{
- sidebar: sidebar_content(projects.active_project_id),
- dashboard: dashboard,
- assistant_cards: assistant_cards(),
- editor_meta: editor_meta(task_status)
- },
- status:
- Workbench.status_bar(workbench,
- post_count: dashboard.post_stats.total_posts,
- media_count: dashboard.media_stats.media_count,
- theme_badge: "desktop-shell",
- ui_language: ui_language,
- offline_mode: true,
- running_task_message: task_status.running_task_message,
- running_task_overflow: task_status.running_task_overflow,
- git_badge_count: 3
- )
- }
- end
-
- defp encode_sidebar_view(view) do
- %{
- id: Atom.to_string(view.id),
- label: normalize_view_label(view.id, view.label),
- activity_group: Atom.to_string(view.activity_group),
- editor_route: Atom.to_string(view.editor_route),
- entity_tab: Map.get(view, :entity_tab, false),
- singleton: Map.get(view, :singleton, false)
- }
- end
-
- defp project_snapshot do
- Projects.shell_snapshot()
- rescue
- error in [Exqlite.Error, DBConnection.OwnershipError] ->
- if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table: projects") do
- reraise error, __STACKTRACE__
- end
-
- default_project_snapshot()
- end
-
- defp default_project_snapshot do
- %{
- active_project_id: "default",
- projects: [
- %{
- id: "default",
- name: "My Blog",
- slug: "my-blog",
- data_path: nil,
- is_active: true
- }
- ]
- }
- end
-
- defp encode_editor_route(route) do
- %{
- id: Atom.to_string(route.id),
- singleton: route.singleton,
- entity_tab: route.entity_tab,
- title: route.title
- }
- end
-
- defp encode_menu_group(group) do
- %{
- id: Atom.to_string(group.id),
- label: humanize(group.id),
- items:
- group.items
- |> Enum.reject(&Map.get(&1, :separator, false))
- |> Enum.map(fn item ->
- %{id: Atom.to_string(item.id), label: humanize(item.id)}
- end)
- }
- end
-
- defp sidebar_content(project_id) do
- Sidebar.snapshot(project_id)
- rescue
- error in [Exqlite.Error, DBConnection.OwnershipError] ->
- if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
- reraise error, __STACKTRACE__
- end
-
- Sidebar.empty_snapshot()
- end
-
- defp dashboard_content(project_id) do
- Dashboard.snapshot(project_id)
- rescue
- error in [Exqlite.Error, DBConnection.OwnershipError] ->
- if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
- reraise error, __STACKTRACE__
- end
-
- Dashboard.empty_snapshot()
- end
-
- defp assistant_cards do
- [
- %{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
- %{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."},
- %{label: "Desktop Runtime", text: "The app window is now served from the Elixir shell renderer."}
- ]
- end
-
- defp editor_meta(task_status) do
- %{
- dashboard: [
- %{label: "Status", value: task_status.running_task_message || "Idle"},
- %{label: "Mode", value: "Offline"},
- %{label: "Main Language", value: "en"}
- ]
- }
- end
-
- defp normalize_view_label(:chat, _label), do: "Chat"
- defp normalize_view_label(:git, _label), do: "Git"
- defp normalize_view_label(_id, label), do: label
-
- defp humanize(value) when is_atom(value), do: value |> Atom.to_string() |> humanize()
-
- defp humanize(value) when is_binary(value) do
- value
- |> String.replace("_", " ")
- |> String.split(" ")
- |> Enum.map(&String.capitalize/1)
- |> Enum.join(" ")
- end
-end
diff --git a/mix.exs b/mix.exs
index 16f1dba..c1dff17 100644
--- a/mix.exs
+++ b/mix.exs
@@ -35,6 +35,7 @@ defmodule BDS.MixProject do
{:desktop, "~> 1.5"},
{:image, "~> 0.65"},
{:stemex, "~> 0.2.1"},
+ {:lazy_html, ">= 0.1.0", only: :test},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
]
end
diff --git a/mix.lock b/mix.lock
index 6c534c8..2a2ded1 100644
--- a/mix.lock
+++ b/mix.lock
@@ -20,6 +20,7 @@
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
+ "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
@@ -27,6 +28,7 @@
"image": {:hex, :image, "0.65.0", "44908233a1a0dcdbb6ae873ec09fd9ae533d1840d300d8b0b1b186d586b935e6", [:mix], [{:color, "~> 0.4", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "0.11.0", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.11.0", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "d2060e08d0f42564f49de1ea97a82a5d237f9ac91edb141dece51f1238dd8b4a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"},
+ "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"},
"luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
diff --git a/priv/ui/app.css b/priv/ui/app.css
index 9dabe3b..3b3f6e8 100644
--- a/priv/ui/app.css
+++ b/priv/ui/app.css
@@ -1039,6 +1039,701 @@ button {
padding: 14px 14px 0;
}
+.overlay-root {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 10000;
+}
+
+.overlay-root:empty {
+ display: none;
+}
+
+.editor-shared-actions {
+ position: relative;
+ margin-bottom: 14px;
+}
+
+.ai-suggestions-modal-backdrop,
+.insert-modal-backdrop,
+.language-picker-modal-backdrop,
+.confirm-delete-modal-backdrop,
+.confirm-dialog-overlay,
+.gallery-overlay,
+.lightbox-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.68);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+}
+
+.ai-suggestions-modal,
+.insert-modal,
+.language-picker-modal,
+.confirm-delete-modal,
+.confirm-dialog,
+.gallery-overlay-content {
+ background: #1e1e1e;
+ border: 1px solid #3c3c3c;
+ border-radius: 8px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+}
+
+.ai-suggestions-modal,
+.language-picker-modal,
+.confirm-delete-modal,
+.confirm-dialog {
+ width: min(680px, calc(100vw - 32px));
+ max-height: calc(100vh - 48px);
+ display: flex;
+ flex-direction: column;
+}
+
+.insert-modal {
+ width: min(680px, calc(100vw - 32px));
+ max-height: calc(100vh - 48px);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.gallery-overlay-content {
+ width: min(980px, calc(100vw - 48px));
+ max-height: calc(100vh - 48px);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.ai-suggestions-modal-header,
+.language-picker-modal-header,
+.confirm-delete-modal-header,
+.insert-modal-header,
+.gallery-overlay-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px 20px;
+ border-bottom: 1px solid #3c3c3c;
+}
+
+.insert-modal-header {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 12px;
+}
+
+.insert-modal-header.media-header-only {
+ flex-direction: row;
+ align-items: center;
+}
+
+.ai-suggestions-modal-header h2,
+.language-picker-modal-header h2,
+.confirm-delete-modal-header h2,
+.gallery-overlay-header h2,
+.insert-modal-title,
+.confirm-dialog h3 {
+ margin: 0;
+ color: #ffffff;
+}
+
+.ai-suggestions-modal-close,
+.confirm-delete-modal-close,
+.gallery-overlay-close,
+.shared-popover-close,
+.lightbox-close {
+ border: none;
+ background: transparent;
+ color: #c5c5c5;
+ cursor: pointer;
+ font-size: 20px;
+ line-height: 1;
+}
+
+.ai-suggestions-modal-body,
+.language-picker-modal-body,
+.confirm-delete-modal-body {
+ padding: 20px;
+ overflow: auto;
+}
+
+.ai-suggestions-list {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.ai-suggestion-item {
+ display: flex;
+ gap: 12px;
+ padding: 16px;
+ border: 1px solid #3c3c3c;
+ border-radius: 6px;
+ background: #252526;
+}
+
+.ai-suggestion-checkbox {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ cursor: pointer;
+}
+
+.ai-suggestion-checkbox input {
+ position: absolute;
+ opacity: 0;
+}
+
+.checkmark {
+ width: 20px;
+ height: 20px;
+ border: 2px solid #555555;
+ border-radius: 4px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: #1e1e1e;
+}
+
+.ai-suggestion-checkbox input:checked + .checkmark,
+.ai-suggestion-checkbox input:checked ~ .checkmark {
+ background: #0078d4;
+ border-color: #0078d4;
+}
+
+.ai-suggestion-checkbox input:checked + .checkmark::after,
+.ai-suggestion-checkbox input:checked ~ .checkmark::after {
+ content: "✓";
+ color: #ffffff;
+ font-size: 12px;
+}
+
+.ai-suggestion-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.ai-suggestion-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+ font-weight: 600;
+}
+
+.ai-suggestion-has-value,
+.language-picker-badge,
+.insert-modal-similarity-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 6px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ color: #c5c5c5;
+ font-size: 11px;
+}
+
+.ai-suggestion-comparison {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ gap: 12px;
+ align-items: center;
+}
+
+.ai-suggestion-column {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 10px 12px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.ai-suggestion-column.muted {
+ color: #9d9d9d;
+}
+
+.ai-suggestion-column.highlighted {
+ border: 1px solid rgba(0, 122, 204, 0.4);
+ color: #ffffff;
+}
+
+.ai-suggestion-column-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.ai-suggestion-arrow {
+ color: #9d9d9d;
+}
+
+.ai-suggestions-modal-footer,
+.confirm-delete-modal-footer,
+.confirm-dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding: 16px 20px;
+ border-top: 1px solid #3c3c3c;
+}
+
+.button-cancel,
+.button-delete,
+.button-apply,
+.confirm-dialog-actions button,
+.insert-modal-submit,
+.language-picker-row,
+.shared-popover-entry,
+.colour-swatch {
+ cursor: pointer;
+}
+
+.button-cancel,
+.confirm-dialog-actions button,
+.insert-modal-submit {
+ border: 1px solid #4c4c4c;
+ border-radius: 4px;
+ padding: 8px 14px;
+ background: transparent;
+ color: #f0f0f0;
+}
+
+.button-apply,
+.confirm-dialog-actions .primary,
+.insert-modal-submit {
+ background: #0e639c;
+ border-color: #0e639c;
+}
+
+.button-delete {
+ border: none;
+ border-radius: 4px;
+ padding: 8px 14px;
+ background: #c73c3c;
+ color: #ffffff;
+}
+
+.insert-modal-tabs {
+ display: flex;
+ margin: 0 -20px;
+}
+
+.insert-modal-tab {
+ flex: 1;
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: transparent;
+ color: #9d9d9d;
+ padding: 10px 16px;
+}
+
+.insert-modal-tab.active {
+ color: #ffffff;
+ border-bottom-color: #0e639c;
+ background: #252526;
+}
+
+.insert-modal-search {
+ border-bottom: 1px solid #3c3c3c;
+}
+
+.insert-modal-input,
+.shared-popover-input {
+ width: 100%;
+ border: none;
+ background: transparent;
+ color: #f0f0f0;
+ padding: 14px 20px;
+ font: inherit;
+}
+
+.insert-modal-results,
+.insert-media-grid,
+.shared-popover-list,
+.language-picker-list {
+ overflow: auto;
+}
+
+.insert-modal-results {
+ padding: 8px;
+}
+
+.insert-modal-result-item,
+.insert-modal-result-create,
+.language-picker-row,
+.shared-popover-entry {
+ width: 100%;
+ border: none;
+ border-radius: 4px;
+ padding: 12px 16px;
+ background: transparent;
+ color: inherit;
+ text-align: left;
+}
+
+.insert-modal-result-item:hover,
+.insert-modal-result-create:hover,
+.language-picker-row:hover,
+.shared-popover-entry:hover,
+.insert-media-card:hover,
+.gallery-overlay-item:hover,
+.colour-swatch:hover {
+ background: #2a2a2a;
+}
+
+.insert-modal-result-title,
+.insert-media-card-title {
+ font-weight: 600;
+ color: #ffffff;
+}
+
+.insert-modal-result-meta,
+.insert-media-card-meta,
+.warning-note,
+.shared-popover-footnote,
+.language-picker-source {
+ color: #9d9d9d;
+ font-size: 12px;
+}
+
+.insert-modal-external {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 18px 20px;
+}
+
+.insert-modal-field,
+.shared-popover-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.insert-modal-label,
+.shared-popover-field span {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #9d9d9d;
+}
+
+.insert-modal-footer {
+ border-top: 1px solid #3c3c3c;
+ padding: 12px 16px;
+}
+
+.insert-modal-footer-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.insert-modal-hint {
+ font-size: 11px;
+ color: #9d9d9d;
+}
+
+.insert-media-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 12px;
+ padding: 16px;
+}
+
+.insert-media-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border: 1px solid #3c3c3c;
+ border-radius: 8px;
+ background: #252526;
+ color: inherit;
+ padding: 10px;
+ text-align: left;
+}
+
+.insert-media-thumb {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 112px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.04);
+ overflow: hidden;
+}
+
+.insert-media-thumb img,
+.gallery-overlay-item img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.insert-media-file-pill {
+ padding: 6px 10px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ font-size: 11px;
+ letter-spacing: 0.04em;
+}
+
+.language-picker-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 12px;
+ align-items: center;
+}
+
+.language-picker-badge.published {
+ background: rgba(34, 197, 94, 0.2);
+ color: #9ae6b4;
+}
+
+.language-picker-badge.draft,
+.language-picker-badge.empty {
+ background: rgba(14, 99, 156, 0.24);
+ color: #8ec5ff;
+}
+
+.confirm-delete-warning {
+ display: flex;
+ gap: 12px;
+ padding: 12px;
+ border-radius: 6px;
+ background: rgba(255, 165, 0, 0.08);
+ border: 1px solid rgba(255, 165, 0, 0.3);
+}
+
+.warning-icon {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(255, 165, 0, 0.18);
+ color: #ffbf47;
+ font-weight: 700;
+}
+
+.reference-list {
+ margin: 12px 0 0;
+ padding-left: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.reference-list li {
+ padding: 8px 10px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.confirm-dialog-overlay {
+ align-items: center;
+}
+
+.confirm-dialog {
+ width: min(460px, calc(100vw - 32px));
+ padding-top: 20px;
+}
+
+.confirm-dialog p {
+ margin: 0;
+ padding: 0 20px 12px;
+ color: #d0d0d0;
+}
+
+.gallery-overlay-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ padding: 18px;
+ overflow: auto;
+}
+
+.gallery-overlay-item {
+ border: none;
+ padding: 0;
+ border-radius: 8px;
+ overflow: hidden;
+ min-height: 148px;
+ background: #252526;
+}
+
+.lightbox-overlay {
+ background: rgba(0, 0, 0, 0.9);
+}
+
+.lightbox-container {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.lightbox-image-container {
+ max-width: 90%;
+ max-height: 78%;
+}
+
+.lightbox-image {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ border-radius: 4px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+}
+
+.lightbox-nav {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 48px;
+ height: 48px;
+ border: none;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.12);
+ color: #ffffff;
+ font-size: 28px;
+}
+
+.lightbox-prev {
+ left: 16px;
+}
+
+.lightbox-next {
+ right: 16px;
+}
+
+.lightbox-close {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.12);
+ color: #ffffff;
+}
+
+.lightbox-footer {
+ position: absolute;
+ bottom: 32px;
+ left: 50%;
+ transform: translateX(-50%);
+ text-align: center;
+ color: #ffffff;
+}
+
+.lightbox-caption {
+ margin: 0 0 6px;
+}
+
+.shared-popover-shell {
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 0;
+ z-index: 20;
+}
+
+.shared-popover {
+ width: min(320px, calc(100vw - 48px));
+ border: 1px solid #3c3c3c;
+ border-radius: 8px;
+ background: #1e1e1e;
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
+ overflow: hidden;
+}
+
+.shared-popover-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 12px 14px;
+ border-bottom: 1px solid #3c3c3c;
+}
+
+.shared-popover-list {
+ display: flex;
+ flex-direction: column;
+ max-height: 220px;
+}
+
+.shared-popover-entry {
+ padding: 10px 14px;
+}
+
+.shared-popover-empty,
+.shared-popover-footnote,
+.colour-picker-selection {
+ display: block;
+ padding: 10px 14px;
+}
+
+.colour-picker-grid {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 8px;
+ padding: 12px 14px;
+}
+
+.colour-swatch {
+ width: 100%;
+ aspect-ratio: 1;
+ border-radius: 6px;
+ border: 2px solid transparent;
+}
+
+.colour-swatch.selected {
+ border-color: #ffffff;
+}
+
+.colour-picker-selection {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: #d0d0d0;
+}
+
+.colour-preview {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+}
+
+@media (max-width: 720px) {
+ .insert-media-grid,
+ .gallery-overlay-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .ai-suggestion-comparison {
+ grid-template-columns: 1fr;
+ }
+
+ .ai-suggestion-arrow {
+ display: none;
+ }
+}
+
.sidebar-section-header {
display: flex;
justify-content: space-between;
diff --git a/priv/ui/app.js b/priv/ui/app.js
deleted file mode 100644
index 750d20c..0000000
--- a/priv/ui/app.js
+++ /dev/null
@@ -1,2607 +0,0 @@
-const root = document.getElementById("bds-shell-app");
-const bootstrapNode = document.getElementById("bds-shell-bootstrap");
-
-if (!root || !bootstrapNode) {
- throw new Error("Missing shell bootstrap payload");
-}
-
-const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
-const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
-const TASK_STATUS_POLL_MS = 1500;
-const bootstrap = JSON.parse(bootstrapNode.textContent);
-const isMac = typeof navigator !== "undefined" && navigator.platform.toLowerCase().includes("mac");
-const state = {
- session: hydrateSession(clone(bootstrap.session)),
- status: clone(bootstrap.status),
- projects: normalizeProjects(bootstrap.projects),
- sidebarContent: clone(bootstrap.content.sidebar),
- sidebarFilterSeeds: clone(bootstrap.content.sidebar),
- sidebarFilters: hydrateSidebarFilters(bootstrap.content.sidebar),
- projectMenuOpen: false,
- taskStatus: normalizeTaskStatus(bootstrap.task_status),
- handledTaskResults: {},
- outputEntries: [],
- gitLogEntries: [],
- uiLanguage: readStoredUiLanguage(bootstrap.i18n?.ui_language || bootstrap.status.right.ui_language),
- supportedUiLanguages: bootstrap.i18n?.supported_ui_languages || [],
- tabMeta: {},
-};
-
-function translationsForLanguage(language) {
- return bootstrap.i18n?.catalogs?.[language] || bootstrap.i18n?.catalogs?.en || {};
-}
-
-function t(key, bindings = {}) {
- const catalog = translationsForLanguage(state.uiLanguage);
- let text = catalog[key] || key;
-
- Object.entries(bindings).forEach(([binding, value]) => {
- text = text.replaceAll(`%{${binding}}`, String(value));
- });
-
- return text;
-}
-
-function tText(value, bindings = {}) {
- return t(String(value), bindings);
-}
-
-bindNativeMenuBridge();
-bindGlobalHotkeys();
-scheduleTaskPolling();
-void fetchProjects();
-render();
-
-function render() {
- root.style.setProperty("--sidebar-width", state.session.sidebar_visible ? `${state.session.sidebar_width}px` : "0px");
- root.style.setProperty("--assistant-width", state.session.assistant_sidebar_visible ? `${state.session.assistant_sidebar_width}px` : "0px");
-
- renderTitlebar();
- renderActivityBar();
- renderSidebar();
- renderTabs();
- renderEditor();
- renderPanel();
- renderAssistant();
- renderStatusBar();
- applyVisibility();
- bindEvents();
-}
-
-function renderTitlebar() {
- const menuBarClass = isMac ? "window-titlebar-menu-bar is-hidden" : "window-titlebar-menu-bar";
-
- root.querySelector(".window-titlebar").innerHTML = `
-
-
- ${escapeHtml(bootstrap.title)}
-
- ${renderTitlebarAction("toggle-sidebar", "toggle-sidebar", t("Toggle sidebar"), `
-
- `)}
- ${renderTitlebarAction("toggle-panel", "toggle-panel", t("Toggle panel"), `
-
-
-
- `)}
- ${renderTitlebarAction("toggle-assistant-sidebar", "toggle-assistant", t("Toggle assistant"), `
-
- `)}
-
- `;
-}
-
-function renderTitlebarAction(command, testId, label, iconMarkup) {
- return `
-
- ${iconMarkup}
-
- `;
-}
-
-function renderActivityBar() {
- const top = sidebarViews().filter((view) => view.activity_group === "top");
- const bottom = sidebarViews().filter((view) => view.activity_group === "bottom");
-
- root.querySelector(".activity-bar").innerHTML = `
- ${top.map(renderActivityButton).join("")}
- ${bottom.map(renderActivityButton).join("")}
- `;
-}
-
-function renderActivityButton(view) {
- const active = state.session.sidebar_visible && state.session.active_view === view.id;
- return `
-
- ${activityIcon(view.id)}
-
- `;
-}
-
-function renderSidebar() {
- const view = currentSidebarView();
- const data = currentSidebarData();
- const filterState = currentSidebarFilterState(view.id);
-
- root.querySelector(".sidebar").innerHTML = `
-
- ${renderSidebarViewHeader(data, view, filterState)}
- ${renderSidebarSearchBox(data, view, filterState)}
- ${renderSidebarFilterPanel(data, view, filterState)}
- ${renderSidebarFilterStatus(data, view, filterState)}
- ${renderSidebarBody(data, view)}
- ${renderSidebarLoadMore(data, view)}
-
- `;
-}
-
-function renderSidebarViewHeader(data, view, filterState) {
- const label = String(tText(view.label || data.title || "")).toUpperCase();
-
- return `
-
- `;
-}
-
-function renderSidebarSearchBox(data, view, filterState) {
- if (!data.filters?.enabled) {
- return "";
- }
-
- return `
-
- `;
-}
-
-function renderSidebarFilterPanel(data, view, filterState) {
- if (!data.filters?.enabled || !filterState.showFilters) {
- return "";
- }
-
- return `
- ${renderSidebarArchiveFilter(data, view, filterState)}
- ${renderSidebarFilterChips(data, view, filterState)}
- `;
-}
-
-function renderSidebarArchiveFilter(data, view, filterState) {
- const entries = Array.isArray(data.filters?.year_month_counts) ? data.filters.year_month_counts : [];
- const years = groupSidebarYearMonths(entries);
-
- return `
-
-
- ${(filterState.year || filterState.month) ? `
✕ ` : ""}
- ${filterState.archiveCollapsed ? "" : `
-
- ${years
- .map(
- (yearEntry) => `
-
-
- ${filterState.expandedYear === yearEntry.year ? `
-
- ${yearEntry.months
- .map(
- (monthEntry) => `
-
- ${escapeHtml(formatDashboardMonth(yearEntry.year, monthEntry.month))}
- ${escapeHtml(String(monthEntry.count))}
-
- `
- )
- .join("")}
-
- ` : ""}
-
- `
- )
- .join("")}
-
- `}
-
- `;
-}
-
-function renderSidebarFilterChips(data, view, filterState) {
- const tags = Array.isArray(data.filters?.available_tags) ? data.filters.available_tags : [];
- const categories = Array.isArray(data.filters?.available_categories) ? data.filters.available_categories : [];
-
- return `
-
- ${tags.length ? `
-
-
- ${filterState.tagsCollapsed ? "" : `
-
- ${tags
- .map(
- (tag) => `
-
- ${escapeHtml(tag)}
-
- `
- )
- .join("")}
-
- `}
-
- ` : ""}
- ${categories.length ? `
-
-
- ${filterState.categoriesCollapsed ? "" : `
-
- ${categories
- .map(
- (category) => `
-
- ${escapeHtml(category)}
-
- `
- )
- .join("")}
-
- `}
-
- ` : ""}
-
- `;
-}
-
-function renderSidebarFilterStatus(data, view, filterState) {
- if (!data.filters?.enabled || !data.filters.has_active_filters) {
- return "";
- }
-
- const count = Number(data.filters.total_count) || 0;
- const label = filterState.search
- ? t(data.filters.results_for_label, { count, query: filterState.search })
- : t(data.filters.results_label, { count });
-
- return `
-
- ${escapeHtml(label)}
- ${escapeHtml(t(data.filters.clear_filters_label))}
-
- `;
-}
-
-function renderSidebarLoadMore(data, view) {
- if (!data.filters?.enabled || !data.filters.has_more) {
- return "";
- }
-
- return `
-
- `;
-}
-
-function renderSidebarBody(data, view) {
- switch (data.layout) {
- case "post_list":
- return renderSidebarPostList(data, view);
- case "media_grid":
- return renderSidebarMediaGrid(data, view);
- case "entity_list":
- return renderSidebarEntityList(data, view);
- case "nav_list":
- return renderSidebarNavList(data, view);
- default:
- return (data.sections || [])
- .map(
- (section) => `
-
- `
- )
- .join("");
- }
-}
-
-function renderSidebarPostList(data, view) {
- const sections = Array.isArray(data.sections) ? data.sections : [];
- const hasItems = sections.some((section) => (section.items || []).length > 0);
-
- return `
- ${sections
- .map(
- (section) => `
-
- `
- )
- .join("")}
- ${hasItems ? "" : renderSidebarEmpty(data.empty_message || "No items")}
- `;
-}
-
-function renderSidebarPostItem(item, view) {
- const tabRef = currentTabRef();
- const itemRoute = item.route || view.editor_route;
- const tabId = tabIdForItem(item, itemRoute);
- const active = tabRef && tabRef.type === itemRoute && tabRef.id === tabId;
- const postType = getSidebarPostType(item.categories || []);
- const languageBadge = Number(item.language_count) > 1
- ? ``
- : "";
-
- return `
-
- `;
-}
-
-function renderSidebarMediaGrid(data, view) {
- const items = Array.isArray(data.items) ? data.items : [];
-
- if (!items.length) {
- return renderSidebarEmpty(data.empty_message || "No items");
- }
-
- return `
-
- `;
-}
-
-function renderSidebarMediaItem(item, view) {
- const tabRef = currentTabRef();
- const itemRoute = item.route || view.editor_route;
- const tabId = tabIdForItem(item, itemRoute);
- const active = tabRef && tabRef.type === itemRoute && tabRef.id === tabId;
- const thumbnail = renderMediaThumbnail(item);
-
- return `
-
- ${thumbnail}
-
- ${escapeHtml(item.title || "")}
- ${escapeHtml(item.meta || "")}
-
-
- `;
-}
-
-function renderMediaThumbnail(item) {
- const fallback = escapeHtml(mediaThumbnailGlyph(item.mime_type));
-
- if (!String(item.mime_type || "").startsWith("image/")) {
- return `${fallback} `;
- }
-
- return `
-
- ${fallback}
-
-
- `;
-}
-
-function renderSidebarEntityList(data, view) {
- const items = Array.isArray(data.items) ? data.items : [];
-
- if (!items.length) {
- return renderSidebarEmpty(data.empty_message || "No items");
- }
-
- return `${items.map((item) => renderSidebarEntityItem(item, view)).join("")}
`;
-}
-
-function renderSidebarEntityItem(item, view) {
- const tabRef = currentTabRef();
- const itemRoute = item.route || view.editor_route;
- const tabId = tabIdForItem(item, itemRoute);
- const active = tabRef && tabRef.type === itemRoute && tabRef.id === tabId;
- const meta = item.updated_at ? formatSidebarRelativeDateMs(item.updated_at) : tText(item.meta || "");
-
- return `
-
-
- ${escapeHtml(item.title || "")}
- ${escapeHtml(meta || "")}
-
-
- `;
-}
-
-function renderSidebarNavList(data, view) {
- const items = Array.isArray(data.items) ? data.items : [];
-
- return `
-
- ${items.map((item) => renderSidebarNavItem(item, view)).join("")}
-
- `;
-}
-
-function renderSidebarNavItem(item, view) {
- const itemRoute = item.route || view.editor_route;
- const tabId = tabIdForItem(item, itemRoute);
- const tabTitle = routeLabel(itemRoute);
-
- return `
-
- ${escapeHtml(item.icon || "")}
- ${escapeHtml(tText(item.title || ""))}
-
- `;
-}
-
-function renderSidebarEmpty(message) {
- return `
-
- `;
-}
-
-function groupSidebarYearMonths(entries) {
- const years = new Map();
-
- entries.forEach((entry) => {
- const year = Number(entry.year);
- const month = Number(entry.month);
- const count = Number(entry.count) || 0;
-
- if (!years.has(year)) {
- years.set(year, { year, count: 0, months: [] });
- }
-
- const yearEntry = years.get(year);
- yearEntry.count += count;
- yearEntry.months.push({ month, count });
- });
-
- return Array.from(years.values())
- .map((yearEntry) => ({
- ...yearEntry,
- months: yearEntry.months.sort((left, right) => right.month - left.month),
- }))
- .sort((left, right) => right.year - left.year);
-}
-
-function hydrateSidebarFilters(sidebarContent) {
- return Object.fromEntries(
- Object.entries(sidebarContent || {}).map(([viewId, data]) => [viewId, defaultSidebarFilterState(viewId, data)])
- );
-}
-
-function defaultSidebarFilterState(viewId, data) {
- const selected = data?.filters?.selected || {};
-
- return {
- search: selected.search || "",
- year: selected.year || null,
- month: selected.month || null,
- tags: Array.isArray(selected.tags) ? [...selected.tags] : [],
- categories: Array.isArray(selected.categories) ? [...selected.categories] : [],
- showFilters: false,
- archiveCollapsed: true,
- tagsCollapsed: true,
- categoriesCollapsed: true,
- expandedYear: selected.year || null,
- displayLimit: data?.filters?.display_limit || data?.filters?.max_items || 500,
- };
-}
-
-function sidebarFilterSeed(viewId) {
- return state.sidebarFilterSeeds[viewId] || state.sidebarContent[viewId] || null;
-}
-
-function currentSidebarFilterState(viewId) {
- if (!state.sidebarFilters[viewId]) {
- state.sidebarFilters[viewId] = defaultSidebarFilterState(viewId, sidebarFilterSeed(viewId));
- }
-
- return state.sidebarFilters[viewId];
-}
-
-function applySidebarPostFilters(viewId) {
- void refreshSidebarView(viewId);
-}
-
-function applySidebarMediaFilters(viewId) {
- void refreshSidebarView(viewId);
-}
-
-async function refreshSidebarView(viewId) {
- try {
- const filterState = currentSidebarFilterState(viewId);
- const response = await fetch("/api/sidebar", {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- view: viewId,
- filters: {
- search: filterState.search,
- year: filterState.year,
- month: filterState.month,
- tags: filterState.tags,
- categories: filterState.categories,
- display_limit: filterState.displayLimit,
- },
- }),
- });
-
- if (!response.ok) {
- return;
- }
-
- const payload = await response.json();
- if (payload.status !== "ok") {
- return;
- }
-
- state.sidebarContent[viewId] = payload.data;
- state.sidebarFilters[viewId] = {
- ...filterState,
- displayLimit: payload.data?.filters?.display_limit || filterState.displayLimit,
- };
- render();
- } catch (_error) {
- // Keep the shell usable if sidebar filtering is temporarily unavailable.
- }
-}
-
-function renderTabs() {
- const tabs = state.session.tabs;
- const node = root.querySelector(".tab-bar");
-
- if (tabs.length === 0) {
- node.innerHTML = `${escapeHtml(t("Dashboard"))}
`;
- return;
- }
-
- node.innerHTML = `
-
- ${tabs.map(renderTab).join("")}
-
- `;
-}
-
-function renderTab(tab) {
- const active = sameTab(tab, currentTabRef());
- const meta = tabMetadata(tab);
-
- return `
-
- ${tabIcon(tab.type)}
- ${escapeHtml(tText(meta.title))}
- ×
-
- `;
-}
-
-function renderEditor() {
- const route = currentRoute();
- const node = root.querySelector(".editor-shell");
-
- if (route === "dashboard") {
- node.innerHTML = renderDashboard();
- return;
- }
-
- const meta = currentEditorMeta();
-
- node.innerHTML = `
-
-
- ${escapeHtml(routeLabel(route))}
- ${escapeHtml(editorTitle())}
- ${escapeHtml(editorSubtitle(route))}
- ${renderEditorBody(route)}
-
-
-
- `;
-}
-
-function renderDashboard() {
- const dashboard = bootstrap.content.dashboard || {};
- const postStats = dashboard.post_stats || {};
- const mediaStats = dashboard.media_stats || {};
- const timelineEntries = Array.isArray(dashboard.timeline_entries) ? dashboard.timeline_entries : [];
- const tagCloudItems = buildDashboardTagCloudItems(dashboard.tag_cloud_items || []);
- const categoryCounts = Array.isArray(dashboard.category_counts) ? dashboard.category_counts : [];
- const recentPosts = Array.isArray(dashboard.recent_posts) ? dashboard.recent_posts : [];
- const meta = currentEditorMeta();
- const maxCount = Math.max(1, ...timelineEntries.map((entry) => Number(entry.count) || 0));
-
- return `
-
-
-
${escapeHtml(t("dashboard.title"))}
-
${escapeHtml(t("dashboard.subtitle"))}
-
-
-
-
${escapeHtml(String(postStats.total_posts || 0))}
-
${escapeHtml(t("dashboard.stats.totalPosts"))}
-
- ${escapeHtml(t("dashboard.stats.published", { count: postStats.published_count || 0 }))}
- ${escapeHtml(t("dashboard.stats.drafts", { count: postStats.draft_count || 0 }))}
- ${(postStats.archived_count || 0) > 0 ? `${escapeHtml(t("dashboard.stats.archived", { count: postStats.archived_count || 0 }))} ` : ""}
-
-
-
-
${escapeHtml(String(mediaStats.media_count || 0))}
-
${escapeHtml(t("dashboard.stats.mediaFiles"))}
-
- ${escapeHtml(t("dashboard.stats.images", { count: mediaStats.image_count || 0 }))}
- ${escapeHtml(formatBytes(mediaStats.total_bytes || 0))}
-
-
-
-
${escapeHtml(String((dashboard.tag_cloud_items || []).length))}
-
${escapeHtml(t("dashboard.stats.tags"))}
-
- ${escapeHtml(t("dashboard.stats.categories", { count: categoryCounts.length }))}
-
-
-
-
- ${timelineEntries.length ? `
-
-
${escapeHtml(t("dashboard.section.postsOverTime"))}
-
- ${timelineEntries
- .map(
- (entry) => `
-
-
- ${escapeHtml(String(entry.count || 0))}
-
-
- ${escapeHtml(formatDashboardMonth(entry.year, entry.month))}
- ${escapeHtml(String(entry.year || ""))}
-
-
- `
- )
- .join("")}
-
-
- ` : ""}
-
- ${tagCloudItems.length ? `
-
-
${escapeHtml(t("dashboard.section.tags"))}
-
- ${tagCloudItems
- .map((item) => `${escapeHtml(item.tag)} `)
- .join("")}
- ${(dashboard.tag_cloud_items || []).length > 40 ? `${escapeHtml(t("dashboard.tagCloud.more", { count: (dashboard.tag_cloud_items || []).length - 40 }))} ` : ""}
-
-
- ` : ""}
-
- ${categoryCounts.length ? `
-
-
${escapeHtml(t("dashboard.section.categories"))}
-
- ${categoryCounts
- .map(
- (category) => `
-
- ${escapeHtml(category.category || "")}
- ${escapeHtml(String(category.count || 0))}
-
- `
- )
- .join("")}
-
-
- ` : ""}
-
- ${recentPosts.length ? `
-
-
${escapeHtml(t("dashboard.section.recentlyUpdated"))}
-
- ${recentPosts
- .map(
- (post) => `
-
- ${escapeHtml(post.title || "")}
- ${escapeHtml(dashboardStatusLabel(post.status || "draft"))}
- ${escapeHtml(formatDashboardDate(post.updated_at))}
- if (route === "settings" || route === "tags" || route === "style") {
- `
- )
- .join("")}
-
-
- ` : ""}
-
-
- ${meta
- .map(
- (item) => `
-
- `
- )
- .join("")}
-
-
-
- `;
-}
-
-function renderEditorBody(route) {
- const meta = currentTabMeta();
-
- if (meta?.payload) {
- return renderCommandPayload(route, meta.payload);
- }
-
- const active = activeItem();
- return `
-
- ${escapeHtml(t("Open"))}
- ${escapeHtml(t("Preview"))}
- ${escapeHtml(t("Metadata"))}
-
-
-
${escapeHtml(tText(active?.title || routeLabel(route)))}
-
${escapeHtml(tText(active?.meta || "Desktop workbench content routed through the Elixir shell."))}
-
- `;
-}
-
-function renderPanel() {
- const tabs = panelTabs();
-
- root.querySelector(".panel-shell").innerHTML = `
-
-
- ${renderPanelBody()}
-
- `;
-}
-
-function renderPanelBody() {
- if (state.session.panel.active_tab === "tasks") {
- return renderTaskPanelEntries();
- }
-
- if (state.session.panel.active_tab === "output") {
- return renderOutputEntries();
- }
-
- if (state.session.panel.active_tab === "git_log") {
- return renderGitLogEntries();
- }
-
- return `
-
- ${escapeHtml(routeLabel(state.session.panel.active_tab))}
- ${escapeHtml(t("The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics."))}
-
- `;
-}
-
-function renderTaskPanelEntries() {
- if (!state.taskStatus.tasks.length) {
- return `
-
- ${escapeHtml(t("Tasks"))}
- ${escapeHtml(t("No background tasks running"))}
-
- `;
- }
-
- return `
-
- ${state.taskStatus.tasks.map((task) => renderTaskEntry(task)).join("")}
-
- `;
-}
-
-function renderTaskEntry(task) {
- const progress = typeof task.progress === "number" ? `${Math.round(task.progress * 100)}%` : null;
- const statusDetail = [task.group_name, progress].filter(Boolean).join(" • ");
- const message = task.message || statusLabel(task.status);
-
- return `
-
-
- ${statusDetail ? `${escapeHtml(statusDetail)} ` : ""}
- ${escapeHtml(message)}
-
- `;
-}
-
-function renderOutputEntries() {
- if (!state.outputEntries.length) {
- return `
-
- ${escapeHtml(t("Output"))}
- ${escapeHtml(t("No shell output yet"))}
-
- `;
- }
-
- return `
-
- ${state.outputEntries
- .map(
- (entry) => `
-
-
${escapeHtml(entry.title)}
-
${escapeHtml(entry.message)}
- ${entry.details ? `
${escapeHtml(entry.details)} ` : ""}
-
- `
- )
- .join("")}
-
- `;
-}
-
-function renderGitLogEntries() {
- return `
-
-
- ${escapeHtml(t("Git Log"))}
- ${escapeHtml(t("Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output."))}
-
-
- `;
-}
-
-function renderAssistant() {
- root.querySelector(".assistant-sidebar").innerHTML = `
-
-
- ${bootstrap.content.assistant_cards
- .map(
- (card) => `
-
- ${escapeHtml(tText(card.label))}
- ${escapeHtml(tText(card.text))}
-
- `
- )
- .join("")}
-
- `;
-}
-
-function renderStatusBar() {
- const status = state.status;
- const taskOverflow = state.taskStatus.running_task_overflow;
- const taskMessage = state.taskStatus.running_task_message || t("Idle");
- const postCount = status.right.post_count_value ?? status.right.post_count;
- const mediaCount = status.right.media_count_value ?? status.right.media_count;
-
- root.querySelector(".status-bar").innerHTML = `
-
- ${renderProjectSelector()}
-
- ${escapeHtml(tText(taskMessage))}
- ${taskOverflow > 0 ? `+${taskOverflow} ` : ""}
-
-
-
- ${escapeHtml(typeof postCount === "number" ? t("%{count} posts", { count: postCount }) : tText(postCount))}
- ${escapeHtml(typeof mediaCount === "number" ? t("%{count} media", { count: mediaCount }) : tText(mediaCount))}
- ${escapeHtml(status.right.theme_badge)}
- ✈
-
- ${escapeHtml(t("UI"))}
- ${renderLanguageOptions()}
-
- ${escapeHtml(status.right.brand)}
-
- `;
-}
-
-function applyVisibility() {
- root.querySelector(".sidebar-shell").classList.toggle("is-hidden", !state.session.sidebar_visible);
- root.querySelector(".assistant-sidebar-shell").classList.toggle("is-hidden", !state.session.assistant_sidebar_visible);
- root.querySelector(".panel-shell").classList.toggle("is-hidden", !state.session.panel.visible);
-}
-
-function bindEvents() {
- root.querySelectorAll("button[data-command]").forEach((button) => {
- button.onclick = () => {
- const command = button.dataset.command;
- if (command === "create-project") {
- void createProject();
- return;
- }
- if (command === "open-tasks-panel") {
- openTasksPanel();
- render();
- return;
- }
- if (command === "toggle-offline-mode") {
- executeShellCommand("toggle_offline_mode");
- return;
- }
- executeShellCommand(command.replace(/-/g, "_"));
- };
- });
-
- root.querySelectorAll("[data-project-menu-trigger]").forEach((button) => {
- button.onclick = (event) => {
- event.stopPropagation();
- toggleProjectMenu();
- };
- });
-
- root.querySelectorAll("[data-project-id]").forEach((button) => {
- button.onclick = () => {
- void selectProject(button.dataset.projectId);
- };
- });
-
- root.querySelectorAll("[data-project-create]").forEach((button) => {
- button.onclick = () => {
- void createProject();
- };
- });
-
- root.querySelectorAll("[data-project-import]").forEach((button) => {
- button.onclick = () => {
- void importExistingProject();
- };
- });
-
- root.querySelectorAll("[data-command='set-ui-language']").forEach((select) => {
- select.onchange = (event) => {
- setUiLanguage(event.target.value);
- render();
- };
- });
-
- root.querySelectorAll("[data-activity]").forEach((button) => {
- button.onclick = () => {
- const next = button.dataset.activity;
-
- if (state.session.active_view === next && state.session.sidebar_visible) {
- state.session.sidebar_visible = false;
- } else {
- state.session.active_view = next;
- state.session.sidebar_visible = true;
- }
- render();
- };
- });
-
- root.querySelectorAll("[data-sidebar-toggle-filters]").forEach((button) => {
- button.onclick = () => {
- const viewId = button.dataset.sidebarToggleFilters;
- const filterState = currentSidebarFilterState(viewId);
- filterState.showFilters = !filterState.showFilters;
- render();
- };
- });
-
- root.querySelectorAll("form[data-sidebar-search]").forEach((form) => {
- form.onsubmit = (event) => {
- event.preventDefault();
- const viewId = form.dataset.sidebarSearch;
- const input = form.querySelector("input[data-sidebar-search-input]");
- const filterState = currentSidebarFilterState(viewId);
- filterState.search = input?.value?.trim() || "";
- filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
- if (viewId === "media") {
- applySidebarMediaFilters(viewId);
- } else {
- applySidebarPostFilters(viewId);
- }
- };
- });
-
- root.querySelectorAll("[data-sidebar-clear-search]").forEach((button) => {
- button.onclick = () => {
- const viewId = button.dataset.sidebarClearSearch;
- const filterState = currentSidebarFilterState(viewId);
- filterState.search = "";
- filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
- if (viewId === "media") {
- applySidebarMediaFilters(viewId);
- } else {
- applySidebarPostFilters(viewId);
- }
- };
- });
-
- root.querySelectorAll("[data-sidebar-toggle-collapse]").forEach((button) => {
- button.onclick = () => {
- const [viewId, section] = button.dataset.sidebarToggleCollapse.split(":");
- const filterState = currentSidebarFilterState(viewId);
-
- if (section === "archive") {
- filterState.archiveCollapsed = !filterState.archiveCollapsed;
- }
-
- if (section === "tags") {
- filterState.tagsCollapsed = !filterState.tagsCollapsed;
- }
-
- if (section === "categories") {
- filterState.categoriesCollapsed = !filterState.categoriesCollapsed;
- }
-
- render();
- };
- });
-
- root.querySelectorAll("[data-sidebar-year]").forEach((button) => {
- button.onclick = () => {
- const [viewId, year] = button.dataset.sidebarYear.split(":");
- const filterState = currentSidebarFilterState(viewId);
- const nextYear = Number.parseInt(year, 10);
- filterState.expandedYear = filterState.expandedYear === nextYear ? null : nextYear;
- filterState.year = nextYear;
- filterState.month = null;
- filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
- if (viewId === "media") {
- applySidebarMediaFilters(viewId);
- } else {
- applySidebarPostFilters(viewId);
- }
- };
- });
-
- root.querySelectorAll("[data-sidebar-month]").forEach((button) => {
- button.onclick = () => {
- const [viewId, year, month] = button.dataset.sidebarMonth.split(":");
- const filterState = currentSidebarFilterState(viewId);
- filterState.year = Number.parseInt(year, 10);
- filterState.month = Number.parseInt(month, 10);
- filterState.expandedYear = filterState.year;
- filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
- if (viewId === "media") {
- applySidebarMediaFilters(viewId);
- } else {
- applySidebarPostFilters(viewId);
- }
- };
- });
-
- root.querySelectorAll("[data-sidebar-clear-date]").forEach((button) => {
- button.onclick = () => {
- const viewId = button.dataset.sidebarClearDate;
- const filterState = currentSidebarFilterState(viewId);
- filterState.year = null;
- filterState.month = null;
- filterState.expandedYear = null;
- filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
- if (viewId === "media") {
- applySidebarMediaFilters(viewId);
- } else {
- applySidebarPostFilters(viewId);
- }
- };
- });
-
- root.querySelectorAll("[data-sidebar-tag]").forEach((button) => {
- button.onclick = () => {
- const [viewId, tag] = button.dataset.sidebarTag.split(":");
- const filterState = currentSidebarFilterState(viewId);
- filterState.tags = toggleSidebarFilterValue(filterState.tags, tag);
- filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
- if (viewId === "media") {
- applySidebarMediaFilters(viewId);
- } else {
- applySidebarPostFilters(viewId);
- }
- };
- });
-
- root.querySelectorAll("[data-sidebar-category]").forEach((button) => {
- button.onclick = () => {
- const [viewId, category] = button.dataset.sidebarCategory.split(":");
- const filterState = currentSidebarFilterState(viewId);
- filterState.categories = toggleSidebarFilterValue(filterState.categories, category);
- filterState.displayLimit = state.sidebarContent[viewId]?.filters?.max_items || filterState.displayLimit;
- applySidebarPostFilters(viewId);
- };
- });
-
- root.querySelectorAll("[data-sidebar-clear-filters]").forEach((button) => {
- button.onclick = () => {
- const viewId = button.dataset.sidebarClearFilters;
- const existing = currentSidebarFilterState(viewId);
- state.sidebarFilters[viewId] = {
- ...defaultSidebarFilterState(viewId, sidebarFilterSeed(viewId)),
- showFilters: existing.showFilters,
- archiveCollapsed: existing.archiveCollapsed,
- tagsCollapsed: existing.tagsCollapsed,
- categoriesCollapsed: existing.categoriesCollapsed,
- };
- if (viewId === "media") {
- applySidebarMediaFilters(viewId);
- } else {
- applySidebarPostFilters(viewId);
- }
- };
- });
-
- root.querySelectorAll("[data-sidebar-load-more]").forEach((button) => {
- button.onclick = () => {
- const viewId = button.dataset.sidebarLoadMore;
- const filterState = currentSidebarFilterState(viewId);
- filterState.displayLimit += state.sidebarContent[viewId]?.filters?.max_items || 500;
- if (viewId === "media") {
- applySidebarMediaFilters(viewId);
- } else {
- applySidebarPostFilters(viewId);
- }
- };
- });
-
- root.querySelectorAll("[data-media-thumbnail-image]").forEach((image) => {
- const container = image.closest(".media-thumbnail");
-
- image.onload = () => {
- container?.classList.add("is-loaded");
- container?.classList.remove("is-error");
- };
-
- image.onerror = () => {
- container?.classList.add("is-error");
- container?.classList.remove("is-loaded");
- };
-
- if (image.complete && image.naturalWidth > 0) {
- container?.classList.add("is-loaded");
- container?.classList.remove("is-error");
- }
- });
-
- root.querySelectorAll("[data-open-tab]").forEach((button) => {
- button.onclick = () => {
- openTab(button.dataset.openRoute, button.dataset.openTab, button.dataset.openTitle, true);
- };
-
- button.ondblclick = () => {
- openTab(button.dataset.openRoute, button.dataset.openTab, button.dataset.openTitle, false);
- };
- });
-
- root.querySelectorAll("[data-tab-id]").forEach((button) => {
- button.onclick = () => {
- state.session.active_tab = { type: button.dataset.tabType, id: button.dataset.tabId };
- render();
- };
- });
-
- root.querySelectorAll("[data-close-tab]").forEach((button) => {
- button.onclick = (event) => {
- event.stopPropagation();
- const [type, id] = button.dataset.closeTab.split(":");
- closeSpecificTab(type, id);
- render();
- };
- });
-
- root.querySelectorAll("[data-panel-tab]").forEach((button) => {
- button.onclick = () => {
- state.session.panel.active_tab = button.dataset.panelTab;
- state.session.panel.visible = true;
- render();
- };
- });
-
- bindResizeHandle("sidebar", {
- key: SIDEBAR_STORAGE_KEY,
- min: 200,
- max: 500,
- get: () => state.session.sidebar_width,
- set: (value) => {
- state.session.sidebar_width = value;
- state.session.sidebar_visible = true;
- },
- });
-
- bindResizeHandle("assistant", {
- key: ASSISTANT_STORAGE_KEY,
- min: 280,
- max: 640,
- get: () => state.session.assistant_sidebar_width,
- set: (value) => {
- state.session.assistant_sidebar_width = value;
- state.session.assistant_sidebar_visible = true;
- },
- invert: true,
- });
-
- bindProjectMenuDismiss();
-}
-
-function bindNativeMenuBridge() {
- if (window.__BDS_NATIVE_MENU_BRIDGE__) {
- return;
- }
-
- window.__BDS_NATIVE_MENU_BRIDGE__ = true;
- window.addEventListener("bds:native-menu-action", (event) => {
- handleNativeMenuAction(event.detail?.action);
- });
-}
-
-function bindGlobalHotkeys() {
- if (window.__BDS_KEYBOARD_BOUND__) {
- return;
- }
-
- window.__BDS_KEYBOARD_BOUND__ = true;
- window.addEventListener("keydown", (event) => {
- if (!(event.metaKey || event.ctrlKey) || event.altKey) {
- return;
- }
-
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) {
- return;
- }
-
- const key = event.key.toLowerCase();
- let command = null;
-
- switch (key) {
- case "b":
- command = "toggle_sidebar";
- break;
- case "j":
- command = "toggle_panel";
- break;
- case "1":
- command = "view_posts";
- break;
- case "2":
- command = "view_media";
- break;
- case "\\":
- command = "toggle_assistant_sidebar";
- break;
- case "w":
- command = "close_tab";
- break;
- default:
- command = null;
- }
-
- if (!command) {
- return;
- }
-
- event.preventDefault();
- executeShellCommand(command);
- });
-}
-
-function scheduleTaskPolling() {
- window.setInterval(fetchTaskStatus, TASK_STATUS_POLL_MS);
- void fetchTaskStatus();
-}
-
-async function fetchTaskStatus() {
- try {
- const response = await fetch("/api/tasks", {
- headers: { Accept: "application/json" },
- cache: "no-store",
- });
-
- if (!response.ok) {
- return;
- }
-
- const next = normalizeTaskStatus(await response.json());
-
- if (JSON.stringify(next) === JSON.stringify(state.taskStatus)) {
- return;
- }
-
- state.taskStatus = next;
- state.status.left.running_task_message = next.running_task_message;
- state.status.left.running_task_overflow = next.running_task_overflow;
- applyCompletedTaskResults(next.tasks);
- render();
- } catch (_error) {
- // Keep the shell usable if task polling is temporarily unavailable.
- }
-}
-
-function applyCompletedTaskResults(tasks) {
- pruneHandledTaskResults(tasks);
-
- tasks.forEach((task) => {
- if (task.status !== "completed" || state.handledTaskResults[task.id]) {
- return;
- }
-
- if (!task.result || typeof task.result !== "object" || typeof task.result.kind !== "string") {
- return;
- }
-
- state.handledTaskResults[task.id] = true;
- applyShellCommandResult(task.result);
- });
-}
-
-function pruneHandledTaskResults(tasks) {
- const visibleTaskIds = new Set(tasks.map((task) => task.id));
-
- Object.keys(state.handledTaskResults).forEach((taskId) => {
- if (!visibleTaskIds.has(taskId)) {
- delete state.handledTaskResults[taskId];
- }
- });
-}
-
-async function fetchProjects() {
- try {
- const response = await fetch("/api/projects", {
- headers: { Accept: "application/json" },
- cache: "no-store",
- });
-
- if (!response.ok) {
- return;
- }
-
- state.projects = normalizeProjects(await response.json());
- if (!state.projects.active_project_id && state.projects.projects.length) {
- state.projects.active_project_id = state.projects.projects[0].id;
- }
- render();
- } catch (_error) {
- // Keep the shell usable if project loading is temporarily unavailable.
- }
-}
-
-async function chooseProjectFolder() {
- try {
- const response = await fetch("/api/project-folder", {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ prompt: t("Select existing blog folder") }),
- });
-
- const payload = await response.json();
-
- if (!response.ok || payload.status === "error") {
- appendOutputEntry(t("Open Existing Blog"), payload.error?.message || t("Command failed with HTTP %{status}", { status: response.status }));
- setPanelTab("output");
- render();
- return null;
- }
-
- if (payload.status === "cancel") {
- return null;
- }
-
- return payload;
- } catch (error) {
- appendOutputEntry(t("Open Existing Blog"), error?.message || String(error));
- setPanelTab("output");
- render();
- return null;
- }
-}
-
-async function createProject(options = {}) {
- const suggestedName = options.name ? String(options.name).trim() : "";
- const name = suggestedName || window.prompt(t("New project name"), t("New Project"));
- if (!name || !name.trim()) {
- return;
- }
-
- closeProjectMenu();
-
- try {
- const response = await fetch("/api/projects", {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- name: name.trim(),
- description: options.description,
- data_path: options.dataPath,
- }),
- });
-
- const payload = await response.json();
-
- if (!response.ok || payload.status !== "ok") {
- appendOutputEntry(t("Create Project"), payload.error?.message || t("Command failed with HTTP %{status}", { status: response.status }));
- setPanelTab("output");
- render();
- return;
- }
-
- await fetchProjects();
- appendOutputEntry(t("Create Project"), t("Activated %{name}", { name: payload.project.name }));
- render();
- } catch (error) {
- appendOutputEntry(t("Create Project"), error?.message || String(error));
- setPanelTab("output");
- render();
- }
-}
-
-async function importExistingProject() {
- closeProjectMenu();
-
- const selection = await chooseProjectFolder();
-
- if (!selection) {
- return;
- }
-
- if (selection.existing_project_id) {
- await selectProject(selection.existing_project_id);
- return;
- }
-
- await createProject({
- name: selection.name,
- description: selection.description,
- dataPath: selection.path,
- });
-}
-
-async function selectProject(projectId) {
- if (!projectId || projectId === state.projects.active_project_id) {
- closeProjectMenu();
- return;
- }
-
- closeProjectMenu();
-
- try {
- const response = await fetch("/api/projects", {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ project_id: projectId }),
- });
-
- const payload = await response.json();
-
- if (!response.ok || payload.status !== "ok") {
- appendOutputEntry(t("Select Project"), payload.error?.message || t("Command failed with HTTP %{status}", { status: response.status }));
- setPanelTab("output");
- render();
- return;
- }
-
- await fetchProjects();
- appendOutputEntry(t("Select Project"), t("Activated %{name}", { name: payload.project.name }));
- render();
- } catch (error) {
- appendOutputEntry(t("Select Project"), error?.message || String(error));
- setPanelTab("output");
- render();
- }
-}
-
-function openTasksPanel() {
- state.session.panel.visible = true;
- state.session.panel.active_tab = "tasks";
-}
-
-function handleNativeMenuAction(action) {
- executeShellCommand(action);
-}
-
-function executeShellCommand(action) {
- if (!action) {
- return;
- }
-
- if (executeLocalShellCommand(action)) {
- render();
- return;
- }
-
- void executeBackendShellCommand(action);
-}
-
-function executeLocalShellCommand(action) {
- if (isSidebarViewCommand(action)) {
- const viewId = action.slice(5);
- state.session.active_view = viewId;
- state.session.sidebar_visible = true;
- return true;
- }
-
- if (isSingletonEditorCommand(action)) {
- openSingletonTab(action.slice(5));
- return true;
- }
-
- switch (action) {
- case "toggle_sidebar":
- state.session.sidebar_visible = !state.session.sidebar_visible;
- persistSessionWidths();
- return true;
- case "toggle_panel":
- state.session.panel.visible = !state.session.panel.visible;
- if (state.session.panel.visible && !state.session.panel.active_tab) {
- state.session.panel.active_tab = "tasks";
- }
- return true;
- case "toggle_assistant_sidebar":
- state.session.assistant_sidebar_visible = !state.session.assistant_sidebar_visible;
- persistSessionWidths();
- return true;
- case "close_tab":
- closeActiveTab();
- return true;
- case "edit_preferences":
- openSingletonTab("settings");
- return true;
- case "edit_menu":
- openSingletonTab("menu_editor");
- return true;
- case "documentation":
- openSingletonTab("documentation");
- return true;
- case "api_documentation":
- openSingletonTab("api_documentation");
- return true;
- case "regenerate_calendar":
- appendOutputEntry(t("Regenerate Calendar"), t("Calendar regeneration is not wired yet, but the base shell now surfaces the command and keeps the Output tab selectable."));
- setPanelTab("output");
- return true;
- case "fill_missing_translations":
- appendOutputEntry(t("Fill Missing Translations"), t("Translation fill is not wired yet, but the command is now routed into Output instead of being ignored."));
- setPanelTab("output");
- return true;
- case "toggle_offline_mode":
- state.status.right.offline_mode = !state.status.right.offline_mode;
- return true;
- default:
- return false;
- }
-}
-
-function isSidebarViewCommand(action) {
- return typeof action === "string"
- && action.startsWith("view_")
- && sidebarViews().some((view) => view.id === action.slice(5));
-}
-
-function isSingletonEditorCommand(action) {
- if (typeof action !== "string" || !action.startsWith("open_")) {
- return false;
- }
-
- const route = bootstrap.registry.editor_routes.find((item) => item.id === action.slice(5));
- return Boolean(route?.singleton);
-}
-
-async function executeBackendShellCommand(action) {
- try {
- const response = await fetch("/api/commands", {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ action }),
- });
-
- if (!response.ok) {
- appendOutputEntry(routeLabel(action), t("Command failed with HTTP %{status}", { status: response.status }));
- setPanelTab("output");
- render();
- return;
- }
-
- const payload = await response.json();
-
- if (payload.status !== "ok") {
- applyShellCommandError(action, payload.error || { message: "Unknown shell command error" });
- return;
- }
-
- applyShellCommandResult(payload.result);
- } catch (error) {
- applyShellCommandError(action, { message: error?.message || String(error) });
- }
-}
-
-function applyShellCommandResult(result) {
- if (!result) {
- return;
- }
-
- switch (result.kind) {
- case "task_queued":
- appendOutputEntry(result.title, result.message);
- setPanelTab(result.panel_tab || "tasks");
- void fetchTaskStatus();
- break;
- case "open_url":
- appendOutputEntry(tText(result.title), tText(result.url || result.message || "Opened URL"));
- setPanelTab("output");
-
- if (result.url) {
- window.open(result.url, "_blank", "noopener");
- }
-
- break;
- case "open_editor":
- openSingletonTab(result.route, {
- title: result.title,
- subtitle: result.subtitle,
- editorMeta: result.editorMeta,
- payload: result.payload,
- });
- return;
- case "output":
- appendOutputEntry(tText(result.title), tText(result.message), result.details);
- setPanelTab(result.panel_tab || "output");
- break;
- default:
- appendOutputEntry(routeLabel(result.action || "output"), tText(result.message || "Command completed"));
- setPanelTab("output");
- break;
- }
-
- render();
-}
-
-function applyShellCommandError(action, error) {
- appendOutputEntry(routeLabel(action), error?.message || t("Command failed"));
- setPanelTab("output");
- render();
-}
-
-function setPanelTab(tab) {
- state.session.panel.visible = true;
- state.session.panel.active_tab = tab;
-}
-
-function appendOutputEntry(title, message, details = "") {
- state.outputEntries = [{ title, message, details }, ...state.outputEntries].slice(0, 20);
-}
-
-function openSingletonTab(type, meta = {}) {
- openTab(type, type, meta.title || routeLabel(type), false, meta);
-}
-
-function closeActiveTab() {
- const active = currentTabRef();
- if (!active) {
- return;
- }
-
- const index = state.session.tabs.findIndex((tab) => tab.type === active.type && tab.id === active.id);
-
- if (index < 0) {
- return;
- }
-
- state.session.tabs.splice(index, 1);
-
- if (state.session.tabs.length === 0) {
- state.session.active_tab = null;
- return;
- }
-
- if (index < state.session.tabs.length) {
- const next = state.session.tabs[index];
- state.session.active_tab = { type: next.type, id: next.id };
- } else {
- const next = state.session.tabs[state.session.tabs.length - 1];
- state.session.active_tab = { type: next.type, id: next.id };
- }
-}
-
-function closeSpecificTab(type, id) {
- const index = state.session.tabs.findIndex((tab) => tab.type === type && tab.id === id);
-
- if (index < 0) {
- return;
- }
-
- const wasActive = state.session.active_tab?.type === type && state.session.active_tab?.id === id;
- state.session.tabs.splice(index, 1);
- delete state.tabMeta[`${type}:${id}`];
-
- if (!state.session.tabs.length) {
- state.session.active_tab = null;
- return;
- }
-
- if (!wasActive) {
- return;
- }
-
- const next = state.session.tabs[Math.min(index, state.session.tabs.length - 1)];
- state.session.active_tab = { type: next.type, id: next.id };
-}
-
-function openTab(type, id, title, transient, meta = {}) {
- const existingIndex = state.session.tabs.findIndex((tab) => tab.type === type && tab.id === id);
-
- if (existingIndex >= 0) {
- state.session.tabs[existingIndex].is_transient = transient ? state.session.tabs[existingIndex].is_transient : false;
- } else if (transient) {
- const transientIndex = state.session.tabs.findIndex((tab) => tab.type === type && tab.is_transient);
- const nextTab = { type, id, is_transient: true };
-
- if (transientIndex >= 0) {
- state.session.tabs.splice(transientIndex, 1, nextTab);
- } else {
- state.session.tabs.push(nextTab);
- }
- } else {
- state.session.tabs.push({ type, id, is_transient: false });
- }
-
- state.tabMeta[`${type}:${id}`] = { title, ...meta };
- state.session.active_tab = { type, id };
- render();
-}
-
-function currentTabMeta() {
- const tab = currentTabRef();
- return tab ? state.tabMeta[`${tab.type}:${tab.id}`] : null;
-}
-
-function activeItem() {
- const tab = currentTabRef();
-
- if (!tab) {
- return null;
- }
-
- const items = Object.values(state.sidebarContent).flatMap(flattenSidebarItems);
- return items.find((item) => item.route === tab.type && tabIdForItem(item, item.route) === tab.id) || null;
-}
-
-function flattenSidebarItems(view) {
- if (Array.isArray(view.sections)) {
- return view.sections.flatMap((section) => section.items || []);
- }
-
- return Array.isArray(view.items) ? view.items : [];
-}
-
-function tabMetadata(tab) {
- const lookup = state.tabMeta[`${tab.type}:${tab.id}`];
- if (lookup) {
- return lookup;
- }
-
- const item = activeItem();
- if (item && tab.id === tabIdForItem(item, item.route)) {
- return { title: tText(item.title) };
- }
-
- return { title: routeLabel(tab.type) };
-}
-
-function currentSidebarView() {
- return sidebarViews().find((view) => view.id === state.session.active_view) || sidebarViews()[0];
-}
-
-function currentSidebarData() {
- return state.sidebarContent[state.session.active_view] || state.sidebarContent[bootstrap.registry.default_sidebar_view];
-}
-
-function currentTabRef() {
- return state.session.active_tab;
-}
-
-function currentRoute() {
- return currentTabRef()?.type || "dashboard";
-}
-
-function currentEditorMeta() {
- const meta = currentTabMeta();
- return meta?.editorMeta || bootstrap.content.editor_meta[currentRoute()] || bootstrap.content.editor_meta.dashboard;
-}
-
-function editorTitle() {
- const meta = currentTabMeta();
- if (meta?.title) {
- return tText(meta.title);
- }
-
- const item = activeItem();
- return tText(item?.title || bootstrap.content.dashboard.title);
-}
-
-function editorSubtitle(route) {
- const meta = currentTabMeta();
- if (meta?.subtitle) {
- return tText(meta.subtitle);
- }
-
- if (route === "dashboard") {
- return tText(bootstrap.content.dashboard.subtitle);
- }
-
- const item = activeItem();
- return tText(item?.meta || "Desktop workbench content routed through the Elixir shell.");
-}
-
-function routeLabel(route) {
- if (!route) {
- return t("Dashboard");
- }
-
- if (route === "output") {
- return t("Output");
- }
-
- if (route === "git_log") {
- return t("Git Log");
- }
-
- if (route === "open_in_browser") {
- return t("Open in Browser");
- }
-
- if (route === "open_data_folder") {
- return t("Open Data Folder");
- }
-
- if (route === "upload_site") {
- return t("Upload Site");
- }
-
- return tText(
- bootstrap.registry.editor_routes.find((item) => item.id === route)?.title ||
- sidebarViews().find((item) => item.id === route)?.label ||
- titleCase(route)
- );
-}
-
-function renderCommandPayload(route, payload) {
- switch (route) {
- case "metadata_diff":
- return `
-
-
- ${escapeHtml(t("Diffs"))}: ${escapeHtml(String(payload.summary?.diff_count || 0))}
- ${escapeHtml(t("Orphans"))}: ${escapeHtml(String(payload.summary?.orphan_count || 0))}
-
-
-
- ${escapeHtml(t("Diff Reports"))}
- ${renderKeyedEntries(payload.diff_reports, ["entity_type", "entity_id", "differences"])}
-
-
- ${escapeHtml(t("Orphan Reports"))}
- ${renderKeyedEntries(payload.orphan_reports, ["entity_type", "path"])}
-
- `;
- case "site_validation":
- return `
-
-
- ${escapeHtml(t("Missing"))}: ${escapeHtml(String(payload.summary?.missing_count || 0))}
- ${escapeHtml(t("Extra"))}: ${escapeHtml(String(payload.summary?.extra_count || 0))}
- ${escapeHtml(t("Stale"))}: ${escapeHtml(String(payload.summary?.stale_count || 0))}
-
-
-
- ${escapeHtml(t("Missing Pages"))}
- ${renderStringList(payload.missing_pages, t("No missing pages"))}
-
-
- ${escapeHtml(t("Extra Pages"))}
- ${renderStringList(payload.extra_pages, t("No extra pages"))}
-
-
- ${escapeHtml(t("Stale Pages"))}
- ${renderStringList(payload.stale_pages, t("No stale pages"))}
-
- `;
- case "translation_validation":
- return `
-
-
- ${escapeHtml(t("Missing"))}: ${escapeHtml(String(payload.summary?.missing_count || 0))}
- ${escapeHtml(t("Orphan Files"))}: ${escapeHtml(String(payload.summary?.orphan_count || 0))}
- ${escapeHtml(t("Do Not Translate"))}: ${escapeHtml(String(payload.summary?.do_not_translate_count || 0))}
-
-
-
- ${escapeHtml(t("Missing Translations"))}
- ${renderKeyedEntries(payload.missing, ["post_id", "language"])}
-
-
- ${escapeHtml(t("Orphan Files"))}
- ${renderStringList(payload.orphan_files, t("No orphan translation files"))}
-
- `;
- case "find_duplicates":
- return `
-
-
- ${escapeHtml(t("Pairs"))}: ${escapeHtml(String(payload.summary?.pair_count || 0))}
-
-
-
- ${escapeHtml(t("Duplicate Candidates"))}
- ${renderKeyedEntries(payload.pairs, ["title_a", "title_b", "score"])}
-
- `;
- default:
- return `
-
- ${escapeHtml(JSON.stringify(payload, null, 2))}
-
- `;
- }
-}
-
-function renderStringList(items, emptyMessage) {
- if (!items || !items.length) {
- return `${escapeHtml(emptyMessage)}
`;
- }
-
- return `${items.map((item) => `${escapeHtml(String(item))} `).join("")} `;
-}
-
-function renderKeyedEntries(items, keys) {
- if (!items || !items.length) {
- return `${escapeHtml(t("No items"))}
`;
- }
-
- return `
-
- ${items
- .map((item) => `
-
- ${keys
- .filter((key) => item[key] !== undefined)
- .map((key) => `${escapeHtml(titleCase(key))}: ${escapeHtml(formatPayloadValue(item[key]))} `)
- .join("")}
-
- `)
- .join("")}
-
- `;
-}
-
-function formatPayloadValue(value) {
- if (Array.isArray(value)) {
- return value.map((entry) => formatPayloadValue(entry)).join(", ");
- }
-
- if (value && typeof value === "object") {
- return JSON.stringify(value);
- }
-
- return String(value);
-}
-
-function tabIdForItem(item, route) {
- if (route === "settings" || route === "tags" || route === "style") {
- return route;
- }
-
- return item.id;
-}
-
-function toggleSidebarFilterValue(values, value) {
- return values.includes(value)
- ? values.filter((entry) => entry !== value)
- : [...values, value];
-}
-
-function sidebarViews() {
- return bootstrap.registry.sidebar_views;
-}
-
-function sameTab(tab, ref) {
- return Boolean(ref) && tab.type === ref.type && tab.id === ref.id;
-}
-
-function uniqueValue(value, index, array) {
- return Boolean(value) && array.indexOf(value) === index;
-}
-
-function titleCase(value) {
- return value
- .split("_")
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
- .join(" ");
-}
-
-function clone(value) {
- return JSON.parse(JSON.stringify(value));
-}
-
-function hydrateSession(session) {
- const next = session;
- next.sidebar_width = readStoredSize(SIDEBAR_STORAGE_KEY, next.sidebar_width, 200, 500);
- next.assistant_sidebar_width = readStoredSize(ASSISTANT_STORAGE_KEY, next.assistant_sidebar_width, 280, 640);
- return next;
-}
-
-function bindResizeHandle(name, options) {
- const handle = root.querySelector(`[data-resize='${name}']`);
- if (!handle) {
- return;
- }
-
- handle.onmousedown = (event) => {
- event.preventDefault();
- const startX = event.clientX;
- const startWidth = options.get();
-
- const onMouseMove = (moveEvent) => {
- const delta = options.invert ? startX - moveEvent.clientX : moveEvent.clientX - startX;
- const width = clamp(startWidth + delta, options.min, options.max);
- options.set(width);
- persistSessionWidths();
- render();
- };
-
- const onMouseUp = () => {
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- };
-
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
- };
-}
-
-function persistSessionWidths() {
- localStorage.setItem(SIDEBAR_STORAGE_KEY, String(state.session.sidebar_width));
- localStorage.setItem(ASSISTANT_STORAGE_KEY, String(state.session.assistant_sidebar_width));
-}
-
-function readStoredSize(key, fallback, min, max) {
- const raw = localStorage.getItem(key);
- if (!raw) {
- return fallback;
- }
-
- const parsed = Number.parseInt(raw, 10);
- if (Number.isNaN(parsed)) {
- return fallback;
- }
-
- return clamp(parsed, min, max);
-}
-
-function clamp(value, min, max) {
- return Math.max(min, Math.min(max, value));
-}
-
-function activityIcon(id) {
- const icons = {
- posts: ' ',
- pages: ' ',
- media: ' ',
- scripts: ' ',
- templates: ' ',
- tags: ' ',
- chat: ' ',
- import: ' ',
- git: ' ',
- settings: ' ',
- };
-
- return icons[id] || icons.posts;
-}
-
-function tabIcon(type) {
- return activityIcon(type === "post" ? "posts" : type);
-}
-
-function normalizeTaskStatus(taskStatus) {
- return {
- active_count: taskStatus?.active_count || 0,
- running_count: taskStatus?.running_count || 0,
- pending_count: taskStatus?.pending_count || 0,
- running_task_message: taskStatus?.running_task_message || null,
- running_task_overflow: taskStatus?.running_task_overflow || 0,
- tasks: Array.isArray(taskStatus?.tasks) ? taskStatus.tasks : [],
- };
-}
-
-function normalizeProjects(projectsPayload) {
- return {
- active_project_id: projectsPayload?.active_project_id || null,
- projects: Array.isArray(projectsPayload?.projects) ? projectsPayload.projects : [],
- };
-}
-
-function panelTabs() {
- return ["tasks", "output", "git_log", state.session.panel.active_tab].filter(uniqueValue);
-}
-
-function renderPanelTab(tab) {
- if (tab === "tasks") {
- return `${escapeHtml(t("Tasks"))} `;
- }
-
- if (tab === "output") {
- return `${escapeHtml(t("Output"))} `;
- }
-
- if (tab === "git_log") {
- return `${escapeHtml(t("Git Log"))} `;
- }
-
- return `${escapeHtml(routeLabel(tab))} `;
-}
-
-function renderLanguageOptions() {
- return state.supportedUiLanguages
- .map((language) => {
- const selected = language.code === state.uiLanguage ? " selected" : "";
- return `${escapeHtml(language.flag || language.code.toUpperCase())} `;
- })
- .join("");
-}
-
-function renderProjectSelector() {
- const activeProject = currentProject();
-
- return `
-
- `;
-}
-
-function renderProjectDropdown() {
- return `
-
-
-
- ${state.projects.projects.map((project) => renderProjectItem(project)).join("")}
-
-
-
- `;
-}
-
-function renderProjectItem(project) {
- const active = project.id === state.projects.active_project_id;
-
- return `
-
- ${escapeHtml(project.name)}
- ${active ? `✓ ` : ""}
-
- `;
-}
-
-function currentProject() {
- return state.projects.projects.find((project) => project.id === state.projects.active_project_id) || state.projects.projects[0] || null;
-}
-
-function toggleProjectMenu() {
- state.projectMenuOpen = !state.projectMenuOpen;
- render();
-}
-
-function closeProjectMenu() {
- if (!state.projectMenuOpen) {
- return;
- }
-
- state.projectMenuOpen = false;
- render();
-}
-
-function bindProjectMenuDismiss() {
- if (window.__BDS_PROJECT_MENU_DISMISS_BOUND__) {
- return;
- }
-
- window.__BDS_PROJECT_MENU_DISMISS_BOUND__ = true;
- document.addEventListener("mousedown", (event) => {
- if (!state.projectMenuOpen) {
- return;
- }
-
- const selector = root.querySelector(".project-selector");
- if (selector && !selector.contains(event.target)) {
- closeProjectMenu();
- }
- });
-}
-
-function setUiLanguage(nextLanguage) {
- state.uiLanguage = nextLanguage;
- state.status.right.ui_language = nextLanguage;
- localStorage.setItem("bds-ui-language", nextLanguage);
-}
-
-function readStoredUiLanguage(fallback) {
- const stored = localStorage.getItem("bds-ui-language");
- return stored || fallback || "en";
-}
-
-function statusLabel(status) {
- switch (status) {
- case "running":
- return t("Running");
- case "pending":
- return t("Queued");
- default:
- return tText(titleCase(status || "task"));
- }
-}
-
-function getSidebarPostType(categories) {
- const lowerCategories = (categories || []).map((category) => String(category).toLowerCase());
-
- if (lowerCategories.includes("picture") || lowerCategories.includes("photo") || lowerCategories.includes("image")) {
- return { icon: "🖼️", type: "picture" };
- }
-
- if (lowerCategories.includes("aside") || lowerCategories.includes("note") || lowerCategories.includes("quick")) {
- return { icon: "📝", type: "aside" };
- }
-
- if (lowerCategories.includes("link") || lowerCategories.includes("bookmark")) {
- return { icon: "🔗", type: "link" };
- }
-
- if (lowerCategories.includes("video")) {
- return { icon: "🎬", type: "video" };
- }
-
- if (lowerCategories.includes("quote")) {
- return { icon: "💬", type: "quote" };
- }
-
- return { icon: "📄", type: "article" };
-}
-
-function formatSidebarAbsoluteDate(timestamp) {
- if (!timestamp) {
- return "";
- }
-
- return new Intl.DateTimeFormat(formatLocaleFor(state.uiLanguage), {
- month: "short",
- day: "numeric",
- year: "numeric",
- }).format(new Date(timestamp));
-}
-
-function formatSidebarRelativeDateMs(timestamp) {
- if (!timestamp) {
- return "";
- }
-
- const date = new Date(timestamp);
- const now = new Date();
- const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
-
- if (diffDays === 0) {
- return date.toLocaleTimeString(formatLocaleFor(state.uiLanguage), { hour: "numeric", minute: "2-digit" });
- }
-
- if (diffDays === 1) {
- return t("sidebar.chat.yesterday");
- }
-
- if (diffDays < 7) {
- return date.toLocaleDateString(formatLocaleFor(state.uiLanguage), { weekday: "short" });
- }
-
- return date.toLocaleDateString(formatLocaleFor(state.uiLanguage), { month: "short", day: "numeric" });
-}
-
-function mediaThumbnailGlyph(mimeType) {
- if (String(mimeType || "").startsWith("image/")) {
- return "🖼️";
- }
-
- return "📄";
-}
-
-function mediaThumbnailUrl(mediaId) {
- return `/api/media-thumbnail/${encodeURIComponent(mediaId)}`;
-}
-
-function buildDashboardTagCloudItems(items) {
- if (!Array.isArray(items) || !items.length) {
- return [];
- }
-
- const topItems = items
- .slice()
- .sort((left, right) => (Number(right.count) || 0) - (Number(left.count) || 0))
- .slice(0, 40);
-
- const counts = topItems.map((item) => Number(item.count) || 0);
- const maxCount = Math.max(1, ...counts);
- const minCount = Math.min(...counts);
- const range = Math.max(1, maxCount - minCount);
-
- return topItems
- .map((item) => ({
- ...item,
- color: normalizeDashboardTagColor(item.color),
- fontSize: 11 + (((Number(item.count) || 0) - minCount) / range) * 11,
- }))
- .sort((left, right) => String(left.tag || "").localeCompare(String(right.tag || "")));
-}
-
-function dashboardPostCountLabel(count) {
- const normalizedCount = Number(count) || 0;
- return t(normalizedCount === 1 ? "dashboard.postCount.one" : "dashboard.postCount.other", { count: normalizedCount });
-}
-
-function dashboardStatusLabel(status) {
- const keys = {
- draft: "dashboard.status.draft",
- published: "dashboard.status.published",
- archived: "dashboard.status.archived",
- };
-
- return keys[status] ? t(keys[status]) : tText(titleCase(status || "draft"));
-}
-
-function formatDashboardMonth(year, month) {
- return new Intl.DateTimeFormat(formatLocaleFor(state.uiLanguage), { month: "short" }).format(new Date(year, (month || 1) - 1, 1));
-}
-
-function formatDashboardDate(timestamp) {
- if (!timestamp) {
- return "";
- }
-
- return new Intl.DateTimeFormat(formatLocaleFor(state.uiLanguage)).format(new Date(timestamp));
-}
-
-function formatLocaleFor(language) {
- const locales = {
- de: "de-DE",
- en: "en-US",
- es: "es-ES",
- fr: "fr-FR",
- it: "it-IT",
- };
-
- return locales[language] || locales.en;
-}
-
-function formatBytes(bytes) {
- const normalizedBytes = Number(bytes) || 0;
-
- if (normalizedBytes === 0) {
- return "0 B";
- }
-
- const units = ["B", "KB", "MB", "GB"];
- const unitIndex = Math.min(Math.floor(Math.log(normalizedBytes) / Math.log(1024)), units.length - 1);
- const value = normalizedBytes / Math.pow(1024, unitIndex);
- return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
-}
-
-function renderDashboardTagStyle(item) {
- const declarations = [`font-size: ${(item.fontSize || 11).toFixed(1)}px;`];
-
- if (item.color) {
- declarations.push(`background-color: ${item.color};`);
- declarations.push(`color: ${dashboardContrastColor(item.color)};`);
- }
-
- return declarations.join(" ");
-}
-
-function normalizeDashboardTagColor(color) {
- if (typeof color !== "string") {
- return null;
- }
-
- const trimmed = color.trim();
- return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed) ? trimmed : null;
-}
-
-function dashboardContrastColor(hexColor) {
- const normalized = hexColor.length === 4
- ? `#${hexColor[1]}${hexColor[1]}${hexColor[2]}${hexColor[2]}${hexColor[3]}${hexColor[3]}`
- : hexColor;
-
- const red = Number.parseInt(normalized.slice(1, 3), 16);
- const green = Number.parseInt(normalized.slice(3, 5), 16);
- const blue = Number.parseInt(normalized.slice(5, 7), 16);
- const luminance = (red * 299 + green * 587 + blue * 114) / 1000;
- return luminance >= 140 ? "#111111" : "#f5f5f5";
-}
-
-function escapeHtml(value) {
- return String(value)
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-}
-
-function escapeHtmlAttribute(value) {
- return escapeHtml(value).replaceAll("`", "`");
-}
\ No newline at end of file
diff --git a/priv/ui/index.html b/priv/ui/index.html
deleted file mode 100644
index 736ba16..0000000
--- a/priv/ui/index.html
+++ /dev/null
@@ -1,138 +0,0 @@
-
-
-
-
-
- Blogging Desktop Server
-
-
-
-
-
-