feat: switch to phoenix liveview
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
4
PLAN.md
4
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.
|
- 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.
|
- 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.
|
- 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
|
### 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ config :bds, :desktop,
|
|||||||
title: "Blogging Desktop Server",
|
title: "Blogging Desktop Server",
|
||||||
secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
|
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,
|
config :bds, :scripting,
|
||||||
runtime: BDS.Scripting.Lua,
|
runtime: BDS.Scripting.Lua,
|
||||||
timeout: 300_000,
|
timeout: 300_000,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ defmodule BDS.Application do
|
|||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
children = [
|
||||||
|
{Phoenix.PubSub, name: BDS.PubSub},
|
||||||
|
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
|
||||||
BDS.Repo,
|
BDS.Repo,
|
||||||
BDS.RepoBootstrap,
|
BDS.RepoBootstrap,
|
||||||
BDS.Tasks,
|
BDS.Tasks,
|
||||||
@@ -67,4 +69,9 @@ defmodule BDS.Application do
|
|||||||
defp desktop_automation? do
|
defp desktop_automation? do
|
||||||
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
|
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp desktop_secret_key_base do
|
||||||
|
Application.get_env(:bds, :desktop)[:secret_key_base] ||
|
||||||
|
raise "missing :desktop secret_key_base configuration"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
42
lib/bds/desktop/endpoint.ex
Normal file
42
lib/bds/desktop/endpoint.ex
Normal file
@@ -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
|
||||||
9
lib/bds/desktop/error_html.ex
Normal file
9
lib/bds/desktop/error_html.ex
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule BDS.Desktop.ErrorHTML do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
def render(_template, _assigns) do
|
||||||
|
"not found"
|
||||||
|
end
|
||||||
|
end
|
||||||
9
lib/bds/desktop/health_controller.ex
Normal file
9
lib/bds/desktop/health_controller.ex
Normal file
@@ -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
|
||||||
26
lib/bds/desktop/layouts.ex
Normal file
26
lib/bds/desktop/layouts.ex
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
defmodule BDS.Desktop.Layouts do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
def root(assigns) do
|
||||||
|
~H"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang={@page_language || "en"}>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><%= @page_title || "Blogging Desktop Server" %></title>
|
||||||
|
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
|
||||||
|
<link phx-track-static rel="stylesheet" href="/assets/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%= @inner_content %>
|
||||||
|
<script defer phx-track-static src="/vendor/phoenix/phoenix.min.js"></script>
|
||||||
|
<script defer phx-track-static src="/vendor/live_view/phoenix_live_view.min.js"></script>
|
||||||
|
<script defer phx-track-static src="/assets/live.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
59
lib/bds/desktop/media_controller.ex
Normal file
59
lib/bds/desktop/media_controller.ex
Normal file
@@ -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
|
||||||
@@ -1,109 +1,28 @@
|
|||||||
defmodule BDS.Desktop.Router do
|
defmodule BDS.Desktop.Router do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Plug.Router
|
use Phoenix.Router
|
||||||
|
|
||||||
plug :put_secret_key_base
|
import Phoenix.LiveView.Router
|
||||||
|
|
||||||
plug Plug.Session,
|
pipeline :browser do
|
||||||
store: :cookie,
|
plug :accepts, ["html"]
|
||||||
key: "_bds_desktop_key",
|
plug :fetch_session
|
||||||
signing_salt: "desktop-shell"
|
plug :fetch_live_flash
|
||||||
|
plug :put_root_layout, html: {BDS.Desktop.Layouts, :root}
|
||||||
plug :match
|
plug :protect_from_forgery
|
||||||
plug :maybe_require_desktop_auth
|
plug :put_secure_browser_headers
|
||||||
|
|
||||||
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())
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/health" do
|
scope "/", BDS.Desktop do
|
||||||
Plug.Conn.send_resp(conn, 200, "ok")
|
pipe_through :browser
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/tasks" do
|
get "/health", HealthController, :show
|
||||||
conn
|
get "/media-thumbnail/:media_id", MediaController, :thumbnail
|
||||||
|> Plug.Conn.put_resp_content_type("application/json")
|
|
||||||
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.task_status_json())
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/projects" do
|
live_session :desktop_shell,
|
||||||
conn
|
root_layout: {BDS.Desktop.Layouts, :root} do
|
||||||
|> Plug.Conn.put_resp_content_type("application/json")
|
live "/", ShellLive, :index
|
||||||
|> 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, [])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ defmodule BDS.Desktop.Server do
|
|||||||
def init(_opts) do
|
def init(_opts) do
|
||||||
{:ok, bandit_pid} =
|
{:ok, bandit_pid} =
|
||||||
Bandit.start_link(
|
Bandit.start_link(
|
||||||
plug: BDS.Desktop.Router,
|
plug: BDS.Desktop.Endpoint,
|
||||||
scheme: :http,
|
scheme: :http,
|
||||||
ip: {127, 0, 0, 1},
|
ip: {127, 0, 0, 1},
|
||||||
port: port(),
|
port: port(),
|
||||||
|
|||||||
@@ -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
|
|
||||||
246
lib/bds/desktop/shell_data.ex
Normal file
246
lib/bds/desktop/shell_data.ex
Normal file
@@ -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(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"></path><path d="M8 12h8v2H8zm0 4h8v2H8z"></path></svg>)
|
||||||
|
"pages" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"></path></svg>)
|
||||||
|
"media" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg>)
|
||||||
|
"scripts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"></path></svg>)
|
||||||
|
"templates" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"></path></svg>)
|
||||||
|
"tags" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"></path></svg>)
|
||||||
|
"chat" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"></path><circle cx="8" cy="10" r="1.5"></circle><circle cx="12" cy="10" r="1.5"></circle><circle cx="16" cy="10" r="1.5"></circle></svg>)
|
||||||
|
"import" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>)
|
||||||
|
"git" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"></path></svg>)
|
||||||
|
"settings" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>)
|
||||||
|
_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
|
||||||
|
<<r::binary-size(2), g::binary-size(2), b::binary-size(2)>> = 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
|
||||||
630
lib/bds/desktop/shell_live.ex
Normal file
630
lib/bds/desktop/shell_live.ex
Normal file
@@ -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"""
|
||||||
|
<div class="app" id="bds-shell-app">
|
||||||
|
<div class="window-titlebar" data-region="title-bar">
|
||||||
|
<div class="window-titlebar-menu-bar is-hidden">
|
||||||
|
<button class="window-titlebar-menu-button" type="button">File</button>
|
||||||
|
<button class="window-titlebar-menu-button" type="button">Edit</button>
|
||||||
|
<button class="window-titlebar-menu-button" type="button">View</button>
|
||||||
|
<button class="window-titlebar-menu-button" type="button">Blog</button>
|
||||||
|
<button class="window-titlebar-menu-button" type="button">Help</button>
|
||||||
|
</div>
|
||||||
|
<div class="window-titlebar-drag-region"></div>
|
||||||
|
<div class="window-titlebar-title" data-testid="window-title"><%= @page_title %></div>
|
||||||
|
<div class="window-titlebar-actions">
|
||||||
|
<button
|
||||||
|
class="window-titlebar-action-button"
|
||||||
|
data-testid="toggle-sidebar"
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_sidebar"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
title="Toggle sidebar"
|
||||||
|
>
|
||||||
|
<span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
|
||||||
|
<span class="window-titlebar-sidebar-pane"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="window-titlebar-action-button"
|
||||||
|
data-testid="toggle-panel"
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_panel"
|
||||||
|
aria-label="Toggle panel"
|
||||||
|
title="Toggle panel"
|
||||||
|
>
|
||||||
|
<span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
|
||||||
|
<span class="window-titlebar-panel-pane"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="window-titlebar-action-button"
|
||||||
|
data-testid="toggle-assistant"
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_assistant_sidebar"
|
||||||
|
aria-label="Toggle assistant"
|
||||||
|
title="Toggle assistant"
|
||||||
|
>
|
||||||
|
<span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
|
||||||
|
<span class="window-titlebar-assistant-pane"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-main">
|
||||||
|
<aside class="activity-bar" data-region="activity-bar">
|
||||||
|
<div class="activity-bar-top">
|
||||||
|
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
|
||||||
|
<button
|
||||||
|
class={["activity-bar-item", if(button.active, do: "active")]}
|
||||||
|
data-testid="activity-button"
|
||||||
|
data-view={button.id}
|
||||||
|
data-active={to_string(button.active)}
|
||||||
|
type="button"
|
||||||
|
phx-click="select_view"
|
||||||
|
phx-value-view={button.id}
|
||||||
|
title={activity_label(button.label)}
|
||||||
|
aria-label={activity_label(button.label)}
|
||||||
|
>
|
||||||
|
<%= raw(ShellData.activity_icon(button.id)) %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="activity-bar-bottom">
|
||||||
|
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
|
||||||
|
<button
|
||||||
|
class={["activity-bar-item", if(button.active, do: "active")]}
|
||||||
|
data-testid="activity-button"
|
||||||
|
data-view={button.id}
|
||||||
|
data-active={to_string(button.active)}
|
||||||
|
type="button"
|
||||||
|
phx-click="select_view"
|
||||||
|
phx-value-view={button.id}
|
||||||
|
title={activity_label(button.label)}
|
||||||
|
aria-label={activity_label(button.label)}
|
||||||
|
>
|
||||||
|
<%= raw(ShellData.activity_icon(button.id)) %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class={["sidebar-shell", if(not @workbench.sidebar_visible, do: "is-hidden")]}
|
||||||
|
data-testid="sidebar-shell"
|
||||||
|
style={"width: #{@workbench.sidebar_width}px;"}
|
||||||
|
>
|
||||||
|
<div class="sidebar" data-region="sidebar">
|
||||||
|
<div class="sidebar-content sidebar-body">
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-header">
|
||||||
|
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= render_sidebar_body(assigns) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="app-content" data-region="content">
|
||||||
|
<div class="tab-bar" data-region="tab-bar"></div>
|
||||||
|
<section class="editor-shell" data-region="editor">
|
||||||
|
<div class="editor-empty">
|
||||||
|
<div class="dashboard-content">
|
||||||
|
<h1 data-testid="editor-title"><%= translated("dashboard.title") %></h1>
|
||||||
|
<p class="text-muted"><%= translated("dashboard.subtitle") %></p>
|
||||||
|
|
||||||
|
<div class="dashboard-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number"><%= @dashboard.post_stats.total_posts || 0 %></div>
|
||||||
|
<div class="stat-label"><%= translated("dashboard.stats.totalPosts") %></div>
|
||||||
|
<div class="stat-breakdown">
|
||||||
|
<span class="stat-tag stat-published"><%= translated("dashboard.stats.published", %{count: @dashboard.post_stats.published_count || 0}) %></span>
|
||||||
|
<span class="stat-tag stat-draft"><%= translated("dashboard.stats.drafts", %{count: @dashboard.post_stats.draft_count || 0}) %></span>
|
||||||
|
<%= if (@dashboard.post_stats.archived_count || 0) > 0 do %>
|
||||||
|
<span class="stat-tag stat-archived"><%= translated("dashboard.stats.archived", %{count: @dashboard.post_stats.archived_count || 0}) %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number"><%= @dashboard.media_stats.media_count || 0 %></div>
|
||||||
|
<div class="stat-label"><%= translated("dashboard.stats.mediaFiles") %></div>
|
||||||
|
<div class="stat-breakdown">
|
||||||
|
<span class="stat-tag"><%= translated("dashboard.stats.images", %{count: @dashboard.media_stats.image_count || 0}) %></span>
|
||||||
|
<span class="stat-tag"><%= ShellData.format_bytes(@dashboard.media_stats.total_bytes || 0) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number"><%= length(@dashboard.tag_cloud_items || []) %></div>
|
||||||
|
<div class="stat-label"><%= translated("dashboard.stats.tags") %></div>
|
||||||
|
<div class="stat-breakdown">
|
||||||
|
<span class="stat-tag"><%= translated("dashboard.stats.categories", %{count: length(@dashboard.category_counts || [])}) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if Enum.any?(@dashboard.timeline_entries || []) do %>
|
||||||
|
<div class="dashboard-section">
|
||||||
|
<h4><%= translated("dashboard.section.postsOverTime") %></h4>
|
||||||
|
<div class="timeline-chart">
|
||||||
|
<%= for entry <- @dashboard.timeline_entries || [] do %>
|
||||||
|
<div class="timeline-bar-container">
|
||||||
|
<div class="timeline-bar" style={"height: #{timeline_height(entry, @dashboard.timeline_entries || [])}%"}>
|
||||||
|
<span class="timeline-bar-count"><%= entry.count || 0 %></span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-bar-label">
|
||||||
|
<span class="timeline-bar-label-month"><%= ShellData.format_dashboard_month(entry.year, entry.month) %></span>
|
||||||
|
<span class="timeline-bar-label-year"><%= entry.year %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if Enum.any?(@dashboard_tag_cloud_items) do %>
|
||||||
|
<div class="dashboard-section">
|
||||||
|
<h4><%= translated("dashboard.section.tags") %></h4>
|
||||||
|
<div class="tag-cloud">
|
||||||
|
<%= for item <- @dashboard_tag_cloud_items do %>
|
||||||
|
<span class={["dashboard-tag", if(item.color, do: "has-color")]} style={ShellData.render_dashboard_tag_style(item)} title={ShellData.dashboard_post_count_label(item.count)}><%= item.tag %></span>
|
||||||
|
<% end %>
|
||||||
|
<%= if length(@dashboard.tag_cloud_items || []) > 40 do %>
|
||||||
|
<span class="text-muted tag-cloud-more"><%= translated("dashboard.tagCloud.more", %{count: length(@dashboard.tag_cloud_items) - 40}) %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if Enum.any?(@dashboard.category_counts || []) do %>
|
||||||
|
<div class="dashboard-section">
|
||||||
|
<h4><%= translated("dashboard.section.categories") %></h4>
|
||||||
|
<div class="tag-cloud">
|
||||||
|
<%= for category <- @dashboard.category_counts || [] do %>
|
||||||
|
<span class="dashboard-tag dashboard-category" title={ShellData.dashboard_post_count_label(category.count || 0)}>
|
||||||
|
<%= category.category || "" %>
|
||||||
|
<span class="tag-count"><%= category.count || 0 %></span>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if Enum.any?(@dashboard.recent_posts || []) do %>
|
||||||
|
<div class="dashboard-section">
|
||||||
|
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4>
|
||||||
|
<div class="recent-posts-list">
|
||||||
|
<%= for post <- @dashboard.recent_posts || [] do %>
|
||||||
|
<button class="recent-post-item" type="button">
|
||||||
|
<span class="recent-post-title"><%= post.title || "" %></span>
|
||||||
|
<span class={"recent-post-status status-#{post.status || "draft"}"}><%= ShellData.dashboard_status_label(post.status || "draft") %></span>
|
||||||
|
<span class="recent-post-date"><%= ShellData.format_dashboard_date(post.updated_at) %></span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="dashboard-inspector-meta" hidden>
|
||||||
|
<%= for item <- @editor_meta do %>
|
||||||
|
<section class="editor-meta-row">
|
||||||
|
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
|
||||||
|
<span><%= translated(item.value) %></span>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-tabs">
|
||||||
|
<%= for tab <- @panel_tabs do %>
|
||||||
|
<button
|
||||||
|
class={["panel-tab", if(@workbench.panel.active_tab == tab, do: "active")]}
|
||||||
|
type="button"
|
||||||
|
phx-click="select_panel_tab"
|
||||||
|
phx-value-tab={tab}
|
||||||
|
>
|
||||||
|
<%= panel_tab_label(tab) %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<%= render_panel_body(assigns) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class={["assistant-sidebar-shell", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
|
||||||
|
data-testid="assistant-shell"
|
||||||
|
style={"width: #{@workbench.assistant_sidebar_width}px;"}
|
||||||
|
>
|
||||||
|
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
|
||||||
|
<aside class="assistant-sidebar" data-region="assistant-sidebar">
|
||||||
|
<div class="assistant-header">
|
||||||
|
<strong><%= translated("Assistant") %></strong>
|
||||||
|
</div>
|
||||||
|
<div class="assistant-content">
|
||||||
|
<%= for card <- @assistant_cards do %>
|
||||||
|
<section class="assistant-card">
|
||||||
|
<strong><%= translated(card.label) %></strong>
|
||||||
|
<span><%= translated(card.text) %></span>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="status-bar" data-region="status-bar">
|
||||||
|
<div class="status-bar-left">
|
||||||
|
<div class="project-selector">
|
||||||
|
<button class="project-selector-trigger" type="button" title={translated("Switch project")}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
|
||||||
|
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="project-name"><%= @current_project && @current_project.name || "My Blog" %></span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" class="dropdown-arrow">
|
||||||
|
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="status-bar-item status-bar-task-button" type="button" phx-click="toggle_panel">
|
||||||
|
<span><%= @status.left.running_task_message || translated("Idle") %></span>
|
||||||
|
<%= if (@status.left.running_task_overflow || 0) > 0 do %>
|
||||||
|
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
|
||||||
|
<% end %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar-right">
|
||||||
|
<span class="status-bar-item"><%= @status.right.post_count %></span>
|
||||||
|
<span class="status-bar-item"><%= @status.right.media_count %></span>
|
||||||
|
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></span>
|
||||||
|
<button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} type="button" title={translated("Toggle offline mode")}>✈</button>
|
||||||
|
<label class="status-bar-item language-badge">
|
||||||
|
<span><%= translated("UI") %></span>
|
||||||
|
<select class="status-bar-language-select">
|
||||||
|
<%= for language <- @supported_ui_languages do %>
|
||||||
|
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="status-bar-item brand"><%= @status.right.brand %></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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 %>
|
||||||
|
<section class="sidebar-section">
|
||||||
|
<div class="sidebar-section-title">
|
||||||
|
<span class={"section-icon status-#{section.status || "draft"}"}>●</span>
|
||||||
|
<span data-testid="sidebar-section-title"><%= translated(section.title) %></span>
|
||||||
|
<span class="sidebar-section-count"><%= section.count || length(section.items || []) %></span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-list">
|
||||||
|
<%= for item <- section.items || [] do %>
|
||||||
|
<button class="sidebar-item sidebar-post-item post-type-post" type="button">
|
||||||
|
<span class="post-type-icon" title="post">●</span>
|
||||||
|
<span class="sidebar-item-content">
|
||||||
|
<span class="sidebar-item-title-row">
|
||||||
|
<span class="sidebar-item-title"><%= item.title || "" %></span>
|
||||||
|
</span>
|
||||||
|
<span class="sidebar-item-meta"><%= format_sidebar_timestamp(item.meta_timestamp) %></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
<%= if Enum.empty?(@sidebar_data.sections || []) do %>
|
||||||
|
<div class="sidebar-empty">
|
||||||
|
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_media_sidebar(assigns) do
|
||||||
|
~H"""
|
||||||
|
<%= if Enum.any?(@sidebar_data.items || []) do %>
|
||||||
|
<div class="sidebar-list media-grid">
|
||||||
|
<%= for item <- @sidebar_data.items || [] do %>
|
||||||
|
<button class="media-item" type="button" title={item.title || ""}>
|
||||||
|
<span class={media_thumbnail_class(item)}>
|
||||||
|
<%= if image_media?(item) do %>
|
||||||
|
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
|
||||||
|
<img class="media-thumbnail-image" src={"/media-thumbnail/#{item.id}"} alt="" loading="lazy" decoding="async" />
|
||||||
|
<% else %>
|
||||||
|
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
<span class="media-item-info">
|
||||||
|
<span class="media-item-name"><%= item.title || "" %></span>
|
||||||
|
<span class="media-item-size"><%= item.meta || "" %></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="sidebar-empty">
|
||||||
|
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_entity_sidebar(assigns) do
|
||||||
|
~H"""
|
||||||
|
<%= if Enum.any?(@sidebar_data.items || []) do %>
|
||||||
|
<div class="settings-nav-list">
|
||||||
|
<%= for item <- @sidebar_data.items || [] do %>
|
||||||
|
<button class="chat-list-item" type="button">
|
||||||
|
<span class="chat-item-content">
|
||||||
|
<span class="chat-item-title"><%= item.title || "" %></span>
|
||||||
|
<span class="chat-item-date"><%= translated(item.meta || "") %></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="sidebar-empty">
|
||||||
|
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_nav_sidebar(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="settings-nav-list">
|
||||||
|
<%= for item <- @sidebar_data.items || [] do %>
|
||||||
|
<button class="settings-nav-entry" type="button">
|
||||||
|
<span class="settings-nav-entry-icon"><%= item.icon || "" %></span>
|
||||||
|
<span><%= translated(item.title || "") %></span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_default_sidebar(assigns) do
|
||||||
|
~H"""
|
||||||
|
<%= for section <- @sidebar_data.sections || [] do %>
|
||||||
|
<section class="sidebar-section">
|
||||||
|
<div class="sidebar-section-header">
|
||||||
|
<span data-testid="sidebar-section-title"><%= translated(section.title) %></span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-section-items">
|
||||||
|
<%= for item <- section.items || [] do %>
|
||||||
|
<div class="sidebar-list-item"><%= item.title || "" %></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% 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 %>
|
||||||
|
<div class="panel-entry panel-empty-state">
|
||||||
|
<strong><%= translated("Tasks") %></strong>
|
||||||
|
<span><%= translated("No background tasks running") %></span>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="task-list">
|
||||||
|
<%= for task <- @task_status.tasks || [] do %>
|
||||||
|
<div class="panel-entry task-entry">
|
||||||
|
<div class="task-entry-header">
|
||||||
|
<strong><%= task.name %></strong>
|
||||||
|
<span class={"task-status task-status-#{task.status}"}><%= task.status |> to_string() |> String.capitalize() %></span>
|
||||||
|
</div>
|
||||||
|
<span><%= task.message || task.group_name || "" %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_output_entries(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="panel-entry panel-empty-state output-list">
|
||||||
|
<strong><%= translated("Output") %></strong>
|
||||||
|
<span><%= translated("No shell output yet") %></span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_git_log(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="git-log-list">
|
||||||
|
<div class="panel-entry">
|
||||||
|
<strong><%= translated("Git Log") %></strong>
|
||||||
|
<span><%= translated("Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.") %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_generic_panel(assigns, tab) do
|
||||||
|
assigns = assign(assigns, :panel_label, ShellData.route_label(tab))
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="panel-entry">
|
||||||
|
<strong><%= @panel_label %></strong>
|
||||||
|
<span><%= translated("The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.") %></span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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
|
||||||
@@ -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"
|
|
||||||
|
|
||||||
[
|
|
||||||
"<!DOCTYPE html>",
|
|
||||||
"<html lang=\"#{ui_language}\">",
|
|
||||||
"<head>",
|
|
||||||
" <meta charset=\"utf-8\">",
|
|
||||||
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
|
|
||||||
" <title>Blogging Desktop Server</title>",
|
|
||||||
" <link rel=\"stylesheet\" href=\"/assets/app.css\">",
|
|
||||||
"</head>",
|
|
||||||
"<body>",
|
|
||||||
" <div class=\"app\" id=\"bds-shell-app\">",
|
|
||||||
" <div class=\"window-titlebar\" data-region=\"title-bar\"></div>",
|
|
||||||
" <div class=\"app-main\">",
|
|
||||||
" <aside class=\"activity-bar\" data-region=\"activity-bar\"></aside>",
|
|
||||||
" <section class=\"sidebar-shell\" data-testid=\"sidebar-shell\">",
|
|
||||||
" <div class=\"sidebar\" data-region=\"sidebar\"></div>",
|
|
||||||
" <div class=\"resizable-panel-divider sidebar-divider\" data-resize=\"sidebar\" data-role=\"resize-handle\"></div>",
|
|
||||||
" </section>",
|
|
||||||
" <main class=\"app-content\" data-region=\"content\">",
|
|
||||||
" <div class=\"tab-bar\" data-region=\"tab-bar\"></div>",
|
|
||||||
" <section class=\"editor-shell\" data-region=\"editor\"></section>",
|
|
||||||
" <section class=\"panel-shell\" data-region=\"panel\"></section>",
|
|
||||||
" </main>",
|
|
||||||
" <section class=\"assistant-sidebar-shell\" data-testid=\"assistant-shell\">",
|
|
||||||
" <div class=\"resizable-panel-divider assistant-divider\" data-resize=\"assistant\" data-role=\"resize-handle\"></div>",
|
|
||||||
" <aside class=\"assistant-sidebar\" data-region=\"assistant-sidebar\"></aside>",
|
|
||||||
" </section>",
|
|
||||||
" </div>",
|
|
||||||
" <footer class=\"status-bar\" data-region=\"status-bar\"></footer>",
|
|
||||||
" </div>",
|
|
||||||
" <script id=\"bds-shell-bootstrap\" type=\"application/json\">#{Jason.encode!(bootstrap)}</script>",
|
|
||||||
" <script src=\"/assets/app.js\"></script>",
|
|
||||||
"</body>",
|
|
||||||
"</html>"
|
|
||||||
]
|
|
||||||
|> 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
|
|
||||||
1
mix.exs
1
mix.exs
@@ -35,6 +35,7 @@ defmodule BDS.MixProject do
|
|||||||
{:desktop, "~> 1.5"},
|
{:desktop, "~> 1.5"},
|
||||||
{:image, "~> 0.65"},
|
{:image, "~> 0.65"},
|
||||||
{:stemex, "~> 0.2.1"},
|
{:stemex, "~> 0.2.1"},
|
||||||
|
{:lazy_html, ">= 0.1.0", only: :test},
|
||||||
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
|
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|||||||
2
mix.lock
2
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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"},
|
||||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
|
|||||||
695
priv/ui/app.css
695
priv/ui/app.css
@@ -1039,6 +1039,701 @@ button {
|
|||||||
padding: 14px 14px 0;
|
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 {
|
.sidebar-section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
2607
priv/ui/app.js
2607
priv/ui/app.js
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Blogging Desktop Server</title>
|
|
||||||
<link rel="stylesheet" href="./app.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app" id="bds-shell-app">
|
|
||||||
<div class="window-titlebar" data-region="title-bar"></div>
|
|
||||||
<div class="app-main">
|
|
||||||
<aside class="activity-bar" data-region="activity-bar"></aside>
|
|
||||||
<section class="sidebar-shell" data-testid="sidebar-shell">
|
|
||||||
<div class="sidebar" data-region="sidebar"></div>
|
|
||||||
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
|
|
||||||
</section>
|
|
||||||
<main class="app-content" data-region="content">
|
|
||||||
<div class="tab-bar" data-region="tab-bar"></div>
|
|
||||||
<section class="editor-shell" data-region="editor"></section>
|
|
||||||
<section class="panel-shell" data-region="panel"></section>
|
|
||||||
</main>
|
|
||||||
<section class="assistant-sidebar-shell" data-testid="assistant-shell">
|
|
||||||
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
|
|
||||||
<aside class="assistant-sidebar" data-region="assistant-sidebar"></aside>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<footer class="status-bar" data-region="status-bar"></footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script id="bds-shell-bootstrap" type="application/json">
|
|
||||||
{
|
|
||||||
"title": "Blogging Desktop Server",
|
|
||||||
"registry": {
|
|
||||||
"default_sidebar_view": "posts",
|
|
||||||
"sidebar_views": [
|
|
||||||
{ "id": "posts", "label": "Posts", "activity_group": "top", "editor_route": "post", "entity_tab": true, "singleton": false },
|
|
||||||
{ "id": "media", "label": "Media", "activity_group": "top", "editor_route": "media", "entity_tab": true, "singleton": false },
|
|
||||||
{ "id": "settings", "label": "Settings", "activity_group": "bottom", "editor_route": "settings", "entity_tab": false, "singleton": true }
|
|
||||||
],
|
|
||||||
"editor_routes": [
|
|
||||||
{ "id": "dashboard", "title": "Dashboard", "singleton": true, "entity_tab": false },
|
|
||||||
{ "id": "post", "title": "Post", "singleton": false, "entity_tab": true },
|
|
||||||
{ "id": "media", "title": "Media", "singleton": false, "entity_tab": true },
|
|
||||||
{ "id": "settings", "title": "Settings", "singleton": true, "entity_tab": false }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"menu_groups": [
|
|
||||||
{ "id": "app", "label": "App", "items": [{ "id": "about", "label": "About" }] },
|
|
||||||
{ "id": "view", "label": "View", "items": [{ "id": "toggle_sidebar", "label": "Toggle Sidebar" }] }
|
|
||||||
],
|
|
||||||
"session": {
|
|
||||||
"sidebar_visible": true,
|
|
||||||
"sidebar_width": 280,
|
|
||||||
"active_view": "posts",
|
|
||||||
"layout": "post_list",
|
|
||||||
"sections": [
|
|
||||||
"assistant_sidebar_width": 360,
|
|
||||||
"status": "draft",
|
|
||||||
"count": 1,
|
|
||||||
"panel": { "visible": false, "active_tab": "tasks" },
|
|
||||||
"tabs": [],
|
|
||||||
{ "id": "post-welcome", "title": "Welcome to bDS2", "meta_timestamp": 1774972800000, "language_count": 1, "categories": ["note"], "route": "post" }
|
|
||||||
"dirty_tabs": []
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"sidebar": {
|
|
||||||
"posts": {
|
|
||||||
"title": "Posts",
|
|
||||||
"subtitle": "Drafts and publishing",
|
|
||||||
"layout": "post_list",
|
|
||||||
"sections": []
|
|
||||||
},
|
|
||||||
"media": {
|
|
||||||
"title": "Media",
|
|
||||||
"subtitle": "Images and files",
|
|
||||||
"layout": "media_grid",
|
|
||||||
"items": [
|
|
||||||
{ "id": "media-hero", "title": "hero.jpg", "meta": "1.2 MB", "mime_type": "image/jpeg", "route": "media" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"title": "Scripts",
|
|
||||||
"subtitle": "Automation helpers",
|
|
||||||
"layout": "entity_list",
|
|
||||||
"items": [
|
|
||||||
{ "id": "script-sync", "title": "Sync tags", "updated_at": 1774800000000, "route": "scripts" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"templates": {
|
|
||||||
"title": "Templates",
|
|
||||||
"subtitle": "Site rendering",
|
|
||||||
"layout": "entity_list",
|
|
||||||
"items": [
|
|
||||||
{ "id": "template-post", "title": "post.liquid", "updated_at": 1774713600000, "route": "templates" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tags": {
|
|
||||||
"title": "Tags",
|
|
||||||
"subtitle": "Tag management",
|
|
||||||
"layout": "nav_list",
|
|
||||||
"items": [
|
|
||||||
{ "id": "tags-cloud", "title": "Tag Cloud", "icon": "☁️", "route": "tags" },
|
|
||||||
{ "id": "tags-manage", "title": "Create / Edit", "icon": "✏️", "route": "tags" },
|
|
||||||
{ "id": "tags-merge", "title": "Merge Tags", "icon": "🔀", "route": "tags" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"title": "Chat",
|
|
||||||
"subtitle": "AI conversations",
|
|
||||||
"layout": "entity_list",
|
|
||||||
"items": [
|
|
||||||
{ "id": "chat-planning", "title": "Planning session", "updated_at": 1774886400000, "route": "chat" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"import": {
|
|
||||||
"title": "Import",
|
|
||||||
"subtitle": "Import definitions",
|
|
||||||
"layout": "entity_list",
|
|
||||||
"items": []
|
|
||||||
},
|
|
||||||
"git": {
|
|
||||||
"title": "Git",
|
|
||||||
"subtitle": "Working tree and history",
|
|
||||||
"layout": "entity_list",
|
|
||||||
"items": [
|
|
||||||
{ "id": "git-working-tree", "title": "Working tree", "meta": "Working tree and history", "route": "git_diff" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"title": "Settings",
|
|
||||||
"subtitle": "Project preferences",
|
|
||||||
"layout": "nav_list",
|
|
||||||
"items": [
|
|
||||||
{ "id": "settings-project", "title": "Project", "icon": "📁", "route": "settings" },
|
|
||||||
{ "id": "settings-style", "title": "Style", "icon": "🎨", "route": "style" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
12
priv/ui/live.js
Normal file
12
priv/ui/live.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const csrfToken = document
|
||||||
|
.querySelector("meta[name='csrf-token']")
|
||||||
|
.getAttribute("content");
|
||||||
|
|
||||||
|
const liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {
|
||||||
|
params: { _csrf_token: csrfToken }
|
||||||
|
});
|
||||||
|
|
||||||
|
liveSocket.connect();
|
||||||
|
window.liveSocket = liveSocket;
|
||||||
|
});
|
||||||
42
test/bds/desktop/shell_live_test.exs
Normal file
42
test/bds/desktop/shell_live_test.exs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLiveTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
import Phoenix.ConnTest
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
@endpoint BDS.Desktop.Endpoint
|
||||||
|
|
||||||
|
test "shell live owns pane visibility and activity selection on the server" do
|
||||||
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
assert html =~ ~s(data-testid="sidebar-shell")
|
||||||
|
assert html =~ ~s(class="panel-shell is-hidden")
|
||||||
|
assert html =~ ~s(data-testid="activity-button")
|
||||||
|
assert html =~ ~s(data-view="posts")
|
||||||
|
assert html =~ ~s(data-view="media")
|
||||||
|
assert html =~ ~s(aria-label="Posts")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='toggle-sidebar']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ ~s(class="sidebar-shell is-hidden")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='toggle-panel']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ ~s(data-region="panel")
|
||||||
|
refute html =~ ~s(class="panel-shell is-hidden")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='activity-button'][data-view='media']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ ~s(aria-label="Media")
|
||||||
|
assert html =~ ~s(data-view="media")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,10 +3,6 @@ defmodule BDS.DesktopTest do
|
|||||||
|
|
||||||
import Plug.Test
|
import Plug.Test
|
||||||
|
|
||||||
defmodule TestFolderPicker do
|
|
||||||
def choose_directory(_prompt), do: Process.get(:test_folder_picker_response, :cancel)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "desktop configuration no longer uses a pending adapter" do
|
test "desktop configuration no longer uses a pending adapter" do
|
||||||
assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
|
assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
|
||||||
end
|
end
|
||||||
@@ -99,220 +95,39 @@ defmodule BDS.DesktopTest do
|
|||||||
assert menu_item(groups, :metadata_diff).shortcut == nil
|
assert menu_item(groups, :metadata_diff).shortcut == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "desktop shell html follows the old app frame regions and references bundled assets" do
|
test "desktop root html is a LiveView shell and references only the live bootstrap assets" do
|
||||||
html = BDS.Desktop.ShellController.index_html()
|
|
||||||
|
|
||||||
assert html =~ ~s(class="app")
|
|
||||||
assert html =~ ~s(class="window-titlebar")
|
|
||||||
assert html =~ ~s(class="activity-bar")
|
|
||||||
assert html =~ ~s(class="sidebar")
|
|
||||||
assert html =~ ~s(class="tab-bar")
|
|
||||||
assert html =~ ~s(class="status-bar")
|
|
||||||
assert html =~ ~s(src="/assets/app.js")
|
|
||||||
assert html =~ ~s(href="/assets/app.css")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "desktop router serves the shell without requiring Phoenix endpoint secrets" do
|
|
||||||
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
|
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
|
||||||
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
|
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
assert conn.resp_body =~ ~s(class="app")
|
assert conn.resp_body =~ ~s(class="app")
|
||||||
|
assert conn.resp_body =~ ~s(class="window-titlebar")
|
||||||
|
assert conn.resp_body =~ ~s(class="activity-bar")
|
||||||
|
assert conn.resp_body =~ ~s(class="sidebar")
|
||||||
|
assert conn.resp_body =~ ~s(class="status-bar")
|
||||||
|
assert conn.resp_body =~ ~s(data-phx-main)
|
||||||
|
assert conn.resp_body =~ ~s(src="/assets/live.js")
|
||||||
|
assert conn.resp_body =~ ~s(href="/assets/app.css")
|
||||||
|
refute conn.resp_body =~ ~s(src="/assets/app.js")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "desktop router exposes live task status for shell polling" do
|
test "desktop endpoint serves the live shell without extra router-side secret injection" do
|
||||||
:ok = BDS.Tasks.clear_finished()
|
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
|
||||||
|
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
|
||||||
assert {:ok, task} =
|
|
||||||
BDS.Tasks.register_external_task("preview build", %{
|
|
||||||
group_id: "generation",
|
|
||||||
group_name: "Generation"
|
|
||||||
})
|
|
||||||
|
|
||||||
on_exit(fn ->
|
|
||||||
_ = BDS.Tasks.complete_task(task.id)
|
|
||||||
_ = BDS.Tasks.clear_finished()
|
|
||||||
end)
|
|
||||||
|
|
||||||
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "halfway")
|
|
||||||
|
|
||||||
conn = conn(:get, "/api/tasks?k=#{Desktop.Auth.login_key()}")
|
|
||||||
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
|
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
assert Plug.Conn.get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"]
|
assert conn.resp_body =~ ~s(data-phx-main)
|
||||||
|
|
||||||
payload = Jason.decode!(conn.resp_body)
|
|
||||||
|
|
||||||
assert payload["active_count"] >= 1
|
|
||||||
assert payload["running_task_message"] == "preview build: halfway"
|
|
||||||
|
|
||||||
assert Enum.any?(payload["tasks"], fn item ->
|
|
||||||
item["id"] == task.id and item["group_name"] == "Generation" and item["progress"] == 0.5
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "desktop router encodes failed task snapshots even when the task error is a tuple" do
|
test "desktop endpoint exposes a simple health route" do
|
||||||
:ok = BDS.Tasks.clear_finished()
|
conn = conn(:get, "/health?k=#{Desktop.Auth.login_key()}")
|
||||||
|
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
|
||||||
assert {:ok, task} =
|
|
||||||
BDS.Tasks.submit_task(
|
|
||||||
"broken rebuild",
|
|
||||||
fn _report ->
|
|
||||||
{:error, {{:badkey, "slug"}, [{BDS.Posts, :upsert_post_from_file, 3, [line: 644]}]}}
|
|
||||||
end,
|
|
||||||
%{group_id: "maintenance", group_name: "Maintenance"}
|
|
||||||
)
|
|
||||||
|
|
||||||
on_exit(fn ->
|
|
||||||
_ = BDS.Tasks.clear_finished()
|
|
||||||
end)
|
|
||||||
|
|
||||||
failed = wait_for_task(task.id, &(&1.status == :failed and &1.error != nil))
|
|
||||||
|
|
||||||
conn = conn(:get, "/api/tasks?k=#{Desktop.Auth.login_key()}")
|
|
||||||
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
|
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
payload = Jason.decode!(conn.resp_body)
|
assert conn.resp_body == "ok"
|
||||||
|
|
||||||
assert Enum.any?(payload["tasks"], fn item ->
|
|
||||||
item["id"] == failed.id and item["status"] == "failed" and is_binary(item["error"])
|
|
||||||
end)
|
|
||||||
|
|
||||||
assert Enum.any?(payload["tasks"], fn item ->
|
|
||||||
item["id"] == failed.id and String.contains?(item["error"], "badkey")
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "desktop router exposes projects for shell project selection and creation" do
|
test "desktop endpoint serves active-project media thumbnails for the live sidebar" do
|
||||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
|
||||||
BDS.Repo.delete_all(BDS.Projects.Project)
|
|
||||||
|
|
||||||
internal_projects_root = "/Users/gb/Projects/bDS2/priv/data/projects"
|
|
||||||
before_internal_dirs =
|
|
||||||
case File.ls(internal_projects_root) do
|
|
||||||
{:ok, entries} -> MapSet.new(entries)
|
|
||||||
{:error, :enoent} -> MapSet.new()
|
|
||||||
end
|
|
||||||
|
|
||||||
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-projects-#{System.unique_integer([:positive])}")
|
|
||||||
File.mkdir_p!(temp_dir)
|
|
||||||
|
|
||||||
on_exit(fn ->
|
|
||||||
File.rm_rf(temp_dir)
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:ok, project} = BDS.Projects.create_project(%{name: "Desktop Projects", data_path: temp_dir})
|
|
||||||
{:ok, _active} = BDS.Projects.set_active_project(project.id)
|
|
||||||
|
|
||||||
conn = conn(:get, "/api/projects?k=#{Desktop.Auth.login_key()}")
|
|
||||||
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
|
|
||||||
payload = Jason.decode!(conn.resp_body)
|
|
||||||
assert payload["active_project_id"] == project.id
|
|
||||||
assert Enum.any?(payload["projects"], &(&1["id"] == project.id and &1["name"] == "Desktop Projects"))
|
|
||||||
assert Enum.any?(payload["projects"], &(&1["id"] == "default" and &1["name"] == "My Blog"))
|
|
||||||
|
|
||||||
created_data_dir = Path.join(temp_dir, "created-from-shell")
|
|
||||||
create_conn =
|
|
||||||
conn(
|
|
||||||
:post,
|
|
||||||
"/api/projects?k=#{Desktop.Auth.login_key()}",
|
|
||||||
Jason.encode!(%{"name" => "Created From Shell", "data_path" => created_data_dir})
|
|
||||||
)
|
|
||||||
|> Plug.Conn.put_req_header("content-type", "application/json")
|
|
||||||
|
|
||||||
create_conn = BDS.Desktop.Router.call(create_conn, BDS.Desktop.Router.init([]))
|
|
||||||
|
|
||||||
assert create_conn.status == 200
|
|
||||||
|
|
||||||
created_payload = Jason.decode!(create_conn.resp_body)
|
|
||||||
assert created_payload["project"]["name"] == "Created From Shell"
|
|
||||||
assert created_payload["active_project_id"] == created_payload["project"]["id"]
|
|
||||||
assert created_payload["project"]["data_path"] == created_data_dir
|
|
||||||
|
|
||||||
after_internal_dirs =
|
|
||||||
case File.ls(internal_projects_root) do
|
|
||||||
{:ok, entries} -> MapSet.new(entries)
|
|
||||||
{:error, :enoent} -> MapSet.new()
|
|
||||||
end
|
|
||||||
|
|
||||||
assert after_internal_dirs == before_internal_dirs
|
|
||||||
end
|
|
||||||
|
|
||||||
test "desktop router lets the shell choose an existing project folder and reuses matching projects" do
|
|
||||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
|
||||||
|
|
||||||
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-existing-project-#{System.unique_integer([:positive])}")
|
|
||||||
meta_dir = Path.join(temp_dir, "meta")
|
|
||||||
File.mkdir_p!(meta_dir)
|
|
||||||
|
|
||||||
File.write!(
|
|
||||||
Path.join(meta_dir, "project.json"),
|
|
||||||
Jason.encode!(%{"name" => "Existing Blog", "description" => "Imported from disk"})
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, project} = BDS.Projects.create_project(%{name: "Existing Blog", data_path: temp_dir})
|
|
||||||
|
|
||||||
previous_desktop = Application.get_env(:bds, :desktop, [])
|
|
||||||
Application.put_env(:bds, :desktop, Keyword.put(previous_desktop, :folder_picker, TestFolderPicker))
|
|
||||||
Process.put(:test_folder_picker_response, {:ok, temp_dir})
|
|
||||||
|
|
||||||
on_exit(fn ->
|
|
||||||
Application.put_env(:bds, :desktop, previous_desktop)
|
|
||||||
Process.delete(:test_folder_picker_response)
|
|
||||||
File.rm_rf(temp_dir)
|
|
||||||
end)
|
|
||||||
|
|
||||||
conn = conn(:post, "/api/project-folder?k=#{Desktop.Auth.login_key()}")
|
|
||||||
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
|
|
||||||
payload = Jason.decode!(conn.resp_body)
|
|
||||||
|
|
||||||
assert payload["status"] == "ok"
|
|
||||||
assert payload["path"] == temp_dir
|
|
||||||
assert payload["name"] == "Existing Blog"
|
|
||||||
assert payload["description"] == "Imported from disk"
|
|
||||||
assert payload["existing_project_id"] == project.id
|
|
||||||
end
|
|
||||||
|
|
||||||
test "desktop router executes shell commands through the JSON api" do
|
|
||||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
|
||||||
:ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Preview))
|
|
||||||
|
|
||||||
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-router-#{System.unique_integer([:positive])}")
|
|
||||||
File.mkdir_p!(temp_dir)
|
|
||||||
|
|
||||||
on_exit(fn ->
|
|
||||||
File.rm_rf(temp_dir)
|
|
||||||
_ = BDS.Preview.stop_preview("default")
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:ok, project} = BDS.Projects.create_project(%{name: "Desktop Router", data_path: temp_dir})
|
|
||||||
{:ok, _project} = BDS.Projects.set_active_project(project.id)
|
|
||||||
|
|
||||||
conn =
|
|
||||||
conn(:post, "/api/commands?k=#{Desktop.Auth.login_key()}", Jason.encode!(%{"action" => "open_in_browser"}))
|
|
||||||
|> Plug.Conn.put_req_header("content-type", "application/json")
|
|
||||||
|
|
||||||
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
assert Plug.Conn.get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"]
|
|
||||||
|
|
||||||
payload = Jason.decode!(conn.resp_body)
|
|
||||||
|
|
||||||
assert payload["result"]["kind"] == "open_url"
|
|
||||||
assert payload["result"]["project_id"] == project.id
|
|
||||||
assert payload["result"]["url"] == "http://127.0.0.1:4123/"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "desktop router serves active-project media thumbnails for the sidebar" do
|
|
||||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
|
|
||||||
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-thumbnail-#{System.unique_integer([:positive])}")
|
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-thumbnail-#{System.unique_integer([:positive])}")
|
||||||
@@ -330,8 +145,8 @@ defmodule BDS.DesktopTest do
|
|||||||
|
|
||||||
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||||
|
|
||||||
conn = conn(:get, "/api/media-thumbnail/#{media.id}?k=#{Desktop.Auth.login_key()}")
|
conn = conn(:get, "/media-thumbnail/#{media.id}?k=#{Desktop.Auth.login_key()}")
|
||||||
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
|
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
assert [content_type] = Plug.Conn.get_resp_header(conn, "content-type")
|
assert [content_type] = Plug.Conn.get_resp_header(conn, "content-type")
|
||||||
@@ -349,21 +164,4 @@ defmodule BDS.DesktopTest do
|
|||||||
Image.new!(3, 2, color: [255, 0, 0])
|
Image.new!(3, 2, color: [255, 0, 0])
|
||||||
|> Image.write!(:memory, suffix: ".jpg", quality: 85)
|
|> Image.write!(:memory, suffix: ".jpg", quality: 85)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp wait_for_task(task_id, matcher, timeout \\ 2_000)
|
|
||||||
|
|
||||||
defp wait_for_task(task_id, _matcher, timeout) when timeout <= 0 do
|
|
||||||
BDS.Tasks.get_task(task_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp wait_for_task(task_id, matcher, timeout) do
|
|
||||||
task = BDS.Tasks.get_task(task_id)
|
|
||||||
|
|
||||||
if task && matcher.(task) do
|
|
||||||
task
|
|
||||||
else
|
|
||||||
Process.sleep(50)
|
|
||||||
wait_for_task(task_id, matcher, timeout - 50)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ defmodule BDS.UI.ShellTest do
|
|||||||
alias BDS.UI.Commands
|
alias BDS.UI.Commands
|
||||||
alias BDS.UI.Registry
|
alias BDS.UI.Registry
|
||||||
alias BDS.UI.Session
|
alias BDS.UI.Session
|
||||||
alias BDS.UI.ShellPage
|
|
||||||
alias BDS.UI.Workbench
|
alias BDS.UI.Workbench
|
||||||
|
|
||||||
test "registry exposes the shared sidebar and editor contracts for the base shell" do
|
test "registry exposes the shared sidebar and editor contracts for the base shell" do
|
||||||
@@ -12,6 +11,7 @@ defmodule BDS.UI.ShellTest do
|
|||||||
editor_routes = Registry.editor_routes()
|
editor_routes = Registry.editor_routes()
|
||||||
|
|
||||||
assert Registry.default_sidebar_view() == :posts
|
assert Registry.default_sidebar_view() == :posts
|
||||||
|
|
||||||
assert Enum.map(sidebar_views, & &1.id) == [
|
assert Enum.map(sidebar_views, & &1.id) == [
|
||||||
:posts,
|
:posts,
|
||||||
:pages,
|
:pages,
|
||||||
@@ -52,6 +52,7 @@ defmodule BDS.UI.ShellTest do
|
|||||||
assert restored.active_view == :media
|
assert restored.active_view == :media
|
||||||
assert restored.active_tab == {:media, "media-1"}
|
assert restored.active_tab == {:media, "media-1"}
|
||||||
assert Workbench.dirty?(restored, :post, "post-1") == true
|
assert Workbench.dirty?(restored, :post, "post-1") == true
|
||||||
|
|
||||||
assert Enum.map(restored.tabs, &{&1.type, &1.id, &1.is_transient}) == [
|
assert Enum.map(restored.tabs, &{&1.type, &1.id, &1.is_transient}) == [
|
||||||
{:post, "post-1", false},
|
{:post, "post-1", false},
|
||||||
{:media, "media-1", true}
|
{:media, "media-1", true}
|
||||||
@@ -96,175 +97,23 @@ defmodule BDS.UI.ShellTest do
|
|||||||
assert Workbench.dirty?(state, :post, "post-1") == true
|
assert Workbench.dirty?(state, :post, "post-1") == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shell page renders the inspectable base app with bootstrap data and shell controls" do
|
test "desktop shell keeps the compact frame metrics and live bootstrap assets" do
|
||||||
html = ShellPage.render()
|
|
||||||
|
|
||||||
assert html =~ ~s(<div class="app" id="bds-shell-app")
|
|
||||||
assert html =~ ~s(data-region="activity-bar")
|
|
||||||
assert html =~ ~s(data-region="sidebar")
|
|
||||||
assert html =~ ~s(data-region="editor")
|
|
||||||
assert html =~ ~s(data-region="status-bar")
|
|
||||||
assert html =~ ~s(data-role="resize-handle")
|
|
||||||
assert html =~ ~s(id="bds-shell-bootstrap")
|
|
||||||
assert html =~ ~s(src="/assets/app.js")
|
|
||||||
assert html =~ ~s(href="/assets/app.css")
|
|
||||||
assert html =~ ~s("task_status")
|
|
||||||
assert html =~ ~s("flag":"🇩🇪")
|
|
||||||
assert html =~ ~s("projects")
|
|
||||||
assert html =~ ~s("id":"default")
|
|
||||||
assert html =~ ~s("name":"My Blog")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shell page localizes bootstrap content for german ui locale" do
|
|
||||||
previous_lang = System.get_env("LANG")
|
|
||||||
previous_lc_all = System.get_env("LC_ALL")
|
|
||||||
|
|
||||||
on_exit(fn ->
|
|
||||||
restore_env("LANG", previous_lang)
|
|
||||||
restore_env("LC_ALL", previous_lc_all)
|
|
||||||
end)
|
|
||||||
|
|
||||||
System.put_env("LANG", "de_DE.UTF-8")
|
|
||||||
System.delete_env("LC_ALL")
|
|
||||||
|
|
||||||
html = ShellPage.render()
|
|
||||||
|
|
||||||
assert html =~ ~s("ui_language":"de")
|
|
||||||
assert html =~ ~s("catalogs")
|
|
||||||
assert html =~ ~s("File":"Datei")
|
|
||||||
assert html =~ ~s("Dashboard":"Instrumententafel")
|
|
||||||
assert html =~ ~s("Assistant":"Assistent")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shell bootstrap and static bundle expose the old dashboard sections" do
|
|
||||||
html = ShellPage.render()
|
|
||||||
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
|
||||||
|
|
||||||
assert html =~ ~s("timeline_entries")
|
|
||||||
assert html =~ ~s("tag_cloud_items")
|
|
||||||
assert html =~ ~s("category_counts")
|
|
||||||
assert html =~ ~s("recent_posts")
|
|
||||||
|
|
||||||
assert js =~ "dashboard-content"
|
|
||||||
assert js =~ "dashboard-stats"
|
|
||||||
assert js =~ "timeline-chart"
|
|
||||||
assert js =~ "tag-cloud"
|
|
||||||
assert js =~ "recent-posts-list"
|
|
||||||
|
|
||||||
assert css =~ ".dashboard-content"
|
|
||||||
assert css =~ ".dashboard-stats"
|
|
||||||
assert css =~ ".timeline-chart"
|
|
||||||
assert css =~ ".tag-cloud"
|
|
||||||
assert css =~ ".recent-posts-list"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shell bootstrap and static bundle expose the old sidebar view contracts" do
|
|
||||||
html = ShellPage.render()
|
|
||||||
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert html =~ ~s("layout":"post_list")
|
|
||||||
assert html =~ ~s("layout":"media_grid")
|
|
||||||
assert html =~ ~s("layout":"entity_list")
|
|
||||||
assert html =~ ~s("layout":"nav_list")
|
|
||||||
|
|
||||||
assert js =~ "renderSidebarPostList"
|
|
||||||
assert js =~ "renderSidebarMediaGrid"
|
|
||||||
assert js =~ "renderSidebarEntityList"
|
|
||||||
assert js =~ "renderSidebarNavList"
|
|
||||||
|
|
||||||
assert css =~ ".sidebar-section-title"
|
|
||||||
assert css =~ ".media-grid"
|
|
||||||
assert css =~ ".chat-list-item"
|
|
||||||
assert css =~ ".settings-nav-entry"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shell bootstrap and static bundle expose old sidebar filters and the post cutoff contract" do
|
|
||||||
html = ShellPage.render()
|
|
||||||
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert html =~ ~s("filters")
|
|
||||||
assert html =~ ~s("search_placeholder":"sidebar.searchPostsPlaceholder")
|
|
||||||
assert html =~ ~s("search_placeholder":"sidebar.searchMediaPlaceholder")
|
|
||||||
assert html =~ ~s("year_month_counts")
|
|
||||||
assert html =~ ~s("available_tags")
|
|
||||||
assert html =~ ~s("available_categories")
|
|
||||||
assert html =~ ~s("max_items":500)
|
|
||||||
|
|
||||||
assert js =~ "renderSidebarSearchBox"
|
|
||||||
assert js =~ "renderSidebarArchiveFilter"
|
|
||||||
assert js =~ "renderSidebarFilterPanel"
|
|
||||||
assert js =~ "renderSidebarFilterStatus"
|
|
||||||
assert js =~ "applySidebarPostFilters"
|
|
||||||
assert js =~ "applySidebarMediaFilters"
|
|
||||||
assert js =~ "media-thumbnail-image"
|
|
||||||
assert js =~ "/api/media-thumbnail/"
|
|
||||||
assert js =~ "loading=\"lazy\""
|
|
||||||
|
|
||||||
assert css =~ ".search-box"
|
|
||||||
assert css =~ ".filter-panel"
|
|
||||||
assert css =~ ".calendar-view"
|
|
||||||
assert css =~ ".filter-chip"
|
|
||||||
assert css =~ ".filter-status"
|
|
||||||
assert css =~ ".media-thumbnail-image"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "clearing sidebar filters resets from the baseline seed instead of the filtered payload" do
|
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert js =~ "sidebarFilterSeeds"
|
|
||||||
assert js =~ "function sidebarFilterSeed(viewId)"
|
|
||||||
assert js =~ "defaultSidebarFilterState(viewId, sidebarFilterSeed(viewId))"
|
|
||||||
refute js =~ "defaultSidebarFilterState(viewId, state.sidebarContent[viewId])"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sidebar bundle follows the old app header and styling model instead of a subtitle header" do
|
|
||||||
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert js =~ "sidebar-section-header"
|
|
||||||
refute js =~ "sidebar-subtitle"
|
|
||||||
|
|
||||||
assert css =~ "--accent-color: #007acc"
|
|
||||||
assert css =~ "--vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI'"
|
|
||||||
assert css =~ "border-left-color: var(--vscode-focusBorder);"
|
|
||||||
assert css =~ ".sidebar-section-header"
|
|
||||||
refute css =~ ".sidebar-subtitle"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "static shell bundle exists for direct browser inspection" do
|
|
||||||
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/index.html")
|
|
||||||
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
||||||
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/live.js")
|
||||||
end
|
|
||||||
|
|
||||||
test "static shell bundle keeps the old compact frame metrics and icon-based controls" do
|
|
||||||
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert css =~ ".window-titlebar"
|
assert css =~ ".window-titlebar"
|
||||||
assert css =~ "height: 34px"
|
assert css =~ "height: 34px"
|
||||||
assert css =~ "width: 48px"
|
assert css =~ "width: 48px"
|
||||||
assert css =~ "height: 35px"
|
assert css =~ "height: 35px"
|
||||||
assert css =~ "height: 22px"
|
assert css =~ "height: 22px"
|
||||||
|
assert live_js =~ "LiveView.LiveSocket"
|
||||||
assert js =~ "window-titlebar-sidebar-icon"
|
assert live_js =~ "Phoenix.Socket"
|
||||||
assert js =~ "window-titlebar-panel-icon"
|
|
||||||
assert js =~ "window-titlebar-assistant-icon"
|
|
||||||
assert js =~ "toggle-assistant-sidebar"
|
|
||||||
assert js =~ "activity-bar-top"
|
|
||||||
assert js =~ "activity-bar-bottom"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "static shell bundle hides the fake titlebar menu on macOS and keeps old status-bar alignment rules" do
|
test "desktop shell css keeps the status bar and hidden menu alignment rules" do
|
||||||
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert js =~ "navigator.platform"
|
|
||||||
assert js =~ "isMac"
|
|
||||||
assert js =~ "window-titlebar-menu-bar is-hidden"
|
|
||||||
|
|
||||||
assert css =~ ".window-titlebar-menu-bar.is-hidden"
|
assert css =~ ".window-titlebar-menu-bar.is-hidden"
|
||||||
assert css =~ "--vscode-statusBar-background: #007acc"
|
assert css =~ "--vscode-statusBar-background: #007acc"
|
||||||
@@ -276,95 +125,5 @@ defmodule BDS.UI.ShellTest do
|
|||||||
assert css =~ ".status-bar-language-select"
|
assert css =~ ".status-bar-language-select"
|
||||||
assert css =~ ".status-bar-item.language-badge"
|
assert css =~ ".status-bar-item.language-badge"
|
||||||
assert css =~ ".status-bar-item.offline-badge"
|
assert css =~ ".status-bar-item.offline-badge"
|
||||||
|
|
||||||
assert js =~ "renderLanguageOptions"
|
|
||||||
assert js =~ "language.flag || language.code.toUpperCase()"
|
|
||||||
assert js =~ "status-bar-language-select"
|
|
||||||
assert js =~ "setUiLanguage"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "static shell bundle polls live task status and renders a task-backed lower panel" do
|
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert js =~ "/api/tasks"
|
|
||||||
assert js =~ "/api/projects"
|
|
||||||
assert js =~ "/api/commands"
|
|
||||||
assert js =~ "fetchTaskStatus"
|
|
||||||
assert js =~ "executeBackendShellCommand"
|
|
||||||
assert js =~ "applyShellCommandResult"
|
|
||||||
assert js =~ "openTasksPanel"
|
|
||||||
assert js =~ "command === \"open-tasks-panel\")"
|
|
||||||
assert js =~ "openTasksPanel();"
|
|
||||||
assert js =~ "return;"
|
|
||||||
assert js =~ "No background tasks running"
|
|
||||||
assert js =~ "task-list"
|
|
||||||
assert js =~ "output-list"
|
|
||||||
assert js =~ "git-log-list"
|
|
||||||
assert js =~ "data-panel-tab=\"output\""
|
|
||||||
assert js =~ "data-panel-tab=\"git_log\""
|
|
||||||
end
|
|
||||||
|
|
||||||
test "static shell bundle keeps bottom panel tabs in a stable order" do
|
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert js =~ "return [\"tasks\", \"output\", \"git_log\", state.session.panel.active_tab].filter(uniqueValue);"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "static shell bundle renders a left-side project field with selection, existing-folder import, and create affordances" do
|
|
||||||
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
|
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert js =~ "project-selector-trigger"
|
|
||||||
assert js =~ "project-dropdown"
|
|
||||||
assert js =~ "create-project-btn"
|
|
||||||
assert js =~ "existing-project-btn"
|
|
||||||
assert js =~ "/api/project-folder"
|
|
||||||
assert js =~ "fetchProjects"
|
|
||||||
assert js =~ "createProject"
|
|
||||||
assert js =~ "importExistingProject"
|
|
||||||
assert js =~ "selectProject"
|
|
||||||
assert js =~ "toggleProjectMenu"
|
|
||||||
assert js =~ "closeProjectMenu"
|
|
||||||
|
|
||||||
assert css =~ ".project-selector-trigger"
|
|
||||||
assert css =~ ".project-dropdown"
|
|
||||||
assert css =~ ".create-project-btn"
|
|
||||||
assert css =~ ".existing-project-btn"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "static shell bundle uses translation catalogs for visible shell chrome" do
|
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert js =~ "translationsForLanguage"
|
|
||||||
assert js =~ "function t("
|
|
||||||
assert js =~ "New Project"
|
|
||||||
assert js =~ "No background tasks running"
|
|
||||||
assert js =~ "Idle"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "static shell bundle binds base shell hotkeys and menu actions to existing shell functionality" do
|
|
||||||
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
|
|
||||||
|
|
||||||
assert js =~ "window.addEventListener(\"keydown\""
|
|
||||||
assert js =~ "event.metaKey"
|
|
||||||
assert js =~ "case \"j\""
|
|
||||||
assert js =~ "case \"1\""
|
|
||||||
assert js =~ "case \"2\""
|
|
||||||
assert js =~ "case \"\\\\\""
|
|
||||||
assert js =~ "function isSidebarViewCommand(action)"
|
|
||||||
assert js =~ "function isSingletonEditorCommand(action)"
|
|
||||||
assert js =~ "action.startsWith(\"view_\")"
|
|
||||||
assert js =~ "action.startsWith(\"open_\")"
|
|
||||||
assert js =~ "openSingletonTab(action.slice(5));"
|
|
||||||
assert js =~ "executeBackendShellCommand(action)"
|
|
||||||
assert js =~ "case \"metadata_diff\""
|
|
||||||
assert js =~ "case \"regenerate_calendar\""
|
|
||||||
assert js =~ "case \"fill_missing_translations\""
|
|
||||||
assert js =~ "root.querySelectorAll(\"button[data-command]\")"
|
|
||||||
assert js =~ "[data-close-tab]"
|
|
||||||
assert js =~ "language.flag"
|
|
||||||
end
|
|
||||||
|
|
||||||
defp restore_env(key, nil), do: System.delete_env(key)
|
|
||||||
defp restore_env(key, value), do: System.put_env(key, value)
|
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user