feat: some refactoring to make shell_live smaller
This commit is contained in:
9
PLAN.md
9
PLAN.md
@@ -13,7 +13,7 @@ The rewrite already implements most of the backend and compatibility-critical su
|
|||||||
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
|
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
|
||||||
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
|
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
|
||||||
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
|
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
|
||||||
- Desktop shell foundation: native menu definitions, 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.
|
||||||
|
|||||||
@@ -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, ¬ &1) end)
|
||||||
|
|> reload_shell(socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("toggle_post_excerpt", %{"id" => post_id}, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> update_post_editor_expanded(post_id, fn expanded -> Map.update!(expanded, :excerpt, ¬ &1) end)
|
||||||
|
|> reload_shell(socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("select_post_editor_language", %{"id" => post_id, "language" => language}, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, PostEditor.normalize_language(language, language)))
|
||||||
|
|> reload_shell(socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("open_overlay", %{"kind" => kind}, socket) do
|
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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
286
lib/bds/desktop/shell_live/overlay_components.ex
Normal file
286
lib/bds/desktop/shell_live/overlay_components.ex
Normal 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
|
||||||
219
lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex
Normal file
219
lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex
Normal 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 %>
|
||||||
404
lib/bds/desktop/shell_live/post_editor.ex
Normal file
404
lib/bds/desktop/shell_live/post_editor.ex
Normal 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
|
||||||
@@ -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>
|
||||||
232
priv/ui/app.css
232
priv/ui/app.css
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user