From 5aefa7ae41dc7d6b132ddeea348e7cb934ea6ec7 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 26 Apr 2026 15:39:04 +0200 Subject: [PATCH] feat: some refactoring to make shell_live smaller --- PLAN.md | 9 +- lib/bds/desktop/shell_live.ex | 764 ++++++------------ lib/bds/desktop/shell_live/index.html.heex | 46 +- .../desktop/shell_live/overlay_components.ex | 286 +++++++ .../overlay_html/shell_overlay.html.heex | 219 +++++ lib/bds/desktop/shell_live/post_editor.ex | 404 +++++++++ .../post_editor_html/post_editor.html.heex | 227 ++++++ priv/ui/app.css | 232 ++++++ test/bds/desktop/shell_live_test.exs | 93 +++ test/bds/ui/shell_test.exs | 17 +- 10 files changed, 1727 insertions(+), 570 deletions(-) create mode 100644 lib/bds/desktop/shell_live/overlay_components.ex create mode 100644 lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex create mode 100644 lib/bds/desktop/shell_live/post_editor.ex create mode 100644 lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex diff --git a/PLAN.md b/PLAN.md index ea850fa..7591afd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -13,7 +13,7 @@ The rewrite already implements most of the backend and compatibility-critical su - Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting. - Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata. - Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync. -- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, status bar, project switcher, shell command routing, template-backed shell rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, UI language switching, project dropdown actions, output/post-link/git lower-panel content, and browser-native menu bridging. +- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, status bar, project switcher, shell command routing, template-backed shell rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, UI language switching, project dropdown actions, output/post-link/git lower-panel content, browser-native menu bridging, and the shared modal/overlay layer for AI suggestions, picker flows, confirmations, and gallery/lightbox interactions. ### Implemented But Not Yet At Parity @@ -24,7 +24,6 @@ The rewrite already implements most of the backend and compatibility-critical su ### Missing Or Materially Incomplete -- Shared modal and overlay system: AI suggestions modal, confirm-delete variants, merge confirmation, pickers, and gallery-style overlays. - Rich route-specific editors for post, media, settings, tags, chat, script, template, and misc surfaces. - Full UI wiring for create/import/publish/preview/edit-menu flows described by the editor specs. - Full parity validation against the old application for every spec-defined edge case in editor behavior, media processing details, and cross-feature action chains. @@ -40,7 +39,7 @@ Ordered from base contracts upward: | Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. | | Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and a LiveView-owned shell frame are in place; route bodies remain generic until the editor UX phase. | | Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. | -| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial to missing | Route registration exists, but feature-complete editors and modal workflows are not done. | +| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial | Shared modal workflows are implemented; route registration exists, but feature-complete editors are not done. | ## Plan To Full Feature Parity @@ -55,8 +54,8 @@ The remaining work needs to proceed from base contracts upward. Later phases sho 3. Finish the desktop shell primitives. Completed 2026-04-26. Route state, registry-backed shell command coverage, panel fallback integration, menu/native-command wiring, template-backed LiveView rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, project dropdown actions, UI language switching, real output/post-link/git lower-panel content, and native-menu event bridging now cover the old shell frame behavior while preserving the legacy layout and styling. -4. Implement the shared modal and confirmation layer. - Add the modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery flows. +4. Implement the shared modal and confirmation layer. Completed 2026-04-26. + The LiveView shell now owns the shared modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery/lightbox flows, with overlay state isolated in a pure module and covered by focused tests. 5. Build feature-complete editors. Replace generic editor bodies with real editors for posts, media, settings, tags, chat, scripts, templates, and misc maintenance views, including save/discard/publish/delete/import flows. diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 0fb379c..eec1e63 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -3,18 +3,18 @@ defmodule BDS.Desktop.ShellLive do use Phoenix.LiveView - import Ecto.Query import Phoenix.HTML alias BDS.Desktop.{FolderPicker, Overlay, ShellCommands, ShellData} + alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents + alias BDS.Desktop.ShellLive.PostEditor alias BDS.Desktop.MenuBar, as: DesktopMenuBar - alias BDS.{Git, I18n, Metadata} + alias BDS.{Git, Posts} alias BDS.Media.Media alias BDS.PostLinks - alias BDS.Posts.{Post, Translation} + alias BDS.Posts.Post alias BDS.Projects alias BDS.Repo - alias BDS.Tags.Tag alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench} @refresh_interval 1_500 @@ -59,6 +59,11 @@ defmodule BDS.Desktop.ShellLive do |> assign(:project_menu_open, false) |> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filter_panels, %{}) + |> assign(:post_editor_drafts, %{}) + |> assign(:post_editor_active_languages, %{}) + |> assign(:post_editor_modes, %{}) + |> assign(:post_editor_expanded, %{}) + |> assign(:post_editor_save_states, %{}) |> assign(:shell_overlay, nil) |> assign(:output_entries, []) |> reload_shell(workbench)} @@ -309,11 +314,62 @@ defmodule BDS.Desktop.ShellLive do {:noreply, reload_shell(socket, workbench)} end + def handle_event("change_post_editor", %{"post_editor" => params}, socket) do + {:noreply, update_post_editor(socket, params)} + end + + def handle_event("save_post_editor", %{"id" => post_id}, socket) do + {:noreply, persist_post_editor(socket, post_id, :save)} + end + + def handle_event("publish_post_editor", %{"id" => post_id}, socket) do + {:noreply, persist_post_editor(socket, post_id, :publish)} + end + + def handle_event("discard_post_editor", %{"id" => post_id}, socket) do + {:noreply, discard_post_editor(socket, post_id)} + end + + def handle_event("delete_post_editor", %{"id" => post_id}, socket) do + {:noreply, delete_post_editor(socket, post_id)} + end + + def handle_event("set_post_editor_mode", %{"id" => post_id, "mode" => mode}, socket) do + {:noreply, + socket + |> assign(:post_editor_modes, Map.put(socket.assigns.post_editor_modes, post_id, PostEditor.normalize_mode(mode))) + |> reload_shell(socket.assigns.workbench)} + end + + def handle_event("toggle_post_metadata", %{"id" => post_id}, socket) do + {:noreply, + socket + |> update_post_editor_expanded(post_id, fn expanded -> Map.update!(expanded, :metadata, ¬ &1) end) + |> reload_shell(socket.assigns.workbench)} + end + + def handle_event("toggle_post_excerpt", %{"id" => post_id}, socket) do + {:noreply, + socket + |> update_post_editor_expanded(post_id, fn expanded -> Map.update!(expanded, :excerpt, ¬ &1) end) + |> reload_shell(socket.assigns.workbench)} + end + + def handle_event("select_post_editor_language", %{"id" => post_id, "language" => language}, socket) do + {:noreply, + socket + |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, PostEditor.normalize_language(language, language))) + |> reload_shell(socket.assigns.workbench)} + end + def handle_event("open_overlay", %{"kind" => kind}, socket) do overlay = - with overlay_kind when not is_nil(overlay_kind) <- overlay_kind(kind), + with overlay_kind when not is_nil(overlay_kind) <- ShellOverlayComponents.kind(kind), %{type: route} <- socket.assigns[:current_tab] do - Overlay.open(route, overlay_kind, overlay_context(socket)) + tab = socket.assigns.current_tab + title = tab_title(tab, socket.assigns.tab_meta) + subtitle = tab_subtitle(tab, socket.assigns.tab_meta) + Overlay.open(route, overlay_kind, ShellOverlayComponents.context(socket.assigns, title, subtitle)) end {:noreply, assign(socket, :shell_overlay, overlay)} @@ -345,7 +401,7 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("overlay_set_tab", %{"tab" => tab}, socket) do - {:noreply, update_shell_overlay(socket, &Overlay.set_active_tab(&1, overlay_tab(tab)))} + {:noreply, update_shell_overlay(socket, &Overlay.set_active_tab(&1, ShellOverlayComponents.tab(tab)))} end def handle_event("overlay_update_form", %{"overlay" => params}, socket) do @@ -365,7 +421,7 @@ defmodule BDS.Desktop.ShellLive do %{kind: :insert_link} -> case Overlay.insert_link_result(overlay, id) do nil -> socket - result -> close_overlay_with_output(socket, overlay.title, markdown_link(result.title, result.canonical_url)) + result -> close_overlay_with_output(socket, overlay.title, ShellOverlayComponents.markdown_link(result.title, result.canonical_url)) end %{kind: :insert_media} -> @@ -397,7 +453,7 @@ defmodule BDS.Desktop.ShellLive do case {overlay.external_url, String.trim(overlay.external_text || "")} do {"", _text} -> nil {url, ""} -> url - {url, text} -> markdown_link(text, url) + {url, text} -> ShellOverlayComponents.markdown_link(text, url) end if details do @@ -617,6 +673,7 @@ defmodule BDS.Desktop.ShellLive do |> assign(:menu_groups, socket.assigns[:menu_groups] || titlebar_menu_groups()) |> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index]) |> assign(:current_tab, current_tab(workbench)) + |> assign_post_editor() end defp render_sidebar_filters(assigns) do @@ -1041,263 +1098,6 @@ defmodule BDS.Desktop.ShellLive do """ end - defp render_shell_overlay(%{shell_overlay: nil} = assigns) do - ~H""" - """ - end - - defp render_shell_overlay(assigns) do - case assigns.shell_overlay.kind do - :ai_suggestions -> render_ai_suggestions_overlay(assigns) - :insert_link -> render_insert_link_overlay(assigns) - :insert_media -> render_insert_media_overlay(assigns) - :language_picker -> render_language_picker_overlay(assigns) - :confirm_delete -> render_confirm_delete_overlay(assigns) - :confirm_dialog -> render_confirm_dialog_overlay(assigns) - :gallery -> render_gallery_overlay(assigns) - _other -> ~H""" - """ - end - end - - defp render_ai_suggestions_overlay(assigns) do - ~H""" -
- - -
- """ - end - - defp render_insert_link_overlay(assigns) do - ~H""" -
- - -
- """ - end - - defp render_insert_media_overlay(assigns) do - ~H""" -
- - -
- """ - end - - defp render_language_picker_overlay(assigns) do - ~H""" -
- - -
- """ - end - - defp render_confirm_delete_overlay(assigns) do - ~H""" -
- - -
- """ - end - - defp render_confirm_dialog_overlay(assigns) do - ~H""" -
- - -
- """ - end - - defp render_gallery_overlay(assigns) do - ~H""" - - """ - end - defp render_task_entries(assigns) do ~H""" <%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %> @@ -1494,6 +1294,167 @@ defmodule BDS.Desktop.ShellLive do Enum.find(tabs, &(&1.type == type and &1.id == id)) end + defp assign_post_editor(socket) do + assigns = Map.put(socket.assigns, :project_metadata, ShellOverlayComponents.project_metadata(socket.assigns.projects.active_project_id)) + assign(socket, :post_editor, PostEditor.build(assigns)) + end + + defp update_post_editor(socket, params) do + case socket.assigns.current_tab do + %{type: :post, id: post_id} -> + case Repo.get(Post, post_id) do + nil -> + socket + + %Post{} = post -> + metadata = ShellOverlayComponents.project_metadata(post.project_id) + canonical_language = PostEditor.normalize_language(post.language, metadata.main_language || "en") + current_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + requested_language = PostEditor.normalize_language(Map.get(params, "language"), current_language) + + next_language = + if current_language == canonical_language do + requested_language + else + current_language + end + + draft = PostEditor.normalize_params(params, current_language, next_language) + workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id) + + socket + |> assign(:workbench, workbench) + |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)) + |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)) + |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) + |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: draft["title"], subtitle: Atom.to_string(post.status || :draft)})) + |> maybe_drop_old_language_draft(post_id, current_language, next_language) + |> reload_shell(workbench) + end + + _other -> + socket + end + end + + defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) when current_language == next_language, + do: socket + + defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do + assign(socket, :post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language)) + end + + defp persist_post_editor(socket, post_id, action) do + case Repo.get(Post, post_id) do + nil -> + socket + + %Post{} = post -> + metadata = ShellOverlayComponents.project_metadata(post.project_id) + canonical_language = PostEditor.normalize_language(post.language, metadata.main_language || "en") + active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + draft = PostEditor.current_draft(socket.assigns, post, metadata, active_language) + + result = PostEditor.persist(post, draft, active_language, metadata, action) + + case result do + {:ok, record} -> + workbench = Workbench.clear_dirty(socket.assigns.workbench, :post, post_id) + normalized_form = PostEditor.persisted_form(Repo.get!(Post, post_id), metadata, active_language) + + socket + |> assign(:workbench, workbench) + |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, normalized_form)) + |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, PostEditor.save_state_for_action(action))) + |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: PostEditor.record_title(record, Repo.get!(Post, post_id)), subtitle: Atom.to_string(PostEditor.record_status(record))})) + |> reload_shell(workbench) + + {:error, reason} -> + socket + |> append_output_entry(translated("Post"), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench) + end + end + end + + defp discard_post_editor(socket, post_id) do + case Repo.get(Post, post_id) do + nil -> + socket + + %Post{} = post -> + metadata = ShellOverlayComponents.project_metadata(post.project_id) + canonical_language = PostEditor.normalize_language(post.language, metadata.main_language || "en") + active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + restored_result = PostEditor.discard(post, active_language, metadata) + + case restored_result do + {:ok, restored_post} -> + workbench = Workbench.clear_dirty(socket.assigns.workbench, :post, post_id) + + socket + |> assign(:workbench, workbench) + |> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language)) + |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :discarded)) + |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: restored_post.title || restored_post.slug || restored_post.id, subtitle: Atom.to_string(restored_post.status || :draft)})) + |> reload_shell(workbench) + + {:error, reason} -> + socket + |> append_output_entry(translated("Post"), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench) + end + end + end + + defp delete_post_editor(socket, post_id) do + case Posts.delete_post(post_id) do + {:ok, :deleted} -> + workbench = Workbench.close_tab(socket.assigns.workbench, :post, post_id) + + socket + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) + |> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id)) + |> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id)) + |> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id)) + |> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id)) + |> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id)) + |> reload_shell(workbench) + + {:error, reason} -> + socket + |> append_output_entry(translated("Post"), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench) + end + end + + defp update_post_editor_expanded(socket, post_id, updater) do + expanded = + socket.assigns.post_editor_expanded + |> Map.get(post_id, %{metadata: false, excerpt: false}) + |> Map.put_new(:metadata, false) + |> Map.put_new(:excerpt, false) + |> updater.() + + assign(socket, :post_editor_expanded, Map.put(socket.assigns.post_editor_expanded, post_id, expanded)) + end + + defp put_nested_map(map, key, nested_key, value) do + Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value)) + end + + defp delete_nested_map(map, key, nested_key) do + case Map.get(map, key) do + nil -> map + nested -> + case Map.delete(nested, nested_key) do + emptied when map_size(emptied) == 0 -> Map.delete(map, key) + remaining -> Map.put(map, key, remaining) + end + end + end + + defp sync_layout(workbench, params) do workbench |> maybe_set_sidebar_width(Map.get(params, "sidebar_width")) @@ -2178,249 +2139,6 @@ defmodule BDS.Desktop.ShellLive do defp assistant_message_testid(role), do: "assistant-message-#{role}" - defp overlay_context(socket) do - project_id = socket.assigns.projects.active_project_id - metadata = overlay_project_metadata(project_id) - current_tab = socket.assigns.current_tab - page_language = socket.assigns.page_language - tab_title = tab_title(current_tab, socket.assigns.tab_meta) - tab_subtitle = tab_subtitle(current_tab, socket.assigns.tab_meta) - posts = overlay_posts(project_id) - media = overlay_media(project_id) - - %{ - current_tab: %{type: current_tab.type, id: current_tab.id, title: tab_title, subtitle: tab_subtitle}, - current_post_language: overlay_source_language(current_tab, metadata), - current_media_language: overlay_source_language(current_tab, metadata), - posts: posts, - media: media, - post_media_ids: overlay_post_media_ids(current_tab), - blog_languages: overlay_blog_languages(metadata), - language_names: overlay_language_names(), - language_flags: overlay_language_flags(), - existing_translations: overlay_existing_translations(current_tab), - ai_title: ShellData.translate("AI Suggestions", %{}, page_language), - insert_link_title: ShellData.translate("Insert Link", %{}, page_language), - insert_media_title: ShellData.translate("Insert Media", %{}, page_language), - language_picker_title: ShellData.translate("Translate", %{}, page_language), - gallery_title: tab_title, - ai_fields: overlay_ai_fields(current_tab, tab_title, tab_subtitle, page_language), - delete_details: overlay_delete_details(current_tab, page_language), - merge_details: overlay_merge_details(project_id, page_language) - } - end - - defp overlay_project_metadata(nil), do: %{main_language: "en", blog_languages: []} - - defp overlay_project_metadata(project_id) do - {:ok, metadata} = Metadata.get_project_metadata(project_id) - metadata - rescue - _error -> %{main_language: "en", blog_languages: []} - end - - defp overlay_posts(nil), do: [] - - defp overlay_posts(project_id) do - Repo.all( - from post in Post, - where: post.project_id == ^project_id, - order_by: [desc: post.updated_at, desc: post.created_at], - select: %{id: post.id, title: post.title, slug: post.slug, status: post.status, published_at: post.published_at, updated_at: post.updated_at, language: post.language} - ) - |> Enum.map(fn post -> - %{ - id: post.id, - title: post.title || post.slug || post.id, - status: Atom.to_string(post.status || :draft), - canonical_url: canonical_post_url(post) - } - end) - end - - defp overlay_media(nil), do: [] - - defp overlay_media(project_id) do - Repo.all( - from media in Media, - where: media.project_id == ^project_id, - order_by: [desc: media.updated_at, desc: media.created_at], - select: %{id: media.id, title: media.title, original_name: media.original_name, mime_type: media.mime_type, alt: media.alt, caption: media.caption} - ) - |> Enum.map(fn media -> - %{ - id: media.id, - title: media.title || media.original_name || media.id, - original_name: media.original_name || media.id, - is_image: String.starts_with?(to_string(media.mime_type || ""), "image/"), - thumbnail_url: "/media-thumbnail/#{media.id}", - image_url: "/media-thumbnail/#{media.id}?size=large", - alt_text: media.alt || media.caption || media.title - } - end) - end - - defp overlay_post_media_ids(%{type: :post, id: post_id}) do - case Repo.query("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do - {:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end) - _other -> [] - end - rescue - _error -> [] - end - - defp overlay_post_media_ids(_tab), do: [] - - defp overlay_existing_translations(%{type: :post, id: post_id}) do - Repo.all( - from translation in Translation, - where: translation.translation_for == ^post_id, - select: {translation.language, translation.status} - ) - |> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end) - rescue - _error -> %{} - end - - defp overlay_existing_translations(_tab), do: %{} - - defp overlay_blog_languages(metadata) do - ([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - end - - defp overlay_source_language(%{type: :post, id: post_id}, metadata) do - case Repo.get(Post, post_id) do - %Post{language: language} when is_binary(language) and language != "" -> language - _other -> metadata.main_language || "en" - end - rescue - _error -> metadata.main_language || "en" - end - - defp overlay_source_language(_tab, metadata), do: metadata.main_language || "en" - - defp overlay_language_names do - %{ - "en" => "English", - "de" => "Deutsch", - "fr" => "Francais", - "it" => "Italiano", - "es" => "Espanol" - } - end - - defp overlay_language_flags do - I18n.supported_languages() - |> Enum.into(%{}, fn language -> {language.code, I18n.flag(language.code)} end) - end - - defp overlay_ai_fields(%{type: :post, id: post_id}, title, subtitle, page_language) do - case Repo.get(Post, post_id) do - %Post{} = post -> - [ - %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: post.title || title, suggested_value: refine_title(post.title || title), locked: false}, - %{key: "excerpt", label: ShellData.translate("Excerpt", %{}, page_language), current_value: post.excerpt || subtitle, suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), locked: false}, - %{key: "slug", label: ShellData.translate("Slug", %{}, page_language), current_value: post.slug || slugify(post.title || title), suggested_value: refine_slug(post.slug || slugify(post.title || title)), locked: post.status == :published} - ] - - _other -> - [] - end - rescue - _error -> [] - end - - defp overlay_ai_fields(%{type: :media, id: media_id}, title, _subtitle, page_language) do - case Repo.get(Media, media_id) do - %Media{} = media -> - [ - %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: media.title || title, suggested_value: refine_title(media.title || title), locked: false}, - %{key: "alt", label: ShellData.translate("Alt Text", %{}, page_language), current_value: media.alt || "", suggested_value: media.alt || title, locked: false}, - %{key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false} - ] - - _other -> - [] - end - rescue - _error -> [] - end - - defp overlay_ai_fields(_tab, _title, _subtitle, _page_language), do: [] - - defp overlay_delete_details(%{type: :media, id: media_id}, page_language) do - entity_name = - case Repo.get(Media, media_id) do - %Media{} = media -> media.title || media.original_name || media.id - _other -> media_id - end - - reference_list = - case Repo.query("SELECT posts.title FROM posts JOIN post_media ON posts.id = post_media.post_id WHERE post_media.media_id = ? ORDER BY post_media.sort_order ASC, posts.updated_at DESC", [media_id]) do - {:ok, %{rows: rows}} -> Enum.map(rows, fn [title] -> title || media_id end) - _other -> [] - end - - %{ - title: ShellData.translate("Delete Media", %{}, page_language), - entity_name: entity_name, - entity_type: "media", - reference_list: reference_list - } - rescue - _error -> %{title: ShellData.translate("Delete Media", %{}, page_language), entity_name: media_id, entity_type: "media", reference_list: []} - end - - defp overlay_delete_details(%{type: :tags}, page_language) do - tag_name = - Repo.one(from tag in Tag, order_by: [asc: tag.name], limit: 1, select: tag.name) - |> Kernel.||("tag") - - %{ - title: ShellData.translate("Delete Tag", %{}, page_language), - entity_name: tag_name, - entity_type: "tag", - reference_list: [] - } - rescue - _error -> %{title: ShellData.translate("Delete Tag", %{}, page_language), entity_name: "tag", entity_type: "tag", reference_list: []} - end - - defp overlay_delete_details(_tab, page_language) do - %{title: ShellData.translate("Delete", %{}, page_language), entity_name: "", entity_type: "item", reference_list: []} - end - - defp overlay_merge_details(project_id, page_language) do - tags = - Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name], limit: 3, select: tag.name) - - target = List.first(tags) || "tag" - - %{ - target: target, - count: max(length(tags), 1), - title: ShellData.translate("Merge Tags", %{}, page_language), - message: ShellData.translate("Cannot be undone.", %{}, page_language) - } - rescue - _error -> %{target: "tag", count: 1, title: ShellData.translate("Merge Tags", %{}, page_language), message: ShellData.translate("Cannot be undone.", %{}, page_language)} - end - - defp overlay_kind("ai_suggestions"), do: :ai_suggestions - defp overlay_kind("insert_link"), do: :insert_link - defp overlay_kind("insert_media"), do: :insert_media - defp overlay_kind("language_picker"), do: :language_picker - defp overlay_kind("confirm_delete"), do: :confirm_delete - defp overlay_kind("confirm_merge"), do: :confirm_merge - defp overlay_kind("gallery"), do: :gallery - defp overlay_kind(_kind), do: nil - - defp overlay_tab("internal"), do: :internal - defp overlay_tab("external"), do: :external - defp overlay_tab(_tab), do: :internal - defp update_shell_overlay(socket, updater) do case socket.assigns[:shell_overlay] do nil -> socket @@ -2434,34 +2152,6 @@ defmodule BDS.Desktop.ShellLive do |> assign(:shell_overlay, nil) end - defp markdown_link(text, url), do: "[#{text}](#{url})" - - defp canonical_post_url(post) do - timestamp = post.published_at || post.updated_at || System.system_time(:millisecond) - date = DateTime.from_unix!(timestamp, :millisecond) - "/#{date.year}/#{pad2(date.month)}/#{pad2(date.day)}/#{post.slug || post.id}" - end - - defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0") - - defp refine_title(nil), do: "" - defp refine_title(title), do: String.trim(title <> " Notes") - - defp refine_excerpt(title, excerpt) do - base = excerpt |> to_string() |> String.trim() - if base == "", do: "#{title} overview", else: base <> "." - end - - defp refine_slug(slug), do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated") - - defp slugify(value) do - value - |> to_string() - |> String.downcase() - |> String.replace(~r/[^a-z0-9]+/u, "-") - |> String.trim("-") - end - defp media_thumbnail_glyph(mime_type) do case String.split(to_string(mime_type || ""), "/", parts: 2) do ["image", _rest] -> "IMG" diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index e9758ff..2232ad9 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -364,29 +364,33 @@ <% else %> -
-
-
<%= tab_route_label(@current_tab) %>
-

<%= tab_title(@current_tab, @tab_meta) %>

-

<%= tab_subtitle(@current_tab, @tab_meta) %>

+ <%= if @current_tab.type == :post and @post_editor do %> + + <% else %> +
+
+
<%= tab_route_label(@current_tab) %>
+

<%= tab_title(@current_tab, @tab_meta) %>

+

<%= tab_subtitle(@current_tab, @tab_meta) %>

- <%= render_editor_toolbar(assigns) %> + <%= render_editor_toolbar(assigns) %> -
-

<%= tab_title(@current_tab, @tab_meta) %>

-

Desktop workbench content routed through the Elixir shell.

-
-
+
+

<%= tab_title(@current_tab, @tab_meta) %>

+

Desktop workbench content routed through the Elixir shell.

+
+
- -
+ + + <% end %> <% end %> @@ -624,5 +628,5 @@ - <%= render_shell_overlay(assigns) %> + diff --git a/lib/bds/desktop/shell_live/overlay_components.ex b/lib/bds/desktop/shell_live/overlay_components.ex new file mode 100644 index 0000000..26f9a7a --- /dev/null +++ b/lib/bds/desktop/shell_live/overlay_components.ex @@ -0,0 +1,286 @@ +defmodule BDS.Desktop.ShellLive.OverlayComponents do + @moduledoc false + + use Phoenix.Component + + import Ecto.Query + + alias BDS.Desktop.ShellData + alias BDS.{I18n, Metadata, Repo} + alias BDS.Media.Media + alias BDS.Posts.{Post, Translation} + alias BDS.Tags.Tag + + embed_templates "overlay_html/*" + + def context(assigns, tab_title, tab_subtitle) do + project_id = assigns.projects.active_project_id + metadata = project_metadata(project_id) + current_tab = assigns.current_tab + page_language = assigns.page_language + posts = posts(project_id) + media = media(project_id) + + %{ + current_tab: %{type: current_tab.type, id: current_tab.id, title: tab_title, subtitle: tab_subtitle}, + current_post_language: source_language(current_tab, metadata), + current_media_language: source_language(current_tab, metadata), + posts: posts, + media: media, + post_media_ids: post_media_ids(current_tab), + blog_languages: blog_languages(metadata), + language_names: language_names(), + language_flags: language_flags(), + existing_translations: existing_translations(current_tab), + ai_title: ShellData.translate("AI Suggestions", %{}, page_language), + insert_link_title: ShellData.translate("Insert Link", %{}, page_language), + insert_media_title: ShellData.translate("Insert Media", %{}, page_language), + language_picker_title: ShellData.translate("Translate", %{}, page_language), + gallery_title: tab_title, + ai_fields: ai_fields(current_tab, tab_title, tab_subtitle, page_language), + delete_details: delete_details(current_tab, page_language), + merge_details: merge_details(project_id, page_language) + } + end + + def kind("ai_suggestions"), do: :ai_suggestions + def kind("insert_link"), do: :insert_link + def kind("insert_media"), do: :insert_media + def kind("language_picker"), do: :language_picker + def kind("confirm_delete"), do: :confirm_delete + def kind("confirm_merge"), do: :confirm_merge + def kind("gallery"), do: :gallery + def kind(_kind), do: nil + + def tab("internal"), do: :internal + def tab("external"), do: :external + def tab(_tab), do: :internal + + def markdown_link(text, url), do: "[#{text}](#{url})" + + def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) + + def project_metadata(nil), do: %{main_language: "en", blog_languages: []} + + def project_metadata(project_id) do + {:ok, metadata} = Metadata.get_project_metadata(project_id) + metadata + rescue + _error -> %{main_language: "en", blog_languages: []} + end + + defp posts(nil), do: [] + + defp posts(project_id) do + Repo.all( + from post in Post, + where: post.project_id == ^project_id, + order_by: [desc: post.updated_at, desc: post.created_at], + select: %{id: post.id, title: post.title, slug: post.slug, status: post.status, published_at: post.published_at, updated_at: post.updated_at, language: post.language} + ) + |> Enum.map(fn post -> + %{ + id: post.id, + title: post.title || post.slug || post.id, + status: Atom.to_string(post.status || :draft), + canonical_url: canonical_post_url(post) + } + end) + end + + defp media(nil), do: [] + + defp media(project_id) do + Repo.all( + from media in Media, + where: media.project_id == ^project_id, + order_by: [desc: media.updated_at, desc: media.created_at], + select: %{id: media.id, title: media.title, original_name: media.original_name, mime_type: media.mime_type, alt: media.alt, caption: media.caption} + ) + |> Enum.map(fn media -> + %{ + id: media.id, + title: media.title || media.original_name || media.id, + original_name: media.original_name || media.id, + is_image: String.starts_with?(to_string(media.mime_type || ""), "image/"), + thumbnail_url: "/media-thumbnail/#{media.id}", + image_url: "/media-thumbnail/#{media.id}?size=large", + alt_text: media.alt || media.caption || media.title + } + end) + end + + defp post_media_ids(%{type: :post, id: post_id}) do + case Repo.query("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do + {:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end) + _other -> [] + end + rescue + _error -> [] + end + + defp post_media_ids(_tab), do: [] + + defp existing_translations(%{type: :post, id: post_id}) do + Repo.all( + from translation in Translation, + where: translation.translation_for == ^post_id, + select: {translation.language, translation.status} + ) + |> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end) + rescue + _error -> %{} + end + + defp existing_translations(_tab), do: %{} + + defp blog_languages(metadata) do + ([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp source_language(%{type: :post, id: post_id}, metadata) do + case Repo.get(Post, post_id) do + %Post{language: language} when is_binary(language) and language != "" -> language + _other -> metadata.main_language || "en" + end + rescue + _error -> metadata.main_language || "en" + end + + defp source_language(_tab, metadata), do: metadata.main_language || "en" + + defp language_names do + %{ + "en" => "English", + "de" => "Deutsch", + "fr" => "Francais", + "it" => "Italiano", + "es" => "Espanol" + } + end + + defp language_flags do + I18n.supported_languages() + |> Enum.into(%{}, fn language -> {language.code, I18n.flag(language.code)} end) + end + + defp ai_fields(%{type: :post, id: post_id}, title, subtitle, page_language) do + case Repo.get(Post, post_id) do + %Post{} = post -> + [ + %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: post.title || title, suggested_value: refine_title(post.title || title), locked: false}, + %{key: "excerpt", label: ShellData.translate("Excerpt", %{}, page_language), current_value: post.excerpt || subtitle, suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), locked: false}, + %{key: "slug", label: ShellData.translate("Slug", %{}, page_language), current_value: post.slug || slugify(post.title || title), suggested_value: refine_slug(post.slug || slugify(post.title || title)), locked: post.status == :published} + ] + + _other -> + [] + end + rescue + _error -> [] + end + + defp ai_fields(%{type: :media, id: media_id}, title, _subtitle, page_language) do + case Repo.get(Media, media_id) do + %Media{} = media -> + [ + %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: media.title || title, suggested_value: refine_title(media.title || title), locked: false}, + %{key: "alt", label: ShellData.translate("Alt Text", %{}, page_language), current_value: media.alt || "", suggested_value: media.alt || title, locked: false}, + %{key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false} + ] + + _other -> + [] + end + rescue + _error -> [] + end + + defp ai_fields(_tab, _title, _subtitle, _page_language), do: [] + + defp delete_details(%{type: :media, id: media_id}, page_language) do + entity_name = + case Repo.get(Media, media_id) do + %Media{} = media -> media.title || media.original_name || media.id + _other -> media_id + end + + reference_list = + case Repo.query("SELECT posts.title FROM posts JOIN post_media ON posts.id = post_media.post_id WHERE post_media.media_id = ? ORDER BY post_media.sort_order ASC, posts.updated_at DESC", [media_id]) do + {:ok, %{rows: rows}} -> Enum.map(rows, fn [title] -> title || media_id end) + _other -> [] + end + + %{ + title: ShellData.translate("Delete Media", %{}, page_language), + entity_name: entity_name, + entity_type: "media", + reference_list: reference_list + } + rescue + _error -> %{title: ShellData.translate("Delete Media", %{}, page_language), entity_name: media_id, entity_type: "media", reference_list: []} + end + + defp delete_details(%{type: :tags}, page_language) do + tag_name = + Repo.one(from tag in Tag, order_by: [asc: tag.name], limit: 1, select: tag.name) + |> Kernel.||("tag") + + %{ + title: ShellData.translate("Delete Tag", %{}, page_language), + entity_name: tag_name, + entity_type: "tag", + reference_list: [] + } + rescue + _error -> %{title: ShellData.translate("Delete Tag", %{}, page_language), entity_name: "tag", entity_type: "tag", reference_list: []} + end + + defp delete_details(_tab, page_language) do + %{title: ShellData.translate("Delete", %{}, page_language), entity_name: "", entity_type: "item", reference_list: []} + end + + defp merge_details(project_id, page_language) do + tags = + Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name], limit: 3, select: tag.name) + + target = List.first(tags) || "tag" + + %{ + target: target, + count: max(length(tags), 1), + title: ShellData.translate("Merge Tags", %{}, page_language), + message: ShellData.translate("Cannot be undone.", %{}, page_language) + } + rescue + _error -> %{target: "tag", count: 1, title: ShellData.translate("Merge Tags", %{}, page_language), message: ShellData.translate("Cannot be undone.", %{}, page_language)} + end + + defp canonical_post_url(post) do + timestamp = post.published_at || post.updated_at || System.system_time(:millisecond) + date = DateTime.from_unix!(timestamp, :millisecond) + "/#{date.year}/#{pad2(date.month)}/#{pad2(date.day)}/#{post.slug || post.id}" + end + + defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0") + + defp refine_title(nil), do: "" + defp refine_title(title), do: String.trim(title <> " Notes") + + defp refine_excerpt(title, excerpt) do + base = excerpt |> to_string() |> String.trim() + if base == "", do: "#{title} overview", else: base <> "." + end + + defp refine_slug(slug), do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated") + + defp slugify(value) do + value + |> to_string() + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/u, "-") + |> String.trim("-") + end +end \ No newline at end of file diff --git a/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex b/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex new file mode 100644 index 0000000..17e957e --- /dev/null +++ b/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex @@ -0,0 +1,219 @@ +<%= if @shell_overlay do %> + <%= case @shell_overlay.kind do %> + <% :ai_suggestions -> %> +
+ + +
+ + <% :insert_link -> %> +
+ + +
+ + <% :insert_media -> %> +
+ + +
+ + <% :language_picker -> %> +
+ + +
+ + <% :confirm_delete -> %> +
+ + +
+ + <% :confirm_dialog -> %> +
+ + +
+ + <% :gallery -> %> + + + <% _other -> %> + <% end %> +<% end %> \ No newline at end of file diff --git a/lib/bds/desktop/shell_live/post_editor.ex b/lib/bds/desktop/shell_live/post_editor.ex new file mode 100644 index 0000000..60602b7 --- /dev/null +++ b/lib/bds/desktop/shell_live/post_editor.ex @@ -0,0 +1,404 @@ +defmodule BDS.Desktop.ShellLive.PostEditor do + @moduledoc false + + use Phoenix.Component + + import Ecto.Query + import Phoenix.HTML + + alias BDS.Desktop.ShellData + alias BDS.{I18n, PostLinks, Posts, Repo, Tags, Templates} + alias BDS.Media.Media + alias BDS.Posts.{Post, Translation} + alias BDS.UI.Workbench + + embed_templates "post_editor_html/*" + + def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do + case Repo.get(Post, post_id) do + nil -> + nil + + %Post{} = post -> + metadata = project_metadata(assigns) + canonical_language = canonical_language(post, metadata) + active_language = Map.get(assigns.post_editor_active_languages, post.id, canonical_language) + translations = translations(post.id) + persisted_form = persisted_form(post, metadata, active_language, translations) + + form = + assigns.post_editor_drafts + |> Map.get(post.id, %{}) + |> Map.get(active_language, persisted_form) + + expanded = + Map.get(assigns.post_editor_expanded, post.id, %{ + metadata: blank?(post.title), + excerpt: not blank?(post.excerpt) + }) + + current_translation = Map.get(translations, active_language) + + %{ + id: post.id, + display_title: display_title(form["title"], post.slug, post.id), + subtitle: active_language_subtitle(active_language, canonical_language), + slug: post.slug || post.id, + status: current_status(post.status, active_language, canonical_language, current_translation), + dirty?: Workbench.dirty?(assigns.workbench, :post, post.id), + save_state: Map.get(assigns.post_editor_save_states, post.id, :idle), + metadata_expanded: Map.get(expanded, :metadata, false), + excerpt_expanded: Map.get(expanded, :excerpt, false), + mode: Map.get(assigns.post_editor_modes, post.id, :markdown), + editing_canonical?: editing_canonical_language?(translations, active_language, canonical_language), + languages: languages(metadata), + form: form, + template_options: template_options(post.project_id), + tag_options: Enum.map(Tags.list_tags(post.project_id), & &1.name), + category_options: metadata.categories || [], + translation_flags: translation_flags(post, canonical_language, active_language, translations), + linked_media: linked_media(post.id), + post_links: post_links(post.id), + footer: footer(post, current_translation, active_language, canonical_language) + } + end + end + + def build(_assigns), do: nil + + def normalize_mode(mode) when mode in [:visual, :markdown, :preview], do: mode + def normalize_mode("visual"), do: :visual + def normalize_mode("preview"), do: :preview + def normalize_mode(_mode), do: :markdown + + def normalize_language(value, fallback) do + case value |> to_string() |> String.trim() do + "" -> fallback + normalized -> String.downcase(normalized) + end + end + + def normalize_params(params, current_language, next_language) do + %{ + "title" => Map.get(params, "title", ""), + "excerpt" => Map.get(params, "excerpt", ""), + "content" => Map.get(params, "content", ""), + "tags" => Map.get(params, "tags", ""), + "categories" => Map.get(params, "categories", ""), + "author" => Map.get(params, "author", ""), + "language" => if(current_language == next_language, do: normalize_language(Map.get(params, "language"), current_language), else: next_language), + "do_not_translate" => truthy?(Map.get(params, "do_not_translate")), + "template_slug" => Map.get(params, "template_slug", "") + } + end + + def current_draft(assigns, %Post{} = post, metadata, active_language) do + persisted = persisted_form(post, metadata, active_language) + + assigns.post_editor_drafts + |> Map.get(post.id, %{}) + |> Map.get(active_language, persisted) + end + + def persisted_form(%Post{} = post, metadata, active_language) do + persisted_form(post, metadata, active_language, translations(post.id)) + end + + def persist(%Post{} = post, draft, active_language, metadata, action) do + canonical_language = canonical_language(post, metadata) + translations = translations(post.id) + + result = + if editing_canonical_language?(translations, active_language, canonical_language) do + post + |> save_canonical_draft(draft) + |> maybe_publish_post(post.id, action) + else + post.id + |> save_translation_draft(active_language, draft) + |> maybe_publish_translation(post.id, active_language, action) + end + + result + end + + def discard(%Post{} = post, active_language, metadata) do + canonical_language = canonical_language(post, metadata) + current_translations = translations(post.id) + + cond do + not editing_canonical_language?(current_translations, active_language, canonical_language) -> + {:ok, post} + + post.file_path not in [nil, ""] and post.status == :draft -> + Posts.discard_post_changes(post.id) + + true -> + {:ok, post} + end + end + + def save_state_for_action(:publish), do: :published + def save_state_for_action(_action), do: :saved + + def record_title(%Translation{title: title}, post), do: blank_to_nil(title) || post.title || post.slug || post.id + def record_title(%Post{title: title, slug: slug, id: id}, _post), do: blank_to_nil(title) || blank_to_nil(slug) || id + + def record_status(%Translation{status: status}), do: status || :draft + def record_status(%Post{status: status}), do: status || :draft + + def editing_canonical_language?(translations, active_language, canonical_language) do + active_language == canonical_language or not Map.has_key?(translations, active_language) + end + + def post_status_label(status), do: ShellData.dashboard_status_label(status) + + def post_editor_save_state_label(:dirty), do: translated("Unsaved") + def post_editor_save_state_label(:saved), do: translated("Saved") + def post_editor_save_state_label(:published), do: translated("Published") + def post_editor_save_state_label(:discarded), do: translated("Reverted") + def post_editor_save_state_label(_state), do: translated("Idle") + + def post_editor_mode_label(:visual), do: translated("Visual") + def post_editor_mode_label(:markdown), do: translated("Markdown") + def post_editor_mode_label(:preview), do: translated("Preview") + + def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) + + defp editor_toolbar(assigns) do + ~H""" + <%= if Enum.any?(@toolbar_buttons) do %> +
+ <%= for button <- @toolbar_buttons do %> + + <% end %> +
+ <% end %> + """ + end + + defp project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{}) + + defp current_status(post_status, active_language, canonical_language, current_translation) do + if active_language == canonical_language, do: post_status, else: translation_status(current_translation) + end + + defp persisted_form(post, metadata, active_language, translations) do + canonical_language = canonical_language(post, metadata) + translation = Map.get(translations, active_language) + + if active_language == canonical_language do + %{ + "title" => post.title || "", + "excerpt" => post.excerpt || "", + "content" => post.content || "", + "tags" => Enum.join(post.tags || [], ", "), + "categories" => Enum.join(post.categories || [], ", "), + "author" => post.author || metadata.default_author || "", + "language" => canonical_language, + "do_not_translate" => post.do_not_translate || false, + "template_slug" => post.template_slug || "" + } + else + %{ + "title" => translation && translation.title || "", + "excerpt" => translation && translation.excerpt || "", + "content" => translation && translation.content || "", + "tags" => Enum.join(post.tags || [], ", "), + "categories" => Enum.join(post.categories || [], ", "), + "author" => post.author || metadata.default_author || "", + "language" => active_language, + "do_not_translate" => post.do_not_translate || false, + "template_slug" => post.template_slug || "" + } + end + end + + defp canonical_language(post, metadata) do + normalize_language(post.language, metadata.main_language || "en") + end + + defp truthy?(value) when value in [true, "true", "on", 1, "1"], do: true + defp truthy?(_value), do: false + + defp blank?(value), do: blank_to_nil(value) == nil + + defp blank_to_nil(value) do + value + |> to_string() + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end + + defp csv_to_list(value) do + value + |> to_string() + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp translations(post_id) do + {:ok, translations} = Posts.list_post_translations(post_id) + Map.new(translations, fn translation -> {translation.language, translation} end) + end + + defp languages(metadata) do + (([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++ Enum.map(I18n.supported_languages(), & &1.code)) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp translation_status(nil), do: :draft + defp translation_status(%Translation{status: status}) when not is_nil(status), do: status + defp translation_status(_translation), do: :draft + + defp template_options(project_id) do + Repo.all( + from template in Templates.Template, + where: template.project_id == ^project_id, + order_by: [asc: template.title, asc: template.slug], + select: %{slug: template.slug, title: fragment("COALESCE(?, ?)", template.title, template.slug)} + ) + rescue + _error -> [] + end + + defp linked_media(post_id) do + case Repo.query("SELECT media_id, sort_order FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do + {:ok, %{rows: rows}} -> + Enum.map(rows, fn [media_id, sort_order] -> + case Repo.get(Media, media_id) do + %Media{} = media -> + %{ + media_id: media.id, + has_thumbnail: String.starts_with?(to_string(media.mime_type || ""), "image/"), + name: media.title || media.original_name || media.id, + sort_order: sort_order || 0 + } + + _other -> + nil + end + end) + |> Enum.reject(&is_nil/1) + + _other -> + [] + end + rescue + _error -> [] + end + + defp post_links(post_id) do + %{ + backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id), + outlinks: related_posts(PostLinks.list_outgoing_links(post_id), :target_post_id) + } + end + + defp related_posts(links, key) do + Enum.map(links, fn link -> + case Repo.get(Post, Map.fetch!(link, key)) do + %Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id} + _other -> nil + end + end) + |> Enum.reject(&is_nil/1) + end + + defp translation_flags(post, canonical_language, active_language, translations) do + canonical = %{language: canonical_language, flag: I18n.flag(canonical_language), status: Atom.to_string(post.status || :draft), active: active_language == canonical_language, label: canonical_language} + + others = + translations + |> Map.values() + |> Enum.sort_by(& &1.language) + |> Enum.map(fn translation -> + %{ + language: translation.language, + flag: I18n.flag(translation.language), + status: Atom.to_string(translation.status || :draft), + active: active_language == translation.language, + label: translation.language + } + end) + + [canonical | others] + end + + defp footer(post, translation, active_language, canonical_language) do + if active_language == canonical_language do + %{ + created_at: format_timestamp(post.created_at), + updated_at: format_timestamp(post.updated_at), + published_at: format_timestamp(post.published_at) + } + else + %{ + created_at: format_timestamp(translation && translation.created_at || post.created_at), + updated_at: format_timestamp(translation && translation.updated_at || post.updated_at), + published_at: format_timestamp(translation && translation.published_at) + } + end + end + + defp format_timestamp(nil), do: "" + + defp format_timestamp(timestamp) do + timestamp + |> DateTime.from_unix!(:millisecond) + |> Calendar.strftime("%x") + end + + defp display_title(title, slug, fallback_id) do + blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled") + end + + defp active_language_subtitle(active_language, canonical_language) do + if active_language == canonical_language do + translated("Canonical draft") + else + translated("Translation: %{language}", %{language: String.upcase(active_language)}) + end + end + + defp save_canonical_draft(%Post{id: post_id}, draft) do + Posts.update_post(post_id, %{ + title: blank_to_nil(Map.get(draft, "title")), + excerpt: blank_to_nil(Map.get(draft, "excerpt")), + content: blank_to_nil(Map.get(draft, "content")), + tags: csv_to_list(Map.get(draft, "tags")), + categories: csv_to_list(Map.get(draft, "categories")), + author: blank_to_nil(Map.get(draft, "author")), + language: blank_to_nil(Map.get(draft, "language")), + do_not_translate: Map.get(draft, "do_not_translate", false), + template_slug: blank_to_nil(Map.get(draft, "template_slug")) + }) + end + + defp save_translation_draft(post_id, language, draft) do + Posts.upsert_post_translation(post_id, language, %{ + title: Map.get(draft, "title", ""), + excerpt: blank_to_nil(Map.get(draft, "excerpt")), + content: blank_to_nil(Map.get(draft, "content")) + }) + end + + defp maybe_publish_post({:ok, %Post{}}, post_id, :publish), do: Posts.publish_post(post_id) + defp maybe_publish_post(result, _post_id, _action), do: result + + defp maybe_publish_translation({:ok, _translation}, post_id, language, :publish), do: Posts.publish_post_translation(post_id, language) + defp maybe_publish_translation(result, _post_id, _language, _action), do: result +end \ No newline at end of file diff --git a/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex b/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex new file mode 100644 index 0000000..892a834 --- /dev/null +++ b/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex @@ -0,0 +1,227 @@ +
+
+
+
<%= translated("Post") %>
+
+

<%= @post_editor.display_title %>

+ <%= if @post_editor.dirty? do %> + + <% end %> +
+

<%= @post_editor.subtitle %>

+
+ +
+ + <%= post_status_label(@post_editor.status) %> + + <%= post_editor_save_state_label(@post_editor.save_state) %> + + + + +
+
+ +
+ + +
+ <%= for flag <- @post_editor.translation_flags do %> + + <% end %> +
+
+ + <%= editor_toolbar(assigns) %> + +
+ + +
+ +
+ + <%= if @post_editor.excerpt_expanded do %> + + <% end %> + +
+ <%= translated("Content") %> +
+ <%= for mode <- [:visual, :markdown, :preview] do %> + + <% end %> +
+
+ + <%= if @post_editor.mode == :preview do %> +
<%= raw(Earmark.as_html!(@post_editor.form["content"] || "")) %>
+ <% else %> + + <% end %> +
+ +
+ <%= translated("Created") %>: <%= @post_editor.footer.created_at %> + <%= translated("Updated") %>: <%= @post_editor.footer.updated_at %> + <%= if @post_editor.footer.published_at do %> + <%= translated("Published") %>: <%= @post_editor.footer.published_at %> + <% end %> +
+
\ No newline at end of file diff --git a/priv/ui/app.css b/priv/ui/app.css index d7d078a..7b247ee 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -835,6 +835,238 @@ button { border-bottom: 1px solid var(--vscode-panel-border); } +.post-editor { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 16px 18px; +} + +.post-editor-header, +.post-editor-title-row, +.post-editor-actions, +.post-editor-flags-bar, +.post-editor-links-columns, +.post-editor-side-panel-header, +.post-editor-side-actions, +.post-editor-excerpt-header, +.post-editor-body-header, +.post-editor-mode-toggle, +.post-editor-footer { + display: flex; + align-items: center; + gap: 10px; +} + +.post-editor-header, +.post-editor-flags-bar, +.post-editor-body-header, +.post-editor-footer, +.post-editor-side-panel-header { + justify-content: space-between; +} + +.post-editor-heading, +.post-editor-column, +.post-editor-links-panel, +.post-editor-side-panel { + min-width: 0; +} + +.post-editor-title-row { + gap: 8px; +} + +.post-editor-dirty-dot { + color: var(--vscode-editorWarning-foreground, #e2c08d); + font-size: 12px; +} + +.post-status-badge, +.translation-flag-button, +.post-editor-mode-button, +.post-editor-section-toggle { + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background: transparent; + color: inherit; +} + +.post-status-badge { + padding: 4px 8px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.post-save-state { + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.post-editor-flags-bar { + flex-wrap: wrap; +} + +.post-editor-flags, +.post-editor-side-actions, +.post-editor-mode-toggle { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.translation-flag-button, +.post-editor-mode-button, +.post-editor-section-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + cursor: pointer; +} + +.translation-flag-button.is-active, +.post-editor-mode-button.is-active, +.post-editor-section-toggle:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.post-editor-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.post-editor-metadata-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.85fr); + gap: 16px; +} + +.post-editor-metadata-grid.is-collapsed { + display: none; +} + +.post-editor-column { + display: flex; + flex-direction: column; + gap: 12px; +} + +.post-editor-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.post-editor-input, +.post-editor-textarea { + width: 100%; + border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); + border-radius: 4px; + background: var(--vscode-input-background, rgba(255, 255, 255, 0.03)); + color: var(--vscode-input-foreground, var(--vscode-foreground)); + padding: 8px 10px; + font: inherit; +} + +.post-editor-input.is-readonly { + color: var(--vscode-descriptionForeground); +} + +.post-editor-textarea { + resize: vertical; + line-height: 1.5; +} + +.post-editor-checkbox-field { + flex-direction: row; + align-items: center; +} + +.post-editor-links-panel, +.post-editor-side-panel { + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + padding: 12px; +} + +.post-editor-links-columns { + align-items: flex-start; + justify-content: flex-start; + gap: 18px; + margin-top: 10px; +} + +.post-editor-links-columns > div, +.post-editor-side-panel { + flex: 1; +} + +.post-editor-links-label, +.post-editor-body-label, +.post-editor-media-meta, +.post-editor-empty { + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.post-editor-media-list { + list-style: none; + margin: 10px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.post-editor-media-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); +} + +.post-editor-content-field { + margin: 0; +} + +.post-editor-content { + min-height: 360px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.post-editor-preview { + min-height: 240px; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + padding: 14px; + line-height: 1.6; +} + +.post-editor-footer { + flex-wrap: wrap; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +@media (max-width: 980px) { + .post-editor-header, + .post-editor-flags-bar, + .post-editor-body-header { + flex-direction: column; + align-items: flex-start; + } + + .post-editor-metadata-grid { + grid-template-columns: 1fr; + } +} + .panel-shell { height: 200px; border-top: 1px solid var(--vscode-panel-border); diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 3120352..be36463 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -560,6 +560,99 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(class="task-list") or html =~ "No background tasks running" end + test "post tabs render a real editor and drive save publish discard flows", %{project: project} do + {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Draft Shell Post", + content: "Initial body", + excerpt: "Initial excerpt" + }) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "post", + "id" => post.id, + "title" => post.title, + "subtitle" => "draft" + }) + + assert html =~ ~s(data-testid="post-editor") + assert html =~ ~s(data-testid="post-editor-form") + assert html =~ ~s(name="post_editor[title]") + assert html =~ ~s(name="post_editor[content]") + assert html =~ ~s(name="post_editor[excerpt]") + assert html =~ ~s(data-testid="post-publish-button") + assert html =~ ~s(data-testid="post-discard-button") + refute html =~ "Desktop workbench content routed through the Elixir shell." + + html = + view + |> form("[data-testid='post-editor-form']", %{ + post_editor: %{ + title: "Updated Shell Post", + content: "Updated body", + excerpt: "Updated excerpt", + tags: "alpha, beta", + categories: "notes, guides", + author: "Ada Lovelace", + language: "de", + do_not_translate: "false", + template_slug: "" + } + }) + |> render_change() + + assert html =~ ~s(class="tab active dirty") + assert html =~ "Updated Shell Post" + + _html = render_click(view, "save_post_editor", %{"id" => post.id}) + + saved_post = Posts.get_post!(post.id) + assert saved_post.title == "Updated Shell Post" + assert saved_post.content == "Updated body" + assert saved_post.excerpt == "Updated excerpt" + assert saved_post.tags == ["alpha", "beta"] + assert saved_post.categories == ["notes", "guides"] + assert saved_post.author == "Ada Lovelace" + assert saved_post.language == "de" + + html = render_click(view, "publish_post_editor", %{"id" => post.id}) + + assert html =~ ~s(data-testid="post-status-badge") + assert Posts.get_post!(post.id).status == :published + + _html = + view + |> form("[data-testid='post-editor-form']", %{ + post_editor: %{ + title: "Published Shell Post", + content: "Draft changes after publish", + excerpt: "Changed after publish", + tags: "alpha, beta", + categories: "notes, guides", + author: "Ada Lovelace", + language: "de", + do_not_translate: "false", + template_slug: "" + } + }) + |> render_change() + + _html = render_click(view, "save_post_editor", %{"id" => post.id}) + assert Posts.get_post!(post.id).status == :draft + + html = render_click(view, "discard_post_editor", %{"id" => post.id}) + + discarded_post = Posts.get_post!(post.id) + assert html =~ "Updated Shell Post" + assert discarded_post.status == :published + assert discarded_post.content == nil + assert discarded_post.title == "Updated Shell Post" + end + defp seed_sidebar_posts(project_id) do now = Persistence.now_ms() diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index 2cd97e2..7437eeb 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -274,19 +274,22 @@ defmodule BDS.UI.ShellTest do css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex") template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex") + overlay_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/overlay_components.ex") + overlay_template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex") assert template =~ "render_editor_toolbar(assigns)" - assert template =~ "render_shell_overlay(assigns)" + assert template =~ "