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 %>
+
+ + + +
+
+ +
+ + +
+ + +
+ +
+
+
+
+
+

<%= 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 %> + + <% end %> +
+
+ <% end %> + + +
+
+
+ +
+
+
+ <%= for tab <- @panel_tabs do %> + + <% end %> +
+
+
+ <%= render_panel_body(assigns) %> +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+
+ <%= @status.right.post_count %> + <%= @status.right.media_count %> + <%= @status.right.theme_badge %> + + + <%= @status.right.brand %> +
+
+
+ """ + 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 %> + + <% end %> +
+ <% else %> + + <% end %> + """ + end + + defp render_nav_sidebar(assigns) do + ~H""" +
+ <%= for item <- @sidebar_data.items || [] do %> + + <% 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.name %> + <%= task.status |> to_string() |> String.capitalize() %> +
+ <%= 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 = ` -
- ${bootstrap.menu_groups - .map((group) => ``) - .join("")} -
-
-
${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 ` - - `; -} - -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 ` - - `; -} - -function renderSidebar() { - const view = currentSidebarView(); - const data = currentSidebarData(); - const filterState = currentSidebarFilterState(view.id); - - root.querySelector(".sidebar").innerHTML = ` - - `; -} - -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) => ` - - ` - ) - .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) => ` - - ` - ) - .join("")} -
- `} -
- ` : ""} - ${categories.length ? ` -
- - ${filterState.categoriesCollapsed ? "" : ` -
- ${categories - .map( - (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)} - -
- `; -} - -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 - ? `${escapeHtml(String(item.language_count))}` - : ""; - - 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 ` - - `; -} - -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 ` - - `; -} - -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 ` - - `; -} - -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 ` - - `; -} - -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) => ` -
-
- ` : ""} - - -
-
- `; -} - -function renderEditorBody(route) { - const meta = currentTabMeta(); - - if (meta?.payload) { - return renderCommandPayload(route, meta.payload); - } - - const active = activeItem(); - return ` -
- - - -
-
-

${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 = ` -
-
- ${tabs.map((tab) => renderPanelTab(tab)).join("")} -
-
-
- ${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 ` -
-
- ${escapeHtml(task.name)} - ${escapeHtml(statusLabel(task.status))} -
- ${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 = ` -
- ${escapeHtml(t("Assistant"))} -
-
- ${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(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(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("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 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 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("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 ``; -} - -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 ``; - } - - if (tab === "output") { - return ``; - } - - if (tab === "git_log") { - return ``; - } - - return ``; -} - -function renderLanguageOptions() { - return state.supportedUiLanguages - .map((language) => { - const selected = language.code === state.uiLanguage ? " selected" : ""; - return ``; - }) - .join(""); -} - -function renderProjectSelector() { - const activeProject = currentProject(); - - return ` -
- - ${state.projectMenuOpen ? renderProjectDropdown() : ""} -
- `; -} - -function renderProjectDropdown() { - return ` -
-
- ${escapeHtml(t("Projects"))} -
-
- ${state.projects.projects.map((project) => renderProjectItem(project)).join("")} -
- -
- `; -} - -function renderProjectItem(project) { - const active = project.id === state.projects.active_project_id; - - return ` - - `; -} - -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 - - - -
-
-
- - -
-
-
-
-
-
-
- -
-
-
-
- -