feat: some refactoring to make shell_live smaller

This commit is contained in:
2026-04-26 15:39:04 +02:00
parent 92fde24aa1
commit 5aefa7ae41
10 changed files with 1727 additions and 570 deletions

View File

@@ -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, 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 ### 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 ### 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. - 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 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. - 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. | | 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. | | 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 | Shared modal workflows are implemented; route registration exists, but feature-complete editors are not done. |
## Plan To Full Feature Parity ## 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. 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. 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. 4. Implement the shared modal and confirmation layer. Completed 2026-04-26.
Add the modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery flows. 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. 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. 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.

View File

@@ -3,18 +3,18 @@ defmodule BDS.Desktop.ShellLive do
use Phoenix.LiveView use Phoenix.LiveView
import Ecto.Query
import Phoenix.HTML import Phoenix.HTML
alias BDS.Desktop.{FolderPicker, Overlay, ShellCommands, ShellData} 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.Desktop.MenuBar, as: DesktopMenuBar
alias BDS.{Git, I18n, Metadata} alias BDS.{Git, Posts}
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.PostLinks alias BDS.PostLinks
alias BDS.Posts.{Post, Translation} alias BDS.Posts.Post
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
alias BDS.Tags.Tag
alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench} alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench}
@refresh_interval 1_500 @refresh_interval 1_500
@@ -59,6 +59,11 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:project_menu_open, false) |> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filters_by_view, %{})
|> assign(:sidebar_filter_panels, %{}) |> 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(:shell_overlay, nil)
|> assign(:output_entries, []) |> assign(:output_entries, [])
|> reload_shell(workbench)} |> reload_shell(workbench)}
@@ -309,11 +314,62 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, reload_shell(socket, workbench)} {:noreply, reload_shell(socket, workbench)}
end 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, &not &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, &not &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 def handle_event("open_overlay", %{"kind" => kind}, socket) do
overlay = 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 %{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 end
{:noreply, assign(socket, :shell_overlay, overlay)} {:noreply, assign(socket, :shell_overlay, overlay)}
@@ -345,7 +401,7 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event("overlay_set_tab", %{"tab" => tab}, socket) do 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 end
def handle_event("overlay_update_form", %{"overlay" => params}, socket) do def handle_event("overlay_update_form", %{"overlay" => params}, socket) do
@@ -365,7 +421,7 @@ defmodule BDS.Desktop.ShellLive do
%{kind: :insert_link} -> %{kind: :insert_link} ->
case Overlay.insert_link_result(overlay, id) do case Overlay.insert_link_result(overlay, id) do
nil -> socket 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 end
%{kind: :insert_media} -> %{kind: :insert_media} ->
@@ -397,7 +453,7 @@ defmodule BDS.Desktop.ShellLive do
case {overlay.external_url, String.trim(overlay.external_text || "")} do case {overlay.external_url, String.trim(overlay.external_text || "")} do
{"", _text} -> nil {"", _text} -> nil
{url, ""} -> url {url, ""} -> url
{url, text} -> markdown_link(text, url) {url, text} -> ShellOverlayComponents.markdown_link(text, url)
end end
if details do if details do
@@ -617,6 +673,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:menu_groups, socket.assigns[:menu_groups] || titlebar_menu_groups()) |> assign(:menu_groups, socket.assigns[:menu_groups] || titlebar_menu_groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index]) |> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> assign(:current_tab, current_tab(workbench)) |> assign(:current_tab, current_tab(workbench))
|> assign_post_editor()
end end
defp render_sidebar_filters(assigns) do defp render_sidebar_filters(assigns) do
@@ -1041,263 +1098,6 @@ defmodule BDS.Desktop.ShellLive do
""" """
end 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"""
<div class="shell-overlay-backdrop ai-suggestions-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="ai-suggestions-modal" role="dialog" aria-modal="true">
<div class="ai-suggestions-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="ai-suggestions-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="ai-suggestions-modal-body">
<div class="ai-suggestions-list">
<%= for field <- @shell_overlay.fields do %>
<div class="ai-suggestion-item">
<label class="ai-suggestion-checkbox">
<input
type="checkbox"
checked={field.accepted}
disabled={field.locked}
phx-click="overlay_toggle_ai_field"
phx-value-key={field.key}
/>
<span class="checkmark"></span>
</label>
<div class="ai-suggestion-content">
<div class="ai-suggestion-label"><%= field.label %></div>
<div class="ai-suggestion-current"><%= field.current_value %></div>
<div class="ai-suggestion-value"><%= field.suggested_value %></div>
</div>
</div>
<% end %>
</div>
</div>
<div class="ai-suggestions-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= translated("Apply Selected") %></button>
</div>
</div>
</div>
"""
end
defp render_insert_link_overlay(assigns) do
~H"""
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="insert-modal" role="dialog" aria-modal="true">
<div class="insert-modal-header">
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
<div class="insert-modal-tabs">
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :internal, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="internal"><%= translated("Internal") %></button>
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :external, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="external"><%= translated("External") %></button>
</div>
</div>
<%= if @shell_overlay.active_tab == :internal do %>
<form class="insert-modal-search" phx-change="overlay_set_search">
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={translated("sidebar.searchPostsPlaceholder")} />
</form>
<div class="insert-modal-results">
<%= for result <- if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts) do %>
<button class="insert-modal-result-item" type="button" phx-click="overlay_select_result" phx-value-id={result.post_id}>
<div class="insert-modal-result-title"><%= result.title %></div>
<div class="insert-modal-result-meta"><%= result.canonical_url %></div>
</button>
<% end %>
<%= if Enum.empty?(if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts)) do %>
<div class="insert-modal-status"><%= translated("No items") %></div>
<% end %>
</div>
<% else %>
<form class="insert-modal-external" phx-change="overlay_update_form">
<label class="insert-modal-field">
<span class="insert-modal-label"><%= translated("URL") %></span>
<input class="insert-modal-input" type="text" name="overlay[url]" value={@shell_overlay.external_url} />
</label>
<label class="insert-modal-field">
<span class="insert-modal-label"><%= translated("Display Text") %></span>
<input class="insert-modal-input" type="text" name="overlay[text]" value={@shell_overlay.external_text} />
</label>
<button class="insert-modal-submit" type="button" phx-click="overlay_insert_external"><%= translated("Insert") %></button>
</form>
<% end %>
</div>
</div>
"""
end
defp render_insert_media_overlay(assigns) do
~H"""
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="insert-modal" role="dialog" aria-modal="true">
<div class="insert-modal-header">
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
</div>
<form class="insert-modal-search" phx-change="overlay_set_search">
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={translated("sidebar.searchMediaPlaceholder")} />
</form>
<div class="insert-modal-results insert-modal-media-grid">
<%= for result <- @shell_overlay.results do %>
<button class="insert-modal-media-item" type="button" phx-click="overlay_select_result" phx-value-id={result.media_id}>
<%= if result.thumbnail_url do %>
<img class="insert-modal-media-thumb" src={result.thumbnail_url} alt="" loading="lazy" />
<% else %>
<span class="insert-modal-media-fallback"><%= result.original_name %></span>
<% end %>
<span class="insert-modal-media-title"><%= result.title %></span>
</button>
<% end %>
</div>
</div>
</div>
"""
end
defp render_language_picker_overlay(assigns) do
~H"""
<div class="shell-overlay-backdrop language-picker-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="language-picker-modal" role="dialog" aria-modal="true">
<div class="language-picker-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="language-picker-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="language-picker-modal-body">
<div class="language-picker-label"><%= translated("Available languages") %></div>
<div class="language-picker-options">
<%= for target <- @shell_overlay.available_targets do %>
<button class="language-picker-option" type="button" phx-click="overlay_select_language" phx-value-code={target.code}>
<span class="language-picker-flag"><%= target.flag_emoji %></span>
<span class="language-picker-name"><%= target.name %></span>
<%= if target.has_existing_translation do %>
<span class="language-picker-status"><%= target.existing_status %></span>
<% end %>
</button>
<% end %>
</div>
</div>
</div>
</div>
"""
end
defp render_confirm_delete_overlay(assigns) do
~H"""
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
<div class="confirm-delete-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="confirm-delete-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="confirm-delete-modal-body">
<div class="confirm-delete-message"><strong><%= @shell_overlay.entity_name %></strong></div>
<%= if @shell_overlay.reference_count > 0 do %>
<div class="confirm-delete-warning">
<div class="warning-content">
<strong><%= translated("This item is referenced by:") %></strong>
<ul class="reference-list">
<%= for title <- @shell_overlay.reference_list do %>
<li><span class="reference-title"><%= title %></span></li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
<div class="confirm-delete-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
<button class="button-delete" type="button" phx-click="overlay_confirm"><%= translated("Delete") %></button>
</div>
</div>
</div>
"""
end
defp render_confirm_dialog_overlay(assigns) do
~H"""
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
<div class="confirm-delete-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="confirm-delete-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="confirm-delete-modal-body">
<div class="confirm-delete-message"><%= @shell_overlay.message %></div>
</div>
<div class="confirm-delete-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= translated("Confirm") %></button>
</div>
</div>
</div>
"""
end
defp render_gallery_overlay(assigns) do
~H"""
<div class="shell-overlay-backdrop gallery-overlay-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="gallery-overlay" role="dialog" aria-modal="true">
<div class="gallery-overlay-header">
<h2><%= translated("Gallery") %></h2>
<button class="gallery-overlay-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="gallery-overlay-grid">
<%= for image <- @shell_overlay.images do %>
<button class="gallery-overlay-item" type="button" phx-click="overlay_select_gallery_image" phx-value-id={image.media_id}>
<img src={image.thumbnail_url} alt={image.alt_text || ""} loading="lazy" />
</button>
<% end %>
</div>
</div>
<%= if @shell_overlay.lightbox do %>
<div class="lightbox-overlay">
<button class="shell-overlay-dismiss" type="button" phx-click="overlay_close_lightbox" aria-label={translated("Cancel")}></button>
<div class="lightbox-container">
<button class="lightbox-close" type="button" phx-click="overlay_close_lightbox">×</button>
<%= if @shell_overlay.lightbox.total_count > 1 do %>
<button class="lightbox-nav lightbox-prev" type="button" phx-click="overlay_lightbox_previous"></button>
<button class="lightbox-nav lightbox-next" type="button" phx-click="overlay_lightbox_next"></button>
<% end %>
<div class="lightbox-image-container">
<img class="lightbox-image" src={@shell_overlay.lightbox.image_url} alt={@shell_overlay.lightbox.alt_text || ""} />
</div>
<div class="lightbox-footer">
<div class="lightbox-caption"><%= @shell_overlay.lightbox.title %></div>
<div class="lightbox-counter"><%= @shell_overlay.lightbox.current_index + 1 %> / <%= @shell_overlay.lightbox.total_count %></div>
</div>
</div>
</div>
<% end %>
</div>
"""
end
defp render_task_entries(assigns) do defp render_task_entries(assigns) do
~H""" ~H"""
<%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %> <%= 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)) Enum.find(tabs, &(&1.type == type and &1.id == id))
end 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 defp sync_layout(workbench, params) do
workbench workbench
|> maybe_set_sidebar_width(Map.get(params, "sidebar_width")) |> 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 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 defp update_shell_overlay(socket, updater) do
case socket.assigns[:shell_overlay] do case socket.assigns[:shell_overlay] do
nil -> socket nil -> socket
@@ -2434,34 +2152,6 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:shell_overlay, nil) |> assign(:shell_overlay, nil)
end 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 defp media_thumbnail_glyph(mime_type) do
case String.split(to_string(mime_type || ""), "/", parts: 2) do case String.split(to_string(mime_type || ""), "/", parts: 2) do
["image", _rest] -> "IMG" ["image", _rest] -> "IMG"

