diff --git a/PLAN.md b/PLAN.md
index ea850fa..7591afd 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -13,7 +13,7 @@ The rewrite already implements most of the backend and compatibility-critical su
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
-- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, status bar, project switcher, shell command routing, template-backed shell rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, UI language switching, project dropdown actions, output/post-link/git lower-panel content, and browser-native menu bridging.
+- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, status bar, project switcher, shell command routing, template-backed shell rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, UI language switching, project dropdown actions, output/post-link/git lower-panel content, browser-native menu bridging, and the shared modal/overlay layer for AI suggestions, picker flows, confirmations, and gallery/lightbox interactions.
### Implemented But Not Yet At Parity
@@ -24,7 +24,6 @@ The rewrite already implements most of the backend and compatibility-critical su
### Missing Or Materially Incomplete
-- Shared modal and overlay system: AI suggestions modal, confirm-delete variants, merge confirmation, pickers, and gallery-style overlays.
- Rich route-specific editors for post, media, settings, tags, chat, script, template, and misc surfaces.
- Full UI wiring for create/import/publish/preview/edit-menu flows described by the editor specs.
- Full parity validation against the old application for every spec-defined edge case in editor behavior, media processing details, and cross-feature action chains.
@@ -40,7 +39,7 @@ Ordered from base contracts upward:
| 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 a LiveView-owned shell frame are in place; route bodies remain generic until the editor UX phase. |
| Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. |
-| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial to missing | Route registration exists, but feature-complete editors and modal workflows are not done. |
+| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial | Shared modal workflows are implemented; route registration exists, but feature-complete editors are not done. |
## Plan To Full Feature Parity
@@ -55,8 +54,8 @@ The remaining work needs to proceed from base contracts upward. Later phases sho
3. Finish the desktop shell primitives. Completed 2026-04-26.
Route state, registry-backed shell command coverage, panel fallback integration, menu/native-command wiring, template-backed LiveView rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, project dropdown actions, UI language switching, real output/post-link/git lower-panel content, and native-menu event bridging now cover the old shell frame behavior while preserving the legacy layout and styling.
-4. Implement the shared modal and confirmation layer.
- Add the modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery flows.
+4. Implement the shared modal and confirmation layer. Completed 2026-04-26.
+ The LiveView shell now owns the shared modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery/lightbox flows, with overlay state isolated in a pure module and covered by focused tests.
5. Build feature-complete editors.
Replace generic editor bodies with real editors for posts, media, settings, tags, chat, scripts, templates, and misc maintenance views, including save/discard/publish/delete/import flows.
diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex
index 0fb379c..eec1e63 100644
--- a/lib/bds/desktop/shell_live.ex
+++ b/lib/bds/desktop/shell_live.ex
@@ -3,18 +3,18 @@ defmodule BDS.Desktop.ShellLive do
use Phoenix.LiveView
- import Ecto.Query
import Phoenix.HTML
alias BDS.Desktop.{FolderPicker, Overlay, ShellCommands, ShellData}
+ alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
+ alias BDS.Desktop.ShellLive.PostEditor
alias BDS.Desktop.MenuBar, as: DesktopMenuBar
- alias BDS.{Git, I18n, Metadata}
+ alias BDS.{Git, Posts}
alias BDS.Media.Media
alias BDS.PostLinks
- alias BDS.Posts.{Post, Translation}
+ alias BDS.Posts.Post
alias BDS.Projects
alias BDS.Repo
- alias BDS.Tags.Tag
alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench}
@refresh_interval 1_500
@@ -59,6 +59,11 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{})
|> assign(:sidebar_filter_panels, %{})
+ |> assign(:post_editor_drafts, %{})
+ |> assign(:post_editor_active_languages, %{})
+ |> assign(:post_editor_modes, %{})
+ |> assign(:post_editor_expanded, %{})
+ |> assign(:post_editor_save_states, %{})
|> assign(:shell_overlay, nil)
|> assign(:output_entries, [])
|> reload_shell(workbench)}
@@ -309,11 +314,62 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, reload_shell(socket, workbench)}
end
+ def handle_event("change_post_editor", %{"post_editor" => params}, socket) do
+ {:noreply, update_post_editor(socket, params)}
+ end
+
+ def handle_event("save_post_editor", %{"id" => post_id}, socket) do
+ {:noreply, persist_post_editor(socket, post_id, :save)}
+ end
+
+ def handle_event("publish_post_editor", %{"id" => post_id}, socket) do
+ {:noreply, persist_post_editor(socket, post_id, :publish)}
+ end
+
+ def handle_event("discard_post_editor", %{"id" => post_id}, socket) do
+ {:noreply, discard_post_editor(socket, post_id)}
+ end
+
+ def handle_event("delete_post_editor", %{"id" => post_id}, socket) do
+ {:noreply, delete_post_editor(socket, post_id)}
+ end
+
+ def handle_event("set_post_editor_mode", %{"id" => post_id, "mode" => mode}, socket) do
+ {:noreply,
+ socket
+ |> assign(:post_editor_modes, Map.put(socket.assigns.post_editor_modes, post_id, PostEditor.normalize_mode(mode)))
+ |> reload_shell(socket.assigns.workbench)}
+ end
+
+ def handle_event("toggle_post_metadata", %{"id" => post_id}, socket) do
+ {:noreply,
+ socket
+ |> update_post_editor_expanded(post_id, fn expanded -> Map.update!(expanded, :metadata, ¬ &1) end)
+ |> reload_shell(socket.assigns.workbench)}
+ end
+
+ def handle_event("toggle_post_excerpt", %{"id" => post_id}, socket) do
+ {:noreply,
+ socket
+ |> update_post_editor_expanded(post_id, fn expanded -> Map.update!(expanded, :excerpt, ¬ &1) end)
+ |> reload_shell(socket.assigns.workbench)}
+ end
+
+ def handle_event("select_post_editor_language", %{"id" => post_id, "language" => language}, socket) do
+ {:noreply,
+ socket
+ |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, PostEditor.normalize_language(language, language)))
+ |> reload_shell(socket.assigns.workbench)}
+ end
+
def handle_event("open_overlay", %{"kind" => kind}, socket) do
overlay =
- with overlay_kind when not is_nil(overlay_kind) <- overlay_kind(kind),
+ with overlay_kind when not is_nil(overlay_kind) <- ShellOverlayComponents.kind(kind),
%{type: route} <- socket.assigns[:current_tab] do
- Overlay.open(route, overlay_kind, overlay_context(socket))
+ tab = socket.assigns.current_tab
+ title = tab_title(tab, socket.assigns.tab_meta)
+ subtitle = tab_subtitle(tab, socket.assigns.tab_meta)
+ Overlay.open(route, overlay_kind, ShellOverlayComponents.context(socket.assigns, title, subtitle))
end
{:noreply, assign(socket, :shell_overlay, overlay)}
@@ -345,7 +401,7 @@ defmodule BDS.Desktop.ShellLive do
end
def handle_event("overlay_set_tab", %{"tab" => tab}, socket) do
- {:noreply, update_shell_overlay(socket, &Overlay.set_active_tab(&1, overlay_tab(tab)))}
+ {:noreply, update_shell_overlay(socket, &Overlay.set_active_tab(&1, ShellOverlayComponents.tab(tab)))}
end
def handle_event("overlay_update_form", %{"overlay" => params}, socket) do
@@ -365,7 +421,7 @@ defmodule BDS.Desktop.ShellLive do
%{kind: :insert_link} ->
case Overlay.insert_link_result(overlay, id) do
nil -> socket
- result -> close_overlay_with_output(socket, overlay.title, markdown_link(result.title, result.canonical_url))
+ result -> close_overlay_with_output(socket, overlay.title, ShellOverlayComponents.markdown_link(result.title, result.canonical_url))
end
%{kind: :insert_media} ->
@@ -397,7 +453,7 @@ defmodule BDS.Desktop.ShellLive do
case {overlay.external_url, String.trim(overlay.external_text || "")} do
{"", _text} -> nil
{url, ""} -> url
- {url, text} -> markdown_link(text, url)
+ {url, text} -> ShellOverlayComponents.markdown_link(text, url)
end
if details do
@@ -617,6 +673,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:menu_groups, socket.assigns[:menu_groups] || titlebar_menu_groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> assign(:current_tab, current_tab(workbench))
+ |> assign_post_editor()
end
defp render_sidebar_filters(assigns) do
@@ -1041,263 +1098,6 @@ defmodule BDS.Desktop.ShellLive do
"""
end
- defp render_shell_overlay(%{shell_overlay: nil} = assigns) do
- ~H"""
- """
- end
-
- defp render_shell_overlay(assigns) do
- case assigns.shell_overlay.kind do
- :ai_suggestions -> render_ai_suggestions_overlay(assigns)
- :insert_link -> render_insert_link_overlay(assigns)
- :insert_media -> render_insert_media_overlay(assigns)
- :language_picker -> render_language_picker_overlay(assigns)
- :confirm_delete -> render_confirm_delete_overlay(assigns)
- :confirm_dialog -> render_confirm_dialog_overlay(assigns)
- :gallery -> render_gallery_overlay(assigns)
- _other -> ~H"""
- """
- end
- end
-
- defp render_ai_suggestions_overlay(assigns) do
- ~H"""
-
-
-
-
-
-
- <%= for field <- @shell_overlay.fields do %>
-
-
-
-
<%= field.label %>
-
<%= field.current_value %>
-
<%= field.suggested_value %>
-
-
- <% end %>
-
-
-
-
-
- """
- end
-
- defp render_insert_link_overlay(assigns) do
- ~H"""
-
-
-
-
-
- <%= if @shell_overlay.active_tab == :internal do %>
-
-
- <%= for result <- if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts) do %>
-
- <% end %>
- <%= if Enum.empty?(if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts)) do %>
-
<%= translated("No items") %>
- <% end %>
-
- <% else %>
-
- <% end %>
-
-
- """
- end
-
- defp render_insert_media_overlay(assigns) do
- ~H"""
-
- """
- end
-
- defp render_language_picker_overlay(assigns) do
- ~H"""
-
-
-
-
-
-
<%= translated("Available languages") %>
-
- <%= for target <- @shell_overlay.available_targets do %>
-
- <% end %>
-
-
-
-
- """
- end
-
- defp render_confirm_delete_overlay(assigns) do
- ~H"""
-
-
-
-
-
-
<%= @shell_overlay.entity_name %>
- <%= if @shell_overlay.reference_count > 0 do %>
-
-
-
<%= translated("This item is referenced by:") %>
-
- <%= for title <- @shell_overlay.reference_list do %>
- - <%= title %>
- <% end %>
-
-
-
- <% end %>
-
-
-
-
- """
- end
-
- defp render_confirm_dialog_overlay(assigns) do
- ~H"""
-
-
-
-
-
-
<%= @shell_overlay.message %>
-
-
-
-
- """
- end
-
- defp render_gallery_overlay(assigns) do
- ~H"""
-
-
-
-
-
- <%= for image <- @shell_overlay.images do %>
-
- <% end %>
-
-
-
- <%= if @shell_overlay.lightbox do %>
-
-
-
-
- <%= if @shell_overlay.lightbox.total_count > 1 do %>
-
-
- <% end %>
-
-