View File

@@ -363,6 +363,9 @@
</div> </div>
</div> </div>
</div> </div>
<% else %>
<%= if @current_tab.type == :post and @post_editor do %>
<PostEditor.post_editor post_editor={@post_editor} toolbar_buttons={editor_toolbar_buttons(@current_tab)} />
<% else %> <% else %>
<div class="editor-frame"> <div class="editor-frame">
<section class="editor-main"> <section class="editor-main">
@@ -388,6 +391,7 @@
</aside> </aside>
</div> </div>
<% end %> <% end %>
<% end %>
</section> </section>
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel"> <section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
@@ -624,5 +628,5 @@
</div> </div>
</footer> </footer>
<%= render_shell_overlay(assigns) %> <ShellOverlayComponents.shell_overlay shell_overlay={@shell_overlay} />
</div> </div>

View File

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

View File

@@ -0,0 +1,219 @@
<%= if @shell_overlay do %>
<%= case @shell_overlay.kind do %>
<% :ai_suggestions -> %>
<div class="shell-overlay-backdrop ai-suggestions-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="ai-suggestions-modal" role="dialog" aria-modal="true">
<div class="ai-suggestions-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="ai-suggestions-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="ai-suggestions-modal-body">
<div class="ai-suggestions-list">
<%= for field <- @shell_overlay.fields do %>
<div class="ai-suggestion-item">
<label class="ai-suggestion-checkbox">
<input
type="checkbox"
checked={field.accepted}
disabled={field.locked}
phx-click="overlay_toggle_ai_field"
phx-value-key={field.key}
/>
<span class="checkmark"></span>
</label>
<div class="ai-suggestion-content">
<div class="ai-suggestion-label"><%= field.label %></div>
<div class="ai-suggestion-current"><%= field.current_value %></div>
<div class="ai-suggestion-value"><%= field.suggested_value %></div>
</div>
</div>
<% end %>
</div>
</div>
<div class="ai-suggestions-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= translated("Apply Selected") %></button>
</div>
</div>
</div>
<% :insert_link -> %>
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="insert-modal" role="dialog" aria-modal="true">
<div class="insert-modal-header">
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
<div class="insert-modal-tabs">
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :internal, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="internal"><%= translated("Internal") %></button>
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :external, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="external"><%= translated("External") %></button>
</div>
</div>
<%= if @shell_overlay.active_tab == :internal do %>
<form class="insert-modal-search" phx-change="overlay_set_search">
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={translated("sidebar.searchPostsPlaceholder")} />
</form>
<div class="insert-modal-results">
<%= for result <- if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts) do %>
<button class="insert-modal-result-item" type="button" phx-click="overlay_select_result" phx-value-id={result.post_id}>
<div class="insert-modal-result-title"><%= result.title %></div>
<div class="insert-modal-result-meta"><%= result.canonical_url %></div>
</button>
<% end %>
<%= if Enum.empty?(if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts)) do %>
<div class="insert-modal-status"><%= translated("No items") %></div>
<% end %>
</div>
<% else %>
<form class="insert-modal-external" phx-change="overlay_update_form">
<label class="insert-modal-field">
<span class="insert-modal-label"><%= translated("URL") %></span>
<input class="insert-modal-input" type="text" name="overlay[url]" value={@shell_overlay.external_url} />
</label>
<label class="insert-modal-field">
<span class="insert-modal-label"><%= translated("Display Text") %></span>
<input class="insert-modal-input" type="text" name="overlay[text]" value={@shell_overlay.external_text} />
</label>
<button class="insert-modal-submit" type="button" phx-click="overlay_insert_external"><%= translated("Insert") %></button>
</form>
<% end %>
</div>
</div>
<% :insert_media -> %>
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="insert-modal" role="dialog" aria-modal="true">
<div class="insert-modal-header">
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
</div>
<form class="insert-modal-search" phx-change="overlay_set_search">
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={translated("sidebar.searchMediaPlaceholder")} />
</form>
<div class="insert-modal-results insert-modal-media-grid">
<%= for result <- @shell_overlay.results do %>
<button class="insert-modal-media-item" type="button" phx-click="overlay_select_result" phx-value-id={result.media_id}>
<%= if result.thumbnail_url do %>
<img class="insert-modal-media-thumb" src={result.thumbnail_url} alt="" loading="lazy" />
<% else %>
<span class="insert-modal-media-fallback"><%= result.original_name %></span>
<% end %>
<span class="insert-modal-media-title"><%= result.title %></span>
</button>
<% end %>
</div>
</div>
</div>
<% :language_picker -> %>
<div class="shell-overlay-backdrop language-picker-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="language-picker-modal" role="dialog" aria-modal="true">
<div class="language-picker-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="language-picker-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="language-picker-modal-body">
<div class="language-picker-label"><%= translated("Available languages") %></div>
<div class="language-picker-options">
<%= for target <- @shell_overlay.available_targets do %>
<button class="language-picker-option" type="button" phx-click="overlay_select_language" phx-value-code={target.code}>
<span class="language-picker-flag"><%= target.flag_emoji %></span>
<span class="language-picker-name"><%= target.name %></span>
<%= if target.has_existing_translation do %>
<span class="language-picker-status"><%= target.existing_status %></span>
<% end %>
</button>
<% end %>
</div>
</div>
</div>
</div>
<% :confirm_delete -> %>
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
<div class="confirm-delete-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="confirm-delete-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="confirm-delete-modal-body">
<div class="confirm-delete-message"><strong><%= @shell_overlay.entity_name %></strong></div>
<%= if @shell_overlay.reference_count > 0 do %>
<div class="confirm-delete-warning">
<div class="warning-content">
<strong><%= translated("This item is referenced by:") %></strong>
<ul class="reference-list">
<%= for title <- @shell_overlay.reference_list do %>
<li><span class="reference-title"><%= title %></span></li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
<div class="confirm-delete-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
<button class="button-delete" type="button" phx-click="overlay_confirm"><%= translated("Delete") %></button>
</div>
</div>
</div>
<% :confirm_dialog -> %>
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
<div class="confirm-delete-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="confirm-delete-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="confirm-delete-modal-body">
<div class="confirm-delete-message"><%= @shell_overlay.message %></div>
</div>
<div class="confirm-delete-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= translated("Confirm") %></button>
</div>
</div>
</div>
<% :gallery -> %>
<div class="shell-overlay-backdrop gallery-overlay-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="gallery-overlay" role="dialog" aria-modal="true">
<div class="gallery-overlay-header">
<h2><%= translated("Gallery") %></h2>
<button class="gallery-overlay-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="gallery-overlay-grid">
<%= for image <- @shell_overlay.images do %>
<button class="gallery-overlay-item" type="button" phx-click="overlay_select_gallery_image" phx-value-id={image.media_id}>
<img src={image.thumbnail_url} alt={image.alt_text || ""} loading="lazy" />
</button>
<% end %>
</div>
</div>
<%= if @shell_overlay.lightbox do %>
<div class="lightbox-overlay">
<button class="shell-overlay-dismiss" type="button" phx-click="overlay_close_lightbox" aria-label={translated("Cancel")}></button>
<div class="lightbox-container">
<button class="lightbox-close" type="button" phx-click="overlay_close_lightbox">×</button>
<%= if @shell_overlay.lightbox.total_count > 1 do %>
<button class="lightbox-nav lightbox-prev" type="button" phx-click="overlay_lightbox_previous"></button>
<% end %>
<img class="lightbox-image" src={@shell_overlay.lightbox.image_url} alt={@shell_overlay.lightbox.alt_text || ""} />
<%= if @shell_overlay.lightbox.total_count > 1 do %>
<button class="lightbox-nav lightbox-next" type="button" phx-click="overlay_lightbox_next"></button>
<div class="lightbox-counter"><%= @shell_overlay.lightbox.position %>/<%= @shell_overlay.lightbox.total_count %></div>
<% end %>
</div>
</div>
<% end %>
</div>
<% _other -> %>
<% end %>
<% end %>

View File

@@ -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 %>
<div class="editor-toolbar">
<%= for button <- @toolbar_buttons do %>
<button
class={["editor-toolbar-button", if(button.destructive, do: "is-destructive")]}
data-testid="editor-toolbar-overlay-button"
type="button"
phx-click="open_overlay"
phx-value-kind={button.kind}
>
<%= translated(button.label) %>
</button>
<% end %>
</div>
<% 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

View File

@@ -0,0 +1,227 @@
<div class="post-editor" data-testid="post-editor">
<div class="post-editor-header">
<div class="post-editor-heading">
<div class="editor-kicker"><%= translated("Post") %></div>
<div class="post-editor-title-row">
<h1 class="editor-title" data-testid="editor-title"><%= @post_editor.display_title %></h1>
<%= if @post_editor.dirty? do %>
<span class="post-editor-dirty-dot">●</span>
<% end %>
</div>
<p class="editor-subtitle"><%= @post_editor.subtitle %></p>
</div>
<div class="post-editor-actions">
<span class={["post-status-badge", "status-#{@post_editor.status}"]} data-testid="post-status-badge">
<%= post_status_label(@post_editor.status) %>
</span>
<span class="post-save-state"><%= post_editor_save_state_label(@post_editor.save_state) %></span>
<button class="editor-toolbar-button" type="button" phx-click="save_post_editor" phx-value-id={@post_editor.id}>
<%= translated("Save") %>
</button>
<button class="editor-toolbar-button" data-testid="post-publish-button" type="button" phx-click="publish_post_editor" phx-value-id={@post_editor.id}>
<%= translated("Publish") %>
</button>
<button class="editor-toolbar-button" data-testid="post-discard-button" type="button" phx-click="discard_post_editor" phx-value-id={@post_editor.id}>
<%= translated("Discard") %>
</button>
<button class="editor-toolbar-button is-destructive" data-testid="post-delete-button" type="button" phx-click="delete_post_editor" phx-value-id={@post_editor.id}>
<%= translated("Delete") %>
</button>
</div>
</div>
<div class="post-editor-flags-bar">
<button class="post-editor-section-toggle" type="button" phx-click="toggle_post_metadata" phx-value-id={@post_editor.id}>
<span><%= if @post_editor.metadata_expanded, do: "▼", else: "▶" %></span>
<span><%= translated("Metadata") %></span>
</button>
<div class="post-editor-flags">
<%= for flag <- @post_editor.translation_flags do %>
<button
class={[
"translation-flag-button",
if(flag.active, do: "is-active"),
"status-#{flag.status}"
]}
type="button"
phx-click="select_post_editor_language"
phx-value-id={@post_editor.id}
phx-value-language={flag.language}
title={flag.label}
>
<span><%= flag.flag %></span>
<span><%= String.upcase(flag.language) %></span>
</button>
<% end %>
</div>
</div>
<%= editor_toolbar(assigns) %>
<form class="post-editor-form" data-testid="post-editor-form" phx-change="change_post_editor">
<div class={["post-editor-metadata-grid", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}>
<div class="post-editor-column">
<label class="post-editor-field">
<span><%= translated("Title") %></span>
<input class="post-editor-input" type="text" name="post_editor[title]" value={@post_editor.form["title"]} />
</label>
<label class="post-editor-field">
<span><%= translated("Tags") %></span>
<input class="post-editor-input" type="text" name="post_editor[tags]" value={@post_editor.form["tags"]} list={"post-editor-tags-#{@post_editor.id}"} />
<datalist id={"post-editor-tags-#{@post_editor.id}"}>
<%= for tag_name <- @post_editor.tag_options do %>
<option value={tag_name}></option>
<% end %>
</datalist>
</label>
<label class="post-editor-field">
<span><%= translated("Author") %></span>
<input class="post-editor-input" type="text" name="post_editor[author]" value={@post_editor.form["author"]} />
</label>
<label class="post-editor-field">
<span><%= translated("Language") %></span>
<select class="post-editor-input" name="post_editor[language]" disabled={not @post_editor.editing_canonical?}>
<%= for language <- @post_editor.languages do %>
<option value={language} selected={language == @post_editor.form["language"]}><%= String.upcase(language) %></option>
<% end %>
</select>
</label>
<label class="post-editor-field post-editor-checkbox-field">
<input type="hidden" name="post_editor[do_not_translate]" value="false" />
<input type="checkbox" name="post_editor[do_not_translate]" value="true" checked={@post_editor.form["do_not_translate"]} disabled={not @post_editor.editing_canonical?} />
<span><%= translated("Do Not Translate") %></span>
</label>
<label class="post-editor-field">
<span><%= translated("Slug") %></span>
<input class="post-editor-input is-readonly" type="text" readonly value={@post_editor.slug} />
</label>
<label class="post-editor-field">
<span><%= translated("Categories") %></span>
<input class="post-editor-input" type="text" name="post_editor[categories]" value={@post_editor.form["categories"]} list={"post-editor-categories-#{@post_editor.id}"} disabled={not @post_editor.editing_canonical?} />
<datalist id={"post-editor-categories-#{@post_editor.id}"}>
<%= for category <- @post_editor.category_options do %>
<option value={category}></option>
<% end %>
</datalist>
</label>
<label class="post-editor-field">
<span><%= translated("Template") %></span>
<select class="post-editor-input" name="post_editor[template_slug]" disabled={not @post_editor.editing_canonical?}>
<option value=""><%= translated("Default") %></option>
<%= for template <- @post_editor.template_options do %>
<option value={template.slug} selected={template.slug == @post_editor.form["template_slug"]}><%= template.title %></option>
<% end %>
</select>
</label>
<div class="post-editor-links-panel">
<strong><%= translated("Post Links") %></strong>
<div class="post-editor-links-columns">
<div>
<span class="post-editor-links-label"><%= translated("Backlinks") %></span>
<%= if Enum.any?(@post_editor.post_links.backlinks) do %>
<ul class="editor-list compact">
<%= for item <- @post_editor.post_links.backlinks do %>
<li><%= item.title %></li>
<% end %>
</ul>
<% else %>
<span class="post-editor-empty"><%= translated("No items") %></span>
<% end %>
</div>
<div>
<span class="post-editor-links-label"><%= translated("Links To") %></span>
<%= if Enum.any?(@post_editor.post_links.outlinks) do %>
<ul class="editor-list compact">
<%= for item <- @post_editor.post_links.outlinks do %>
<li><%= item.title %></li>
<% end %>
</ul>
<% else %>
<span class="post-editor-empty"><%= translated("No items") %></span>
<% end %>
</div>
</div>
</div>
</div>
<div class="post-editor-column post-editor-side-panel">
<div class="post-editor-side-panel-header">
<strong><%= translated("Linked Media") %></strong>
<div class="post-editor-side-actions">
<button class="editor-toolbar-button" type="button" phx-click="open_overlay" phx-value-kind="insert_media"><%= translated("Insert Media") %></button>
<button class="editor-toolbar-button" type="button" phx-click="open_overlay" phx-value-kind="gallery"><%= translated("Gallery") %></button>
</div>
</div>
<%= if Enum.any?(@post_editor.linked_media) do %>
<ul class="post-editor-media-list">
<%= for item <- @post_editor.linked_media do %>
<li class="post-editor-media-item">
<span class="post-editor-media-title"><%= item.name %></span>
<span class="post-editor-media-meta"><%= translated("Order") %>: <%= item.sort_order %></span>
</li>
<% end %>
</ul>
<% else %>
<div class="post-editor-empty"><%= translated("No linked media") %></div>
<% end %>
</div>
</div>
<div class="post-editor-excerpt-header">
<button class="post-editor-section-toggle" type="button" phx-click="toggle_post_excerpt" phx-value-id={@post_editor.id}>
<span><%= if @post_editor.excerpt_expanded, do: "▼", else: "▶" %></span>
<span><%= translated("Excerpt") %></span>
</button>
</div>
<%= if @post_editor.excerpt_expanded do %>
<label class="post-editor-field">
<textarea class="post-editor-textarea post-editor-excerpt" name="post_editor[excerpt]" rows="4"><%= @post_editor.form["excerpt"] %></textarea>
</label>
<% end %>
<div class="post-editor-body-header">
<span class="post-editor-body-label"><%= translated("Content") %></span>
<div class="post-editor-mode-toggle">
<%= for mode <- [:visual, :markdown, :preview] do %>
<button
class={["post-editor-mode-button", if(@post_editor.mode == mode, do: "is-active")]}
type="button"
phx-click="set_post_editor_mode"
phx-value-id={@post_editor.id}
phx-value-mode={mode}
>
<%= post_editor_mode_label(mode) %>
</button>
<% end %>
</div>
</div>
<%= if @post_editor.mode == :preview do %>
<div class="post-editor-preview" data-testid="post-editor-preview"><%= raw(Earmark.as_html!(@post_editor.form["content"] || "")) %></div>
<% else %>
<label class="post-editor-field post-editor-content-field">
<textarea class="post-editor-textarea post-editor-content" data-testid="post-editor-content" name="post_editor[content]" rows="18"><%= @post_editor.form["content"] %></textarea>
</label>
<% end %>
</form>
<div class="post-editor-footer">
<span><strong><%= translated("Created") %>:</strong> <%= @post_editor.footer.created_at %></span>
<span><strong><%= translated("Updated") %>:</strong> <%= @post_editor.footer.updated_at %></span>
<%= if @post_editor.footer.published_at do %>
<span><strong><%= translated("Published") %>:</strong> <%= @post_editor.footer.published_at %></span>
<% end %>
</div>
</div>

View File

@@ -835,6 +835,238 @@ button {
border-bottom: 1px solid var(--vscode-panel-border); 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 { .panel-shell {
height: 200px; height: 200px;
border-top: 1px solid var(--vscode-panel-border); border-top: 1px solid var(--vscode-panel-border);

View File

@@ -560,6 +560,99 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(class="task-list") or html =~ "No background tasks running" assert html =~ ~s(class="task-list") or html =~ "No background tasks running"
end 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 defp seed_sidebar_posts(project_id) do
now = Persistence.now_ms() now = Persistence.now_ms()

View File

@@ -274,19 +274,22 @@ defmodule BDS.UI.ShellTest do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") 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") 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") 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_editor_toolbar(assigns)"
assert template =~ "render_shell_overlay(assigns)" assert template =~ "<ShellOverlayComponents.shell_overlay"
assert live_ex =~ ~s(def handle_event("open_overlay") assert live_ex =~ ~s(def handle_event("open_overlay")
assert live_ex =~ ~s(def handle_event("close_overlay") assert live_ex =~ ~s(def handle_event("close_overlay")
assert live_ex =~ ~s(def handle_event("overlay_keydown") assert live_ex =~ ~s(def handle_event("overlay_keydown")
assert live_ex =~ "ai-suggestions-modal" assert overlay_ex =~ "def context(assigns, tab_title, tab_subtitle)"
assert live_ex =~ "confirm-delete-modal" assert overlay_template =~ "ai-suggestions-modal"
assert live_ex =~ "insert-modal" assert overlay_template =~ "confirm-delete-modal"
assert live_ex =~ "language-picker-modal" assert overlay_template =~ "insert-modal"
assert live_ex =~ "gallery-overlay" assert overlay_template =~ "language-picker-modal"
assert live_ex =~ "lightbox-overlay" assert overlay_template =~ "gallery-overlay"
assert overlay_template =~ "lightbox-overlay"
assert css =~ ".shell-overlay-backdrop" assert css =~ ".shell-overlay-backdrop"
assert css =~ ".ai-suggestions-modal-backdrop" assert css =~ ".ai-suggestions-modal-backdrop"