-
-
-
-
- <% end %>
-
- """
- end
-
defp render_task_entries(assigns) do
~H"""
<%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %>
@@ -1494,6 +1294,167 @@ defmodule BDS.Desktop.ShellLive do
Enum.find(tabs, &(&1.type == type and &1.id == id))
end
+ defp assign_post_editor(socket) do
+ assigns = Map.put(socket.assigns, :project_metadata, ShellOverlayComponents.project_metadata(socket.assigns.projects.active_project_id))
+ assign(socket, :post_editor, PostEditor.build(assigns))
+ end
+
+ defp update_post_editor(socket, params) do
+ case socket.assigns.current_tab do
+ %{type: :post, id: post_id} ->
+ case Repo.get(Post, post_id) do
+ nil ->
+ socket
+
+ %Post{} = post ->
+ metadata = ShellOverlayComponents.project_metadata(post.project_id)
+ canonical_language = PostEditor.normalize_language(post.language, metadata.main_language || "en")
+ current_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
+ requested_language = PostEditor.normalize_language(Map.get(params, "language"), current_language)
+
+ next_language =
+ if current_language == canonical_language do
+ requested_language
+ else
+ current_language
+ end
+
+ draft = PostEditor.normalize_params(params, current_language, next_language)
+ workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
+
+ socket
+ |> assign(:workbench, workbench)
+ |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft))
+ |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language))
+ |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty))
+ |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: draft["title"], subtitle: Atom.to_string(post.status || :draft)}))
+ |> maybe_drop_old_language_draft(post_id, current_language, next_language)
+ |> reload_shell(workbench)
+ end
+
+ _other ->
+ socket
+ end
+ end
+
+ defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) when current_language == next_language,
+ do: socket
+
+ defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do
+ assign(socket, :post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language))
+ end
+
+ defp persist_post_editor(socket, post_id, action) do
+ case Repo.get(Post, post_id) do
+ nil ->
+ socket
+
+ %Post{} = post ->
+ metadata = ShellOverlayComponents.project_metadata(post.project_id)
+ canonical_language = PostEditor.normalize_language(post.language, metadata.main_language || "en")
+ active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
+ draft = PostEditor.current_draft(socket.assigns, post, metadata, active_language)
+
+ result = PostEditor.persist(post, draft, active_language, metadata, action)
+
+ case result do
+ {:ok, record} ->
+ workbench = Workbench.clear_dirty(socket.assigns.workbench, :post, post_id)
+ normalized_form = PostEditor.persisted_form(Repo.get!(Post, post_id), metadata, active_language)
+
+ socket
+ |> assign(:workbench, workbench)
+ |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, normalized_form))
+ |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, PostEditor.save_state_for_action(action)))
+ |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: PostEditor.record_title(record, Repo.get!(Post, post_id)), subtitle: Atom.to_string(PostEditor.record_status(record))}))
+ |> reload_shell(workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output_entry(translated("Post"), inspect(reason), nil, "error")
+ |> reload_shell(socket.assigns.workbench)
+ end
+ end
+ end
+
+ defp discard_post_editor(socket, post_id) do
+ case Repo.get(Post, post_id) do
+ nil ->
+ socket
+
+ %Post{} = post ->
+ metadata = ShellOverlayComponents.project_metadata(post.project_id)
+ canonical_language = PostEditor.normalize_language(post.language, metadata.main_language || "en")
+ active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
+ restored_result = PostEditor.discard(post, active_language, metadata)
+
+ case restored_result do
+ {:ok, restored_post} ->
+ workbench = Workbench.clear_dirty(socket.assigns.workbench, :post, post_id)
+
+ socket
+ |> assign(:workbench, workbench)
+ |> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language))
+ |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :discarded))
+ |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: restored_post.title || restored_post.slug || restored_post.id, subtitle: Atom.to_string(restored_post.status || :draft)}))
+ |> reload_shell(workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output_entry(translated("Post"), inspect(reason), nil, "error")
+ |> reload_shell(socket.assigns.workbench)
+ end
+ end
+ end
+
+ defp delete_post_editor(socket, post_id) do
+ case Posts.delete_post(post_id) do
+ {:ok, :deleted} ->
+ workbench = Workbench.close_tab(socket.assigns.workbench, :post, post_id)
+
+ socket
+ |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
+ |> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id))
+ |> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id))
+ |> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id))
+ |> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id))
+ |> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id))
+ |> reload_shell(workbench)
+
+ {:error, reason} ->
+ socket
+ |> append_output_entry(translated("Post"), inspect(reason), nil, "error")
+ |> reload_shell(socket.assigns.workbench)
+ end
+ end
+
+ defp update_post_editor_expanded(socket, post_id, updater) do
+ expanded =
+ socket.assigns.post_editor_expanded
+ |> Map.get(post_id, %{metadata: false, excerpt: false})
+ |> Map.put_new(:metadata, false)
+ |> Map.put_new(:excerpt, false)
+ |> updater.()
+
+ assign(socket, :post_editor_expanded, Map.put(socket.assigns.post_editor_expanded, post_id, expanded))
+ end
+
+ defp put_nested_map(map, key, nested_key, value) do
+ Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value))
+ end
+
+ defp delete_nested_map(map, key, nested_key) do
+ case Map.get(map, key) do
+ nil -> map
+ nested ->
+ case Map.delete(nested, nested_key) do
+ emptied when map_size(emptied) == 0 -> Map.delete(map, key)
+ remaining -> Map.put(map, key, remaining)
+ end
+ end
+ end
+
+
defp sync_layout(workbench, params) do
workbench
|> maybe_set_sidebar_width(Map.get(params, "sidebar_width"))
@@ -2178,249 +2139,6 @@ defmodule BDS.Desktop.ShellLive do
defp assistant_message_testid(role), do: "assistant-message-#{role}"
- defp overlay_context(socket) do
- project_id = socket.assigns.projects.active_project_id
- metadata = overlay_project_metadata(project_id)
- current_tab = socket.assigns.current_tab
- page_language = socket.assigns.page_language
- tab_title = tab_title(current_tab, socket.assigns.tab_meta)
- tab_subtitle = tab_subtitle(current_tab, socket.assigns.tab_meta)
- posts = overlay_posts(project_id)
- media = overlay_media(project_id)
-
- %{
- current_tab: %{type: current_tab.type, id: current_tab.id, title: tab_title, subtitle: tab_subtitle},
- current_post_language: overlay_source_language(current_tab, metadata),
- current_media_language: overlay_source_language(current_tab, metadata),
- posts: posts,
- media: media,
- post_media_ids: overlay_post_media_ids(current_tab),
- blog_languages: overlay_blog_languages(metadata),
- language_names: overlay_language_names(),
- language_flags: overlay_language_flags(),
- existing_translations: overlay_existing_translations(current_tab),
- ai_title: ShellData.translate("AI Suggestions", %{}, page_language),
- insert_link_title: ShellData.translate("Insert Link", %{}, page_language),
- insert_media_title: ShellData.translate("Insert Media", %{}, page_language),
- language_picker_title: ShellData.translate("Translate", %{}, page_language),
- gallery_title: tab_title,
- ai_fields: overlay_ai_fields(current_tab, tab_title, tab_subtitle, page_language),
- delete_details: overlay_delete_details(current_tab, page_language),
- merge_details: overlay_merge_details(project_id, page_language)
- }
- end
-
- defp overlay_project_metadata(nil), do: %{main_language: "en", blog_languages: []}
-
- defp overlay_project_metadata(project_id) do
- {:ok, metadata} = Metadata.get_project_metadata(project_id)
- metadata
- rescue
- _error -> %{main_language: "en", blog_languages: []}
- end
-
- defp overlay_posts(nil), do: []
-
- defp overlay_posts(project_id) do
- Repo.all(
- from post in Post,
- where: post.project_id == ^project_id,
- order_by: [desc: post.updated_at, desc: post.created_at],
- select: %{id: post.id, title: post.title, slug: post.slug, status: post.status, published_at: post.published_at, updated_at: post.updated_at, language: post.language}
- )
- |> Enum.map(fn post ->
- %{
- id: post.id,
- title: post.title || post.slug || post.id,
- status: Atom.to_string(post.status || :draft),
- canonical_url: canonical_post_url(post)
- }
- end)
- end
-
- defp overlay_media(nil), do: []
-
- defp overlay_media(project_id) do
- Repo.all(
- from media in Media,
- where: media.project_id == ^project_id,
- order_by: [desc: media.updated_at, desc: media.created_at],
- select: %{id: media.id, title: media.title, original_name: media.original_name, mime_type: media.mime_type, alt: media.alt, caption: media.caption}
- )
- |> Enum.map(fn media ->
- %{
- id: media.id,
- title: media.title || media.original_name || media.id,
- original_name: media.original_name || media.id,
- is_image: String.starts_with?(to_string(media.mime_type || ""), "image/"),
- thumbnail_url: "/media-thumbnail/#{media.id}",
- image_url: "/media-thumbnail/#{media.id}?size=large",
- alt_text: media.alt || media.caption || media.title
- }
- end)
- end
-
- defp overlay_post_media_ids(%{type: :post, id: post_id}) do
- case Repo.query("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do
- {:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end)
- _other -> []
- end
- rescue
- _error -> []
- end
-
- defp overlay_post_media_ids(_tab), do: []
-
- defp overlay_existing_translations(%{type: :post, id: post_id}) do
- Repo.all(
- from translation in Translation,
- where: translation.translation_for == ^post_id,
- select: {translation.language, translation.status}
- )
- |> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end)
- rescue
- _error -> %{}
- end
-
- defp overlay_existing_translations(_tab), do: %{}
-
- defp overlay_blog_languages(metadata) do
- ([metadata.main_language || "en"] ++ (metadata.blog_languages || []))
- |> Enum.reject(&is_nil/1)
- |> Enum.uniq()
- end
-
- defp overlay_source_language(%{type: :post, id: post_id}, metadata) do
- case Repo.get(Post, post_id) do
- %Post{language: language} when is_binary(language) and language != "" -> language
- _other -> metadata.main_language || "en"
- end
- rescue
- _error -> metadata.main_language || "en"
- end
-
- defp overlay_source_language(_tab, metadata), do: metadata.main_language || "en"
-
- defp overlay_language_names do
- %{
- "en" => "English",
- "de" => "Deutsch",
- "fr" => "Francais",
- "it" => "Italiano",
- "es" => "Espanol"
- }
- end
-
- defp overlay_language_flags do
- I18n.supported_languages()
- |> Enum.into(%{}, fn language -> {language.code, I18n.flag(language.code)} end)
- end
-
- defp overlay_ai_fields(%{type: :post, id: post_id}, title, subtitle, page_language) do
- case Repo.get(Post, post_id) do
- %Post{} = post ->
- [
- %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: post.title || title, suggested_value: refine_title(post.title || title), locked: false},
- %{key: "excerpt", label: ShellData.translate("Excerpt", %{}, page_language), current_value: post.excerpt || subtitle, suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), locked: false},
- %{key: "slug", label: ShellData.translate("Slug", %{}, page_language), current_value: post.slug || slugify(post.title || title), suggested_value: refine_slug(post.slug || slugify(post.title || title)), locked: post.status == :published}
- ]
-
- _other ->
- []
- end
- rescue
- _error -> []
- end
-
- defp overlay_ai_fields(%{type: :media, id: media_id}, title, _subtitle, page_language) do
- case Repo.get(Media, media_id) do
- %Media{} = media ->
- [
- %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: media.title || title, suggested_value: refine_title(media.title || title), locked: false},
- %{key: "alt", label: ShellData.translate("Alt Text", %{}, page_language), current_value: media.alt || "", suggested_value: media.alt || title, locked: false},
- %{key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false}
- ]
-
- _other ->
- []
- end
- rescue
- _error -> []
- end
-
- defp overlay_ai_fields(_tab, _title, _subtitle, _page_language), do: []
-
- defp overlay_delete_details(%{type: :media, id: media_id}, page_language) do
- entity_name =
- case Repo.get(Media, media_id) do
- %Media{} = media -> media.title || media.original_name || media.id
- _other -> media_id
- end
-
- reference_list =
- case Repo.query("SELECT posts.title FROM posts JOIN post_media ON posts.id = post_media.post_id WHERE post_media.media_id = ? ORDER BY post_media.sort_order ASC, posts.updated_at DESC", [media_id]) do
- {:ok, %{rows: rows}} -> Enum.map(rows, fn [title] -> title || media_id end)
- _other -> []
- end
-
- %{
- title: ShellData.translate("Delete Media", %{}, page_language),
- entity_name: entity_name,
- entity_type: "media",
- reference_list: reference_list
- }
- rescue
- _error -> %{title: ShellData.translate("Delete Media", %{}, page_language), entity_name: media_id, entity_type: "media", reference_list: []}
- end
-
- defp overlay_delete_details(%{type: :tags}, page_language) do
- tag_name =
- Repo.one(from tag in Tag, order_by: [asc: tag.name], limit: 1, select: tag.name)
- |> Kernel.||("tag")
-
- %{
- title: ShellData.translate("Delete Tag", %{}, page_language),
- entity_name: tag_name,
- entity_type: "tag",
- reference_list: []
- }
- rescue
- _error -> %{title: ShellData.translate("Delete Tag", %{}, page_language), entity_name: "tag", entity_type: "tag", reference_list: []}
- end
-
- defp overlay_delete_details(_tab, page_language) do
- %{title: ShellData.translate("Delete", %{}, page_language), entity_name: "", entity_type: "item", reference_list: []}
- end
-
- defp overlay_merge_details(project_id, page_language) do
- tags =
- Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name], limit: 3, select: tag.name)
-
- target = List.first(tags) || "tag"
-
- %{
- target: target,
- count: max(length(tags), 1),
- title: ShellData.translate("Merge Tags", %{}, page_language),
- message: ShellData.translate("Cannot be undone.", %{}, page_language)
- }
- rescue
- _error -> %{target: "tag", count: 1, title: ShellData.translate("Merge Tags", %{}, page_language), message: ShellData.translate("Cannot be undone.", %{}, page_language)}
- end
-
- defp overlay_kind("ai_suggestions"), do: :ai_suggestions
- defp overlay_kind("insert_link"), do: :insert_link
- defp overlay_kind("insert_media"), do: :insert_media
- defp overlay_kind("language_picker"), do: :language_picker
- defp overlay_kind("confirm_delete"), do: :confirm_delete
- defp overlay_kind("confirm_merge"), do: :confirm_merge
- defp overlay_kind("gallery"), do: :gallery
- defp overlay_kind(_kind), do: nil
-
- defp overlay_tab("internal"), do: :internal
- defp overlay_tab("external"), do: :external
- defp overlay_tab(_tab), do: :internal
-
defp update_shell_overlay(socket, updater) do
case socket.assigns[:shell_overlay] do
nil -> socket
@@ -2434,34 +2152,6 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:shell_overlay, nil)
end
- defp markdown_link(text, url), do: "[#{text}](#{url})"
-
- defp canonical_post_url(post) do
- timestamp = post.published_at || post.updated_at || System.system_time(:millisecond)
- date = DateTime.from_unix!(timestamp, :millisecond)
- "/#{date.year}/#{pad2(date.month)}/#{pad2(date.day)}/#{post.slug || post.id}"
- end
-
- defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
-
- defp refine_title(nil), do: ""
- defp refine_title(title), do: String.trim(title <> " Notes")
-
- defp refine_excerpt(title, excerpt) do
- base = excerpt |> to_string() |> String.trim()
- if base == "", do: "#{title} overview", else: base <> "."
- end
-
- defp refine_slug(slug), do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated")
-
- defp slugify(value) do
- value
- |> to_string()
- |> String.downcase()
- |> String.replace(~r/[^a-z0-9]+/u, "-")
- |> String.trim("-")
- end
-
defp media_thumbnail_glyph(mime_type) do
case String.split(to_string(mime_type || ""), "/", parts: 2) do
["image", _rest] -> "IMG"
diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex
index e9758ff..2232ad9 100644
--- a/lib/bds/desktop/shell_live/index.html.heex
+++ b/lib/bds/desktop/shell_live/index.html.heex
@@ -364,29 +364,33 @@
<% else %>
-
-
- <%= tab_route_label(@current_tab) %>
- <%= tab_title(@current_tab, @tab_meta) %>
- <%= tab_subtitle(@current_tab, @tab_meta) %>
+ <%= if @current_tab.type == :post and @post_editor do %>
+
+ <% else %>
+
+
+ <%= tab_route_label(@current_tab) %>
+ <%= tab_title(@current_tab, @tab_meta) %>
+ <%= tab_subtitle(@current_tab, @tab_meta) %>
- <%= render_editor_toolbar(assigns) %>
+ <%= render_editor_toolbar(assigns) %>
-
-
<%= tab_title(@current_tab, @tab_meta) %>
-
Desktop workbench content routed through the Elixir shell.
-
-
+
+
<%= tab_title(@current_tab, @tab_meta) %>
+
Desktop workbench content routed through the Elixir shell.
+
+
-
-
+
+
+ <% end %>
<% end %>
@@ -624,5 +628,5 @@
- <%= render_shell_overlay(assigns) %>
+
diff --git a/lib/bds/desktop/shell_live/overlay_components.ex b/lib/bds/desktop/shell_live/overlay_components.ex
new file mode 100644
index 0000000..26f9a7a
--- /dev/null
+++ b/lib/bds/desktop/shell_live/overlay_components.ex
@@ -0,0 +1,286 @@
+defmodule BDS.Desktop.ShellLive.OverlayComponents do
+ @moduledoc false
+
+ use Phoenix.Component
+
+ import Ecto.Query
+
+ alias BDS.Desktop.ShellData
+ alias BDS.{I18n, Metadata, Repo}
+ alias BDS.Media.Media
+ alias BDS.Posts.{Post, Translation}
+ alias BDS.Tags.Tag
+
+ embed_templates "overlay_html/*"
+
+ def context(assigns, tab_title, tab_subtitle) do
+ project_id = assigns.projects.active_project_id
+ metadata = project_metadata(project_id)
+ current_tab = assigns.current_tab
+ page_language = assigns.page_language
+ posts = posts(project_id)
+ media = media(project_id)
+
+ %{
+ current_tab: %{type: current_tab.type, id: current_tab.id, title: tab_title, subtitle: tab_subtitle},
+ current_post_language: source_language(current_tab, metadata),
+ current_media_language: source_language(current_tab, metadata),
+ posts: posts,
+ media: media,
+ post_media_ids: post_media_ids(current_tab),
+ blog_languages: blog_languages(metadata),
+ language_names: language_names(),
+ language_flags: language_flags(),
+ existing_translations: existing_translations(current_tab),
+ ai_title: ShellData.translate("AI Suggestions", %{}, page_language),
+ insert_link_title: ShellData.translate("Insert Link", %{}, page_language),
+ insert_media_title: ShellData.translate("Insert Media", %{}, page_language),
+ language_picker_title: ShellData.translate("Translate", %{}, page_language),
+ gallery_title: tab_title,
+ ai_fields: ai_fields(current_tab, tab_title, tab_subtitle, page_language),
+ delete_details: delete_details(current_tab, page_language),
+ merge_details: merge_details(project_id, page_language)
+ }
+ end
+
+ def kind("ai_suggestions"), do: :ai_suggestions
+ def kind("insert_link"), do: :insert_link
+ def kind("insert_media"), do: :insert_media
+ def kind("language_picker"), do: :language_picker
+ def kind("confirm_delete"), do: :confirm_delete
+ def kind("confirm_merge"), do: :confirm_merge
+ def kind("gallery"), do: :gallery
+ def kind(_kind), do: nil
+
+ def tab("internal"), do: :internal
+ def tab("external"), do: :external
+ def tab(_tab), do: :internal
+
+ def markdown_link(text, url), do: "[#{text}](#{url})"
+
+ def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
+
+ def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
+
+ def project_metadata(project_id) do
+ {:ok, metadata} = Metadata.get_project_metadata(project_id)
+ metadata
+ rescue
+ _error -> %{main_language: "en", blog_languages: []}
+ end
+
+ defp posts(nil), do: []
+
+ defp posts(project_id) do
+ Repo.all(
+ from post in Post,
+ where: post.project_id == ^project_id,
+ order_by: [desc: post.updated_at, desc: post.created_at],
+ select: %{id: post.id, title: post.title, slug: post.slug, status: post.status, published_at: post.published_at, updated_at: post.updated_at, language: post.language}
+ )
+ |> Enum.map(fn post ->
+ %{
+ id: post.id,
+ title: post.title || post.slug || post.id,
+ status: Atom.to_string(post.status || :draft),
+ canonical_url: canonical_post_url(post)
+ }
+ end)
+ end
+
+ defp media(nil), do: []
+
+ defp media(project_id) do
+ Repo.all(
+ from media in Media,
+ where: media.project_id == ^project_id,
+ order_by: [desc: media.updated_at, desc: media.created_at],
+ select: %{id: media.id, title: media.title, original_name: media.original_name, mime_type: media.mime_type, alt: media.alt, caption: media.caption}
+ )
+ |> Enum.map(fn media ->
+ %{
+ id: media.id,
+ title: media.title || media.original_name || media.id,
+ original_name: media.original_name || media.id,
+ is_image: String.starts_with?(to_string(media.mime_type || ""), "image/"),
+ thumbnail_url: "/media-thumbnail/#{media.id}",
+ image_url: "/media-thumbnail/#{media.id}?size=large",
+ alt_text: media.alt || media.caption || media.title
+ }
+ end)
+ end
+
+ defp post_media_ids(%{type: :post, id: post_id}) do
+ case Repo.query("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do
+ {:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end)
+ _other -> []
+ end
+ rescue
+ _error -> []
+ end
+
+ defp post_media_ids(_tab), do: []
+
+ defp existing_translations(%{type: :post, id: post_id}) do
+ Repo.all(
+ from translation in Translation,
+ where: translation.translation_for == ^post_id,
+ select: {translation.language, translation.status}
+ )
+ |> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end)
+ rescue
+ _error -> %{}
+ end
+
+ defp existing_translations(_tab), do: %{}
+
+ defp blog_languages(metadata) do
+ ([metadata.main_language || "en"] ++ (metadata.blog_languages || []))
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ defp source_language(%{type: :post, id: post_id}, metadata) do
+ case Repo.get(Post, post_id) do
+ %Post{language: language} when is_binary(language) and language != "" -> language
+ _other -> metadata.main_language || "en"
+ end
+ rescue
+ _error -> metadata.main_language || "en"
+ end
+
+ defp source_language(_tab, metadata), do: metadata.main_language || "en"
+
+ defp language_names do
+ %{
+ "en" => "English",
+ "de" => "Deutsch",
+ "fr" => "Francais",
+ "it" => "Italiano",
+ "es" => "Espanol"
+ }
+ end
+
+ defp language_flags do
+ I18n.supported_languages()
+ |> Enum.into(%{}, fn language -> {language.code, I18n.flag(language.code)} end)
+ end
+
+ defp ai_fields(%{type: :post, id: post_id}, title, subtitle, page_language) do
+ case Repo.get(Post, post_id) do
+ %Post{} = post ->
+ [
+ %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: post.title || title, suggested_value: refine_title(post.title || title), locked: false},
+ %{key: "excerpt", label: ShellData.translate("Excerpt", %{}, page_language), current_value: post.excerpt || subtitle, suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), locked: false},
+ %{key: "slug", label: ShellData.translate("Slug", %{}, page_language), current_value: post.slug || slugify(post.title || title), suggested_value: refine_slug(post.slug || slugify(post.title || title)), locked: post.status == :published}
+ ]
+
+ _other ->
+ []
+ end
+ rescue
+ _error -> []
+ end
+
+ defp ai_fields(%{type: :media, id: media_id}, title, _subtitle, page_language) do
+ case Repo.get(Media, media_id) do
+ %Media{} = media ->
+ [
+ %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: media.title || title, suggested_value: refine_title(media.title || title), locked: false},
+ %{key: "alt", label: ShellData.translate("Alt Text", %{}, page_language), current_value: media.alt || "", suggested_value: media.alt || title, locked: false},
+ %{key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false}
+ ]
+
+ _other ->
+ []
+ end
+ rescue
+ _error -> []
+ end
+
+ defp ai_fields(_tab, _title, _subtitle, _page_language), do: []
+
+ defp delete_details(%{type: :media, id: media_id}, page_language) do
+ entity_name =
+ case Repo.get(Media, media_id) do
+ %Media{} = media -> media.title || media.original_name || media.id
+ _other -> media_id
+ end
+
+ reference_list =
+ case Repo.query("SELECT posts.title FROM posts JOIN post_media ON posts.id = post_media.post_id WHERE post_media.media_id = ? ORDER BY post_media.sort_order ASC, posts.updated_at DESC", [media_id]) do
+ {:ok, %{rows: rows}} -> Enum.map(rows, fn [title] -> title || media_id end)
+ _other -> []
+ end
+
+ %{
+ title: ShellData.translate("Delete Media", %{}, page_language),
+ entity_name: entity_name,
+ entity_type: "media",
+ reference_list: reference_list
+ }
+ rescue
+ _error -> %{title: ShellData.translate("Delete Media", %{}, page_language), entity_name: media_id, entity_type: "media", reference_list: []}
+ end
+
+ defp delete_details(%{type: :tags}, page_language) do
+ tag_name =
+ Repo.one(from tag in Tag, order_by: [asc: tag.name], limit: 1, select: tag.name)
+ |> Kernel.||("tag")
+
+ %{
+ title: ShellData.translate("Delete Tag", %{}, page_language),
+ entity_name: tag_name,
+ entity_type: "tag",
+ reference_list: []
+ }
+ rescue
+ _error -> %{title: ShellData.translate("Delete Tag", %{}, page_language), entity_name: "tag", entity_type: "tag", reference_list: []}
+ end
+
+ defp delete_details(_tab, page_language) do
+ %{title: ShellData.translate("Delete", %{}, page_language), entity_name: "", entity_type: "item", reference_list: []}
+ end
+
+ defp merge_details(project_id, page_language) do
+ tags =
+ Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name], limit: 3, select: tag.name)
+
+ target = List.first(tags) || "tag"
+
+ %{
+ target: target,
+ count: max(length(tags), 1),
+ title: ShellData.translate("Merge Tags", %{}, page_language),
+ message: ShellData.translate("Cannot be undone.", %{}, page_language)
+ }
+ rescue
+ _error -> %{target: "tag", count: 1, title: ShellData.translate("Merge Tags", %{}, page_language), message: ShellData.translate("Cannot be undone.", %{}, page_language)}
+ end
+
+ defp canonical_post_url(post) do
+ timestamp = post.published_at || post.updated_at || System.system_time(:millisecond)
+ date = DateTime.from_unix!(timestamp, :millisecond)
+ "/#{date.year}/#{pad2(date.month)}/#{pad2(date.day)}/#{post.slug || post.id}"
+ end
+
+ defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
+
+ defp refine_title(nil), do: ""
+ defp refine_title(title), do: String.trim(title <> " Notes")
+
+ defp refine_excerpt(title, excerpt) do
+ base = excerpt |> to_string() |> String.trim()
+ if base == "", do: "#{title} overview", else: base <> "."
+ end
+
+ defp refine_slug(slug), do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated")
+
+ defp slugify(value) do
+ value
+ |> to_string()
+ |> String.downcase()
+ |> String.replace(~r/[^a-z0-9]+/u, "-")
+ |> String.trim("-")
+ end
+end
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex b/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex
new file mode 100644
index 0000000..17e957e
--- /dev/null
+++ b/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex
@@ -0,0 +1,219 @@
+<%= if @shell_overlay do %>
+ <%= case @shell_overlay.kind do %>
+ <% :ai_suggestions -> %>
+
+
+
+
+
+
+ <%= for field <- @shell_overlay.fields do %>
+
+
+
+
<%= field.label %>
+
<%= field.current_value %>
+
<%= field.suggested_value %>
+
+
+ <% end %>
+
+
+
+
+
+
+ <% :insert_link -> %>
+
+
+
+
+
+ <%= if @shell_overlay.active_tab == :internal do %>
+
+
+ <%= for result <- if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts) do %>
+
+ <% end %>
+ <%= if Enum.empty?(if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts)) do %>
+
<%= translated("No items") %>
+ <% end %>
+
+ <% else %>
+
+ <% end %>
+
+
+
+ <% :insert_media -> %>
+
+
+ <% :language_picker -> %>
+
+
+
+
+
+
<%= translated("Available languages") %>
+
+ <%= for target <- @shell_overlay.available_targets do %>
+
+ <% end %>
+
+
+
+
+
+ <% :confirm_delete -> %>
+
+
+
+
+
+
<%= @shell_overlay.entity_name %>
+ <%= if @shell_overlay.reference_count > 0 do %>
+
+
+
<%= translated("This item is referenced by:") %>
+
+ <%= for title <- @shell_overlay.reference_list do %>
+ - <%= title %>
+ <% end %>
+
+
+
+ <% end %>
+
+
+
+
+
+ <% :confirm_dialog -> %>
+
+
+
+
+
+
<%= @shell_overlay.message %>
+
+
+
+
+
+ <% :gallery -> %>
+
+
+
+
+
+ <%= for image <- @shell_overlay.images do %>
+
+ <% end %>
+
+
+
+ <%= if @shell_overlay.lightbox do %>
+
+
+
+
+ <%= if @shell_overlay.lightbox.total_count > 1 do %>
+
+ <% end %>
+

+ <%= if @shell_overlay.lightbox.total_count > 1 do %>
+
+
<%= @shell_overlay.lightbox.position %>/<%= @shell_overlay.lightbox.total_count %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <% _other -> %>
+ <% end %>
+<% end %>
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/post_editor.ex b/lib/bds/desktop/shell_live/post_editor.ex
new file mode 100644
index 0000000..60602b7
--- /dev/null
+++ b/lib/bds/desktop/shell_live/post_editor.ex
@@ -0,0 +1,404 @@
+defmodule BDS.Desktop.ShellLive.PostEditor do
+ @moduledoc false
+
+ use Phoenix.Component
+
+ import Ecto.Query
+ import Phoenix.HTML
+
+ alias BDS.Desktop.ShellData
+ alias BDS.{I18n, PostLinks, Posts, Repo, Tags, Templates}
+ alias BDS.Media.Media
+ alias BDS.Posts.{Post, Translation}
+ alias BDS.UI.Workbench
+
+ embed_templates "post_editor_html/*"
+
+ def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do
+ case Repo.get(Post, post_id) do
+ nil ->
+ nil
+
+ %Post{} = post ->
+ metadata = project_metadata(assigns)
+ canonical_language = canonical_language(post, metadata)
+ active_language = Map.get(assigns.post_editor_active_languages, post.id, canonical_language)
+ translations = translations(post.id)
+ persisted_form = persisted_form(post, metadata, active_language, translations)
+
+ form =
+ assigns.post_editor_drafts
+ |> Map.get(post.id, %{})
+ |> Map.get(active_language, persisted_form)
+
+ expanded =
+ Map.get(assigns.post_editor_expanded, post.id, %{
+ metadata: blank?(post.title),
+ excerpt: not blank?(post.excerpt)
+ })
+
+ current_translation = Map.get(translations, active_language)
+
+ %{
+ id: post.id,
+ display_title: display_title(form["title"], post.slug, post.id),
+ subtitle: active_language_subtitle(active_language, canonical_language),
+ slug: post.slug || post.id,
+ status: current_status(post.status, active_language, canonical_language, current_translation),
+ dirty?: Workbench.dirty?(assigns.workbench, :post, post.id),
+ save_state: Map.get(assigns.post_editor_save_states, post.id, :idle),
+ metadata_expanded: Map.get(expanded, :metadata, false),
+ excerpt_expanded: Map.get(expanded, :excerpt, false),
+ mode: Map.get(assigns.post_editor_modes, post.id, :markdown),
+ editing_canonical?: editing_canonical_language?(translations, active_language, canonical_language),
+ languages: languages(metadata),
+ form: form,
+ template_options: template_options(post.project_id),
+ tag_options: Enum.map(Tags.list_tags(post.project_id), & &1.name),
+ category_options: metadata.categories || [],
+ translation_flags: translation_flags(post, canonical_language, active_language, translations),
+ linked_media: linked_media(post.id),
+ post_links: post_links(post.id),
+ footer: footer(post, current_translation, active_language, canonical_language)
+ }
+ end
+ end
+
+ def build(_assigns), do: nil
+
+ def normalize_mode(mode) when mode in [:visual, :markdown, :preview], do: mode
+ def normalize_mode("visual"), do: :visual
+ def normalize_mode("preview"), do: :preview
+ def normalize_mode(_mode), do: :markdown
+
+ def normalize_language(value, fallback) do
+ case value |> to_string() |> String.trim() do
+ "" -> fallback
+ normalized -> String.downcase(normalized)
+ end
+ end
+
+ def normalize_params(params, current_language, next_language) do
+ %{
+ "title" => Map.get(params, "title", ""),
+ "excerpt" => Map.get(params, "excerpt", ""),
+ "content" => Map.get(params, "content", ""),
+ "tags" => Map.get(params, "tags", ""),
+ "categories" => Map.get(params, "categories", ""),
+ "author" => Map.get(params, "author", ""),
+ "language" => if(current_language == next_language, do: normalize_language(Map.get(params, "language"), current_language), else: next_language),
+ "do_not_translate" => truthy?(Map.get(params, "do_not_translate")),
+ "template_slug" => Map.get(params, "template_slug", "")
+ }
+ end
+
+ def current_draft(assigns, %Post{} = post, metadata, active_language) do
+ persisted = persisted_form(post, metadata, active_language)
+
+ assigns.post_editor_drafts
+ |> Map.get(post.id, %{})
+ |> Map.get(active_language, persisted)
+ end
+
+ def persisted_form(%Post{} = post, metadata, active_language) do
+ persisted_form(post, metadata, active_language, translations(post.id))
+ end
+
+ def persist(%Post{} = post, draft, active_language, metadata, action) do
+ canonical_language = canonical_language(post, metadata)
+ translations = translations(post.id)
+
+ result =
+ if editing_canonical_language?(translations, active_language, canonical_language) do
+ post
+ |> save_canonical_draft(draft)
+ |> maybe_publish_post(post.id, action)
+ else
+ post.id
+ |> save_translation_draft(active_language, draft)
+ |> maybe_publish_translation(post.id, active_language, action)
+ end
+
+ result
+ end
+
+ def discard(%Post{} = post, active_language, metadata) do
+ canonical_language = canonical_language(post, metadata)
+ current_translations = translations(post.id)
+
+ cond do
+ not editing_canonical_language?(current_translations, active_language, canonical_language) ->
+ {:ok, post}
+
+ post.file_path not in [nil, ""] and post.status == :draft ->
+ Posts.discard_post_changes(post.id)
+
+ true ->
+ {:ok, post}
+ end
+ end
+
+ def save_state_for_action(:publish), do: :published
+ def save_state_for_action(_action), do: :saved
+
+ def record_title(%Translation{title: title}, post), do: blank_to_nil(title) || post.title || post.slug || post.id
+ def record_title(%Post{title: title, slug: slug, id: id}, _post), do: blank_to_nil(title) || blank_to_nil(slug) || id
+
+ def record_status(%Translation{status: status}), do: status || :draft
+ def record_status(%Post{status: status}), do: status || :draft
+
+ def editing_canonical_language?(translations, active_language, canonical_language) do
+ active_language == canonical_language or not Map.has_key?(translations, active_language)
+ end
+
+ def post_status_label(status), do: ShellData.dashboard_status_label(status)
+
+ def post_editor_save_state_label(:dirty), do: translated("Unsaved")
+ def post_editor_save_state_label(:saved), do: translated("Saved")
+ def post_editor_save_state_label(:published), do: translated("Published")
+ def post_editor_save_state_label(:discarded), do: translated("Reverted")
+ def post_editor_save_state_label(_state), do: translated("Idle")
+
+ def post_editor_mode_label(:visual), do: translated("Visual")
+ def post_editor_mode_label(:markdown), do: translated("Markdown")
+ def post_editor_mode_label(:preview), do: translated("Preview")
+
+ def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
+
+ defp editor_toolbar(assigns) do
+ ~H"""
+ <%= if Enum.any?(@toolbar_buttons) do %>
+
+ <%= for button <- @toolbar_buttons do %>
+
+ <% end %>
+
+ <% end %>
+ """
+ end
+
+ defp project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{})
+
+ defp current_status(post_status, active_language, canonical_language, current_translation) do
+ if active_language == canonical_language, do: post_status, else: translation_status(current_translation)
+ end
+
+ defp persisted_form(post, metadata, active_language, translations) do
+ canonical_language = canonical_language(post, metadata)
+ translation = Map.get(translations, active_language)
+
+ if active_language == canonical_language do
+ %{
+ "title" => post.title || "",
+ "excerpt" => post.excerpt || "",
+ "content" => post.content || "",
+ "tags" => Enum.join(post.tags || [], ", "),
+ "categories" => Enum.join(post.categories || [], ", "),
+ "author" => post.author || metadata.default_author || "",
+ "language" => canonical_language,
+ "do_not_translate" => post.do_not_translate || false,
+ "template_slug" => post.template_slug || ""
+ }
+ else
+ %{
+ "title" => translation && translation.title || "",
+ "excerpt" => translation && translation.excerpt || "",
+ "content" => translation && translation.content || "",
+ "tags" => Enum.join(post.tags || [], ", "),
+ "categories" => Enum.join(post.categories || [], ", "),
+ "author" => post.author || metadata.default_author || "",
+ "language" => active_language,
+ "do_not_translate" => post.do_not_translate || false,
+ "template_slug" => post.template_slug || ""
+ }
+ end
+ end
+
+ defp canonical_language(post, metadata) do
+ normalize_language(post.language, metadata.main_language || "en")
+ end
+
+ defp truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
+ defp truthy?(_value), do: false
+
+ defp blank?(value), do: blank_to_nil(value) == nil
+
+ defp blank_to_nil(value) do
+ value
+ |> to_string()
+ |> String.trim()
+ |> case do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+
+ defp csv_to_list(value) do
+ value
+ |> to_string()
+ |> String.split(",")
+ |> Enum.map(&String.trim/1)
+ |> Enum.reject(&(&1 == ""))
+ end
+
+ defp translations(post_id) do
+ {:ok, translations} = Posts.list_post_translations(post_id)
+ Map.new(translations, fn translation -> {translation.language, translation} end)
+ end
+
+ defp languages(metadata) do
+ (([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++ Enum.map(I18n.supported_languages(), & &1.code))
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ defp translation_status(nil), do: :draft
+ defp translation_status(%Translation{status: status}) when not is_nil(status), do: status
+ defp translation_status(_translation), do: :draft
+
+ defp template_options(project_id) do
+ Repo.all(
+ from template in Templates.Template,
+ where: template.project_id == ^project_id,
+ order_by: [asc: template.title, asc: template.slug],
+ select: %{slug: template.slug, title: fragment("COALESCE(?, ?)", template.title, template.slug)}
+ )
+ rescue
+ _error -> []
+ end
+
+ defp linked_media(post_id) do
+ case Repo.query("SELECT media_id, sort_order FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do
+ {:ok, %{rows: rows}} ->
+ Enum.map(rows, fn [media_id, sort_order] ->
+ case Repo.get(Media, media_id) do
+ %Media{} = media ->
+ %{
+ media_id: media.id,
+ has_thumbnail: String.starts_with?(to_string(media.mime_type || ""), "image/"),
+ name: media.title || media.original_name || media.id,
+ sort_order: sort_order || 0
+ }
+
+ _other ->
+ nil
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+
+ _other ->
+ []
+ end
+ rescue
+ _error -> []
+ end
+
+ defp post_links(post_id) do
+ %{
+ backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id),
+ outlinks: related_posts(PostLinks.list_outgoing_links(post_id), :target_post_id)
+ }
+ end
+
+ defp related_posts(links, key) do
+ Enum.map(links, fn link ->
+ case Repo.get(Post, Map.fetch!(link, key)) do
+ %Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id}
+ _other -> nil
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ defp translation_flags(post, canonical_language, active_language, translations) do
+ canonical = %{language: canonical_language, flag: I18n.flag(canonical_language), status: Atom.to_string(post.status || :draft), active: active_language == canonical_language, label: canonical_language}
+
+ others =
+ translations
+ |> Map.values()
+ |> Enum.sort_by(& &1.language)
+ |> Enum.map(fn translation ->
+ %{
+ language: translation.language,
+ flag: I18n.flag(translation.language),
+ status: Atom.to_string(translation.status || :draft),
+ active: active_language == translation.language,
+ label: translation.language
+ }
+ end)
+
+ [canonical | others]
+ end
+
+ defp footer(post, translation, active_language, canonical_language) do
+ if active_language == canonical_language do
+ %{
+ created_at: format_timestamp(post.created_at),
+ updated_at: format_timestamp(post.updated_at),
+ published_at: format_timestamp(post.published_at)
+ }
+ else
+ %{
+ created_at: format_timestamp(translation && translation.created_at || post.created_at),
+ updated_at: format_timestamp(translation && translation.updated_at || post.updated_at),
+ published_at: format_timestamp(translation && translation.published_at)
+ }
+ end
+ end
+
+ defp format_timestamp(nil), do: ""
+
+ defp format_timestamp(timestamp) do
+ timestamp
+ |> DateTime.from_unix!(:millisecond)
+ |> Calendar.strftime("%x")
+ end
+
+ defp display_title(title, slug, fallback_id) do
+ blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled")
+ end
+
+ defp active_language_subtitle(active_language, canonical_language) do
+ if active_language == canonical_language do
+ translated("Canonical draft")
+ else
+ translated("Translation: %{language}", %{language: String.upcase(active_language)})
+ end
+ end
+
+ defp save_canonical_draft(%Post{id: post_id}, draft) do
+ Posts.update_post(post_id, %{
+ title: blank_to_nil(Map.get(draft, "title")),
+ excerpt: blank_to_nil(Map.get(draft, "excerpt")),
+ content: blank_to_nil(Map.get(draft, "content")),
+ tags: csv_to_list(Map.get(draft, "tags")),
+ categories: csv_to_list(Map.get(draft, "categories")),
+ author: blank_to_nil(Map.get(draft, "author")),
+ language: blank_to_nil(Map.get(draft, "language")),
+ do_not_translate: Map.get(draft, "do_not_translate", false),
+ template_slug: blank_to_nil(Map.get(draft, "template_slug"))
+ })
+ end
+
+ defp save_translation_draft(post_id, language, draft) do
+ Posts.upsert_post_translation(post_id, language, %{
+ title: Map.get(draft, "title", ""),
+ excerpt: blank_to_nil(Map.get(draft, "excerpt")),
+ content: blank_to_nil(Map.get(draft, "content"))
+ })
+ end
+
+ defp maybe_publish_post({:ok, %Post{}}, post_id, :publish), do: Posts.publish_post(post_id)
+ defp maybe_publish_post(result, _post_id, _action), do: result
+
+ defp maybe_publish_translation({:ok, _translation}, post_id, language, :publish), do: Posts.publish_post_translation(post_id, language)
+ defp maybe_publish_translation(result, _post_id, _language, _action), do: result
+end
\ No newline at end of file
diff --git a/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex b/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex
new file mode 100644
index 0000000..892a834
--- /dev/null
+++ b/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+ <%= for flag <- @post_editor.translation_flags do %>
+
+ <% end %>
+
+
+
+ <%= editor_toolbar(assigns) %>
+
+
+
+
+
\ No newline at end of file
diff --git a/priv/ui/app.css b/priv/ui/app.css
index d7d078a..7b247ee 100644
--- a/priv/ui/app.css
+++ b/priv/ui/app.css
@@ -835,6 +835,238 @@ button {
border-bottom: 1px solid var(--vscode-panel-border);
}
+.post-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 14px 16px 18px;
+}
+
+.post-editor-header,
+.post-editor-title-row,
+.post-editor-actions,
+.post-editor-flags-bar,
+.post-editor-links-columns,
+.post-editor-side-panel-header,
+.post-editor-side-actions,
+.post-editor-excerpt-header,
+.post-editor-body-header,
+.post-editor-mode-toggle,
+.post-editor-footer {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.post-editor-header,
+.post-editor-flags-bar,
+.post-editor-body-header,
+.post-editor-footer,
+.post-editor-side-panel-header {
+ justify-content: space-between;
+}
+
+.post-editor-heading,
+.post-editor-column,
+.post-editor-links-panel,
+.post-editor-side-panel {
+ min-width: 0;
+}
+
+.post-editor-title-row {
+ gap: 8px;
+}
+
+.post-editor-dirty-dot {
+ color: var(--vscode-editorWarning-foreground, #e2c08d);
+ font-size: 12px;
+}
+
+.post-status-badge,
+.translation-flag-button,
+.post-editor-mode-button,
+.post-editor-section-toggle {
+ border: 1px solid var(--vscode-panel-border);
+ border-radius: 4px;
+ background: transparent;
+ color: inherit;
+}
+
+.post-status-badge {
+ padding: 4px 8px;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.post-save-state {
+ color: var(--vscode-descriptionForeground);
+ font-size: 12px;
+}
+
+.post-editor-flags-bar {
+ flex-wrap: wrap;
+}
+
+.post-editor-flags,
+.post-editor-side-actions,
+.post-editor-mode-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.translation-flag-button,
+.post-editor-mode-button,
+.post-editor-section-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ cursor: pointer;
+}
+
+.translation-flag-button.is-active,
+.post-editor-mode-button.is-active,
+.post-editor-section-toggle:hover {
+ background: var(--vscode-toolbar-hoverBackground);
+}
+
+.post-editor-form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.post-editor-metadata-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.85fr);
+ gap: 16px;
+}
+
+.post-editor-metadata-grid.is-collapsed {
+ display: none;
+}
+
+.post-editor-column {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.post-editor-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.post-editor-input,
+.post-editor-textarea {
+ width: 100%;
+ border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
+ border-radius: 4px;
+ background: var(--vscode-input-background, rgba(255, 255, 255, 0.03));
+ color: var(--vscode-input-foreground, var(--vscode-foreground));
+ padding: 8px 10px;
+ font: inherit;
+}
+
+.post-editor-input.is-readonly {
+ color: var(--vscode-descriptionForeground);
+}
+
+.post-editor-textarea {
+ resize: vertical;
+ line-height: 1.5;
+}
+
+.post-editor-checkbox-field {
+ flex-direction: row;
+ align-items: center;
+}
+
+.post-editor-links-panel,
+.post-editor-side-panel {
+ border: 1px solid var(--vscode-panel-border);
+ border-radius: 6px;
+ padding: 12px;
+}
+
+.post-editor-links-columns {
+ align-items: flex-start;
+ justify-content: flex-start;
+ gap: 18px;
+ margin-top: 10px;
+}
+
+.post-editor-links-columns > div,
+.post-editor-side-panel {
+ flex: 1;
+}
+
+.post-editor-links-label,
+.post-editor-body-label,
+.post-editor-media-meta,
+.post-editor-empty {
+ color: var(--vscode-descriptionForeground);
+ font-size: 12px;
+}
+
+.post-editor-media-list {
+ list-style: none;
+ margin: 10px 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.post-editor-media-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 8px 10px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.post-editor-content-field {
+ margin: 0;
+}
+
+.post-editor-content {
+ min-height: 360px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.post-editor-preview {
+ min-height: 240px;
+ border: 1px solid var(--vscode-panel-border);
+ border-radius: 6px;
+ padding: 14px;
+ line-height: 1.6;
+}
+
+.post-editor-footer {
+ flex-wrap: wrap;
+ color: var(--vscode-descriptionForeground);
+ font-size: 12px;
+}
+
+@media (max-width: 980px) {
+ .post-editor-header,
+ .post-editor-flags-bar,
+ .post-editor-body-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .post-editor-metadata-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
.panel-shell {
height: 200px;
border-top: 1px solid var(--vscode-panel-border);
diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs
index 3120352..be36463 100644
--- a/test/bds/desktop/shell_live_test.exs
+++ b/test/bds/desktop/shell_live_test.exs
@@ -560,6 +560,99 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(class="task-list") or html =~ "No background tasks running"
end
+ test "post tabs render a real editor and drive save publish discard flows", %{project: project} do
+ {:ok, post} =
+ Posts.create_post(%{
+ project_id: project.id,
+ title: "Draft Shell Post",
+ content: "Initial body",
+ excerpt: "Initial excerpt"
+ })
+
+ {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
+
+ html =
+ render_click(view, "pin_sidebar_item", %{
+ "route" => "post",
+ "id" => post.id,
+ "title" => post.title,
+ "subtitle" => "draft"
+ })
+
+ assert html =~ ~s(data-testid="post-editor")
+ assert html =~ ~s(data-testid="post-editor-form")
+ assert html =~ ~s(name="post_editor[title]")
+ assert html =~ ~s(name="post_editor[content]")
+ assert html =~ ~s(name="post_editor[excerpt]")
+ assert html =~ ~s(data-testid="post-publish-button")
+ assert html =~ ~s(data-testid="post-discard-button")
+ refute html =~ "Desktop workbench content routed through the Elixir shell."
+
+ html =
+ view
+ |> form("[data-testid='post-editor-form']", %{
+ post_editor: %{
+ title: "Updated Shell Post",
+ content: "Updated body",
+ excerpt: "Updated excerpt",
+ tags: "alpha, beta",
+ categories: "notes, guides",
+ author: "Ada Lovelace",
+ language: "de",
+ do_not_translate: "false",
+ template_slug: ""
+ }
+ })
+ |> render_change()
+
+ assert html =~ ~s(class="tab active dirty")
+ assert html =~ "Updated Shell Post"
+
+ _html = render_click(view, "save_post_editor", %{"id" => post.id})
+
+ saved_post = Posts.get_post!(post.id)
+ assert saved_post.title == "Updated Shell Post"
+ assert saved_post.content == "Updated body"
+ assert saved_post.excerpt == "Updated excerpt"
+ assert saved_post.tags == ["alpha", "beta"]
+ assert saved_post.categories == ["notes", "guides"]
+ assert saved_post.author == "Ada Lovelace"
+ assert saved_post.language == "de"
+
+ html = render_click(view, "publish_post_editor", %{"id" => post.id})
+
+ assert html =~ ~s(data-testid="post-status-badge")
+ assert Posts.get_post!(post.id).status == :published
+
+ _html =
+ view
+ |> form("[data-testid='post-editor-form']", %{
+ post_editor: %{
+ title: "Published Shell Post",
+ content: "Draft changes after publish",
+ excerpt: "Changed after publish",
+ tags: "alpha, beta",
+ categories: "notes, guides",
+ author: "Ada Lovelace",
+ language: "de",
+ do_not_translate: "false",
+ template_slug: ""
+ }
+ })
+ |> render_change()
+
+ _html = render_click(view, "save_post_editor", %{"id" => post.id})
+ assert Posts.get_post!(post.id).status == :draft
+
+ html = render_click(view, "discard_post_editor", %{"id" => post.id})
+
+ discarded_post = Posts.get_post!(post.id)
+ assert html =~ "Updated Shell Post"
+ assert discarded_post.status == :published
+ assert discarded_post.content == nil
+ assert discarded_post.title == "Updated Shell Post"
+ end
+
defp seed_sidebar_posts(project_id) do
now = Persistence.now_ms()
diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs
index 2cd97e2..7437eeb 100644
--- a/test/bds/ui/shell_test.exs
+++ b/test/bds/ui/shell_test.exs
@@ -274,19 +274,22 @@ defmodule BDS.UI.ShellTest do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex")
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
+ overlay_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/overlay_components.ex")
+ overlay_template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex")
assert template =~ "render_editor_toolbar(assigns)"
- assert template =~ "render_shell_overlay(assigns)"
+ assert template =~ "