From 4548531f4e1111a135c509463f0e45a963ed34f3 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 26 Apr 2026 17:36:45 +0200 Subject: [PATCH] fix: more alignment with old app Co-authored-by: Copilot --- lib/bds/desktop/shell_live.ex | 85 +- .../desktop/shell_live/overlay_components.ex | 2 +- lib/bds/desktop/shell_live/post_editor.ex | 447 +++++++++- .../post_editor_html/post_editor.html.heex | 554 ++++++++---- priv/ui/app.css | 844 +++++++++++++++--- priv/ui/live.js | 29 + test/bds/desktop/shell_live_test.exs | 56 +- 7 files changed, 1632 insertions(+), 385 deletions(-) diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 2264f30..012c34c 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -63,6 +63,9 @@ defmodule BDS.Desktop.ShellLive do |> assign(:sidebar_filter_panels, %{}) |> assign(:post_editor_drafts, %{}) |> assign(:post_editor_active_languages, %{}) + |> assign(:post_editor_tag_queries, %{}) + |> assign(:post_editor_category_queries, %{}) + |> assign(:post_editor_quick_actions_open, %{}) |> assign(:post_editor_modes, %{}) |> assign(:post_editor_expanded, %{}) |> assign(:post_editor_save_states, %{}) @@ -352,7 +355,40 @@ defmodule BDS.Desktop.ShellLive do {:noreply, PostEditor.select_language(socket, post_id, language, &reload_shell/2)} end + def handle_event("toggle_post_editor_quick_actions", %{"id" => post_id}, socket) do + {:noreply, PostEditor.toggle_quick_actions(socket, post_id, &reload_shell/2)} + end + + def handle_event("detect_post_editor_language", %{"id" => post_id}, socket) do + {:noreply, PostEditor.detect_language(socket, post_id, &reload_shell/2, &append_output_entry/5)} + end + + def handle_event("add_post_editor_tag", %{"id" => post_id, "tag" => tag}, socket) do + {:noreply, PostEditor.add_list_value(socket, post_id, :tags, tag, &reload_shell/2)} + end + + def handle_event("remove_post_editor_tag", %{"id" => post_id, "tag" => tag}, socket) do + {:noreply, PostEditor.remove_list_value(socket, post_id, :tags, tag, &reload_shell/2)} + end + + def handle_event("add_post_editor_category", %{"id" => post_id, "category" => category}, socket) do + {:noreply, PostEditor.add_list_value(socket, post_id, :categories, category, &reload_shell/2)} + end + + def handle_event("remove_post_editor_category", %{"id" => post_id, "category" => category}, socket) do + {:noreply, PostEditor.remove_list_value(socket, post_id, :categories, category, &reload_shell/2)} + end + def handle_event("open_overlay", %{"kind" => kind}, socket) do + socket = + case socket.assigns[:current_tab] do + %{type: :post, id: post_id} when kind in ["ai_suggestions", "language_picker"] -> + assign(socket, :post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false)) + + _other -> + socket + end + overlay = with overlay_kind when not is_nil(overlay_kind) <- ShellOverlayComponents.kind(kind), %{type: route} <- socket.assigns[:current_tab] do @@ -405,16 +441,17 @@ defmodule BDS.Desktop.ShellLive do def handle_event("overlay_select_result", %{"id" => id}, socket) do overlay = socket.assigns[:shell_overlay] + current_tab = socket.assigns[:current_tab] socket = - case overlay do - %{kind: :insert_link} -> + case {overlay, current_tab} do + {%{kind: :insert_link}, %{type: :post, id: post_id}} -> case Overlay.insert_link_result(overlay, id) do nil -> socket - result -> close_overlay_with_output(socket, overlay.title, ShellOverlayComponents.markdown_link(result.title, result.canonical_url)) + result -> PostEditor.insert_content(socket, post_id, ShellOverlayComponents.markdown_link(result.title, result.canonical_url), &reload_shell/2) end - %{kind: :insert_media} -> + {%{kind: :insert_media}, %{type: :post, id: post_id}} -> case Overlay.insert_media_result(overlay, id) do nil -> socket result -> @@ -425,7 +462,7 @@ defmodule BDS.Desktop.ShellLive do "[#{result.original_name}](bds-media://#{result.media_id})" end - close_overlay_with_output(socket, overlay.title, syntax) + PostEditor.insert_content(socket, post_id, syntax, &reload_shell/2) end _other -> @@ -436,9 +473,11 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("overlay_insert_external", _params, socket) do + current_tab = socket.assigns[:current_tab] + socket = - case socket.assigns[:shell_overlay] do - %{kind: :insert_link} = overlay -> + case {socket.assigns[:shell_overlay], current_tab} do + {%{kind: :insert_link} = overlay, %{type: :post, id: post_id}} -> details = case {overlay.external_url, String.trim(overlay.external_text || "")} do {"", _text} -> nil @@ -447,7 +486,7 @@ defmodule BDS.Desktop.ShellLive do end if details do - close_overlay_with_output(socket, overlay.title, details) + PostEditor.insert_content(socket, post_id, details, &reload_shell/2) else socket end @@ -460,9 +499,13 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("overlay_select_language", %{"code" => code}, socket) do + current_tab = socket.assigns[:current_tab] + socket = - case socket.assigns[:shell_overlay] do - %{kind: :language_picker, title: title} -> close_overlay_with_output(socket, title, code) + case {socket.assigns[:shell_overlay], current_tab} do + {%{kind: :language_picker}, %{type: :post, id: post_id}} -> + PostEditor.translate(socket, post_id, code, &reload_shell/2, &append_output_entry/5) + _other -> socket end @@ -470,17 +513,23 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("overlay_confirm", _params, socket) do - socket = - case socket.assigns[:shell_overlay] do - %{kind: :ai_suggestions, title: title} = overlay -> - selected = Overlay.selected_ai_fields(overlay) - details = Enum.map_join(selected, ", ", & &1.label) - close_overlay_with_output(socket, title, details) + current_tab = socket.assigns[:current_tab] - %{kind: :confirm_delete, title: title, entity_name: entity_name} -> + socket = + case {socket.assigns[:shell_overlay], current_tab} do + {%{kind: :ai_suggestions} = overlay, %{type: :post, id: post_id}} -> + PostEditor.apply_ai_suggestions( + socket, + post_id, + Overlay.selected_ai_fields(overlay), + &reload_shell/2, + &append_output_entry/5 + ) + + {%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} -> close_overlay_with_output(socket, title, entity_name) - %{kind: :confirm_dialog, title: title, message: message} -> + {%{kind: :confirm_dialog, title: title, message: message}, _tab} -> close_overlay_with_output(socket, title, message) _other -> diff --git a/lib/bds/desktop/shell_live/overlay_components.ex b/lib/bds/desktop/shell_live/overlay_components.ex index 6e5de99..a327f2c 100644 --- a/lib/bds/desktop/shell_live/overlay_components.ex +++ b/lib/bds/desktop/shell_live/overlay_components.ex @@ -135,7 +135,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do defp existing_translations(_tab), do: %{} defp blog_languages(metadata) do - ([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) + ([metadata.main_language || "en"] ++ (metadata.blog_languages || []) ++ Enum.map(I18n.supported_languages(), & &1.code)) |> Enum.reject(&is_nil/1) |> Enum.uniq() end diff --git a/lib/bds/desktop/shell_live/post_editor.ex b/lib/bds/desktop/shell_live/post_editor.ex index e74e008..531234a 100644 --- a/lib/bds/desktop/shell_live/post_editor.ex +++ b/lib/bds/desktop/shell_live/post_editor.ex @@ -4,10 +4,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do use Phoenix.Component import Ecto.Query - import Phoenix.HTML alias BDS.Desktop.ShellData - alias BDS.{I18n, Metadata, PostLinks, Posts, Repo, Tags, Templates} + alias BDS.{AI, I18n, Metadata, PostLinks, Posts, Preview, Repo, Tags, Templates} alias BDS.Media.Media alias BDS.Posts.{Post, Translation} alias BDS.UI.Workbench @@ -40,16 +39,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor do end draft = normalize_params(params, current_language, next_language) - workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id) + current = current_draft(socket.assigns, post, metadata, next_language) + dirty? = draft != current 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.(workbench) + |> put_query_state(post_id, :tags, Map.get(params, "tag_query", "")) + |> put_query_state(post_id, :categories, Map.get(params, "category_query", "")) + |> maybe_update_draft(post_id, post, current_language, next_language, draft, dirty?) + |> reload_with_assigned_workbench(reload) end _other -> @@ -126,6 +123,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do |> 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_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id)) + |> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id)) + |> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, 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)) @@ -162,6 +162,186 @@ defmodule BDS.Desktop.ShellLive.PostEditor do |> reload.(workbench) end + def toggle_quick_actions(socket, post_id, reload) do + workbench = socket.assigns.workbench + + socket + |> assign(:post_editor_quick_actions_open, Map.update(socket.assigns.post_editor_quick_actions_open, post_id, true, &(!&1))) + |> reload.(workbench) + end + + def detect_language(socket, post_id, reload, append_output) do + if Map.get(socket.assigns, :offline_mode, true) do + socket + |> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") + |> reload.(socket.assigns.workbench) + else + case Repo.get(Post, post_id) do + nil -> + socket + + %Post{} = post -> + metadata = project_metadata(post.project_id) + canonical_language = canonical_language(post, metadata) + active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + draft = current_draft(socket.assigns, post, metadata, active_language) + text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "content", "")], "\n\n") + + case AI.detect_language(text) do + {:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" -> + socket + |> put_draft_field(post_id, post, active_language, "language", normalize_language(language_code, canonical_language)) + |> reload_with_assigned_workbench(reload) + + {:error, reason} -> + socket + |> append_output.(translated("Detect Language"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + + _other -> + socket + |> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error") + |> reload.(socket.assigns.workbench) + end + end + end + end + + def translate(socket, post_id, language, reload, append_output) do + if Map.get(socket.assigns, :offline_mode, true) do + socket + |> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") + |> reload.(socket.assigns.workbench) + else + normalized_language = normalize_language(language, "") + + case AI.translate_post(post_id, normalized_language) do + {:ok, translation} -> + with {:ok, _saved_translation} <- + Posts.upsert_post_translation(post_id, normalized_language, %{ + title: translation.title, + excerpt: translation.excerpt, + content: translation.content + }) do + socket + |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, normalized_language)) + |> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, normalized_language)) + |> assign(:post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false)) + |> reload.(socket.assigns.workbench) + else + {:error, reason} -> + socket + |> append_output.(translated("Translate"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + + {:error, reason} -> + socket + |> append_output.(translated("Translate"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + end + end + + def apply_ai_suggestions(socket, post_id, fields, reload, append_output) do + case Repo.get(Post, post_id) do + nil -> + socket + + %Post{} -> + attrs = + fields + |> Enum.reduce(%{}, fn field, acc -> + case field.key do + "title" -> Map.put(acc, :title, blank_to_nil(field.suggested_value)) + "excerpt" -> Map.put(acc, :excerpt, blank_to_nil(field.suggested_value)) + "slug" -> Map.put(acc, :slug, blank_to_nil(field.suggested_value)) + _other -> acc + end + end) + + if map_size(attrs) == 0 do + socket |> assign(:shell_overlay, nil) + else + case Posts.update_post(post_id, attrs) do + {:ok, updated_post} -> + metadata = project_metadata(updated_post.project_id) + active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language(updated_post, metadata)) + refreshed_form = persisted_form(updated_post, metadata, active_language) + + socket + |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, refreshed_form)) + |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) + |> assign(:shell_overlay, nil) + |> reload.(socket.assigns.workbench) + + {:error, reason} -> + socket + |> append_output.(translated("AI Suggestions"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + end + end + end + + def insert_content(socket, post_id, snippet, reload) do + socket + |> Phoenix.LiveView.push_event("post-editor-insert-content", %{id: post_id, content: snippet}) + |> assign(:shell_overlay, nil) + |> reload.(socket.assigns.workbench) + end + + def add_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do + case Repo.get(Post, post_id) do + nil -> + socket + + %Post{} = post -> + metadata = project_metadata(post.project_id) + canonical_language = canonical_language(post, metadata) + active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + draft = current_draft(socket.assigns, post, metadata, active_language) + normalized = normalize_list_entry(value) + + if normalized in [nil, ""] do + socket + else + ensure_list_value(post.project_id, kind, normalized) + + updated = + draft + |> Map.get(field_key(kind), "") + |> csv_to_list() + |> Kernel.++([normalized]) + |> Enum.uniq() + |> Enum.join(", ") + + socket + |> put_query_state(post_id, kind, "") + |> put_draft_field(post_id, post, active_language, field_key(kind), updated) + |> reload_with_assigned_workbench(reload) + end + end + end + + def remove_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do + case Repo.get(Post, post_id) do + nil -> + socket + + %Post{} = post -> + metadata = project_metadata(post.project_id) + canonical_language = canonical_language(post, metadata) + active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + draft = current_draft(socket.assigns, post, metadata, active_language) + updated = draft |> Map.get(field_key(kind), "") |> csv_to_list() |> Enum.reject(&(&1 == value)) |> Enum.join(", ") + + socket + |> put_draft_field(post_id, post, active_language, field_key(kind), updated) + |> reload_with_assigned_workbench(reload) + end + end + def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do case Repo.get(Post, post_id) do nil -> @@ -190,20 +370,40 @@ defmodule BDS.Desktop.ShellLive.PostEditor do %{ id: post.id, display_title: display_title(form["title"], post.slug, post.id), - subtitle: active_language_subtitle(active_language, canonical_language), + subtitle: nil, slug: post.slug || post.id, - status: current_status(post.status, active_language, canonical_language, current_translation), + status: post.status || :draft, dirty?: Workbench.dirty?(assigns.workbench, :post, post.id), save_state: Map.get(assigns.post_editor_save_states, post.id, :idle), + quick_actions_open?: Map.get(assigns.post_editor_quick_actions_open, post.id, false), 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), + can_publish?: (post.status || :draft) == :draft, + can_delete?: (post.status || :draft) == :published, + has_published_version?: has_published_version?(post), + discard_label: discard_label(post), + discard_title: discard_title(post), + detect_language_enabled?: not blank?(Map.get(form, "title")) or not blank?(Map.get(form, "content")), + can_translate?: Enum.any?(languages(metadata), &(&1 != 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), + show_template_selector?: template_options(post.project_id) != [], + tag_options: Tags.list_tags(post.project_id), + tag_values: tag_values(form), + tag_chips: tag_chips(form, Tags.list_tags(post.project_id)), + tag_query: query_value(assigns, :tags, post.id), + tag_query_addable?: query_addable?(query_value(assigns, :tags, post.id), tag_values(form), Tags.list_tags(post.project_id), fn option -> option.name end), + category_values: category_values(form), + category_query: query_value(assigns, :categories, post.id), category_options: metadata.categories || [], + category_query_addable?: query_addable?(query_value(assigns, :categories, post.id), category_values(form), metadata.categories || [], & &1), + tag_suggestions: tag_suggestions(form, Tags.list_tags(post.project_id), query_value(assigns, :tags, post.id)), + category_suggestions: category_suggestions(form, metadata.categories || [], query_value(assigns, :categories, post.id)), + gallery_count: gallery_count(form), + preview_url: preview_url(post, active_language, canonical_language, Map.get(assigns.post_editor_modes, post.id, :markdown)), translation_flags: translation_flags(post, canonical_language, active_language, translations), linked_media: linked_media(post.id), post_links: post_links(post.id), @@ -313,6 +513,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) + def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted) + def project_metadata(nil), do: %{main_language: "en", blog_languages: []} def project_metadata(project_id) do @@ -322,32 +524,161 @@ defmodule BDS.Desktop.ShellLive.PostEditor do _error -> %{main_language: "en", blog_languages: []} end - defp editor_toolbar(assigns) do - ~H""" - <%= if Enum.any?(@toolbar_buttons) do %> -
- <%= for button <- @toolbar_buttons do %> - - <% end %> -
- <% end %> - """ + def tag_chip_style(nil), do: nil + + def tag_chip_style(color) do + normalized = normalize_color(color) + + if normalized do + "background-color: #{normalized}; color: #{contrast_color(normalized)}; border-color: #{normalized};" + end end defp assigned_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) + defp maybe_update_draft(socket, post_id, post, current_language, next_language, draft, true) do + 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) end + defp maybe_update_draft(socket, post_id, _post, _current_language, next_language, _draft, false) do + assign(socket, :post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)) + end + + defp put_draft_field(socket, post_id, post, active_language, field, value) do + metadata = project_metadata(post.project_id) + draft = Map.put(current_draft(socket.assigns, post, metadata, active_language), field, value) + 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, active_language, draft)) + |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) + end + + defp put_query_state(socket, post_id, kind, value) do + key = query_key(kind) + assign(socket, key, Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || ""))) + end + + defp query_value(assigns, kind, post_id) do + assigns + |> Map.get(query_key(kind), %{}) + |> Map.get(post_id, "") + end + + defp query_key(:tags), do: :post_editor_tag_queries + defp query_key(:categories), do: :post_editor_category_queries + + defp field_key(:tags), do: "tags" + defp field_key(:categories), do: "categories" + + defp tag_values(form), do: csv_to_list(Map.get(form, "tags", "")) + defp category_values(form), do: csv_to_list(Map.get(form, "categories", "")) + + defp tag_suggestions(form, options, query) do + selected = MapSet.new(tag_values(form)) + filter_suggestions(options, query, fn option -> option.name end, selected) + end + + defp tag_chips(form, options) do + option_map = Map.new(options, fn option -> {option.name, option} end) + + Enum.map(tag_values(form), fn name -> + option = Map.get(option_map, name) + %{name: name, color: option && option.color} + end) + end + + defp category_suggestions(form, options, query) do + selected = MapSet.new(category_values(form)) + filter_suggestions(options, query, & &1, selected) + end + + defp filter_suggestions(options, query, labeler, selected) do + query = normalize_query(query) + + options + |> Enum.filter(fn option -> + label = labeler.(option) + not MapSet.member?(selected, label) and (query == "" or String.contains?(String.downcase(label), query)) + end) + |> Enum.take(8) + end + + defp query_addable?(query, selected_values, options, labeler) do + normalized = normalize_query(query) + + normalized != "" and + normalized not in Enum.map(selected_values, &String.downcase/1) and + not Enum.any?(options, fn option -> String.downcase(labeler.(option)) == normalized end) + end + + defp normalize_query(value) do + value + |> to_string() + |> String.trim() + |> String.downcase() + end + + defp normalize_list_entry(value) do + value + |> to_string() + |> String.trim() + |> String.downcase() + end + + defp ensure_list_value(project_id, :tags, value) do + if Enum.any?(Tags.list_tags(project_id), &(String.downcase(&1.name) == value)) do + :ok + else + _ = Tags.create_tag(%{project_id: project_id, name: value}) + :ok + end + end + + defp ensure_list_value(project_id, :categories, value) do + {:ok, metadata} = Metadata.get_project_metadata(project_id) + + if value in (metadata.categories || []) do + :ok + else + _ = Metadata.add_category(project_id, value) + :ok + end + rescue + _error -> :ok + end + + defp normalize_color(nil), do: nil + defp normalize_color(""), do: nil + + defp normalize_color("#" <> rest = color) when byte_size(rest) == 6 do + if String.match?(rest, ~r/\A[0-9a-fA-F]{6}\z/), do: color, else: nil + end + + defp normalize_color(_color), do: nil + + defp contrast_color("#" <> rgb) do + <> = rgb + {red, _} = Integer.parse(r, 16) + {green, _} = Integer.parse(g, 16) + {blue, _} = Integer.parse(b, 16) + luminance = (red * 299 + green * 587 + blue * 114) / 1000 + if luminance > 150, do: "#1e1e1e", else: "#ffffff" + end + + defp contrast_color(_color), do: "#ffffff" + + defp reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench) + defp persisted_form(post, metadata, active_language, translations) do canonical_language = canonical_language(post, metadata) translation = Map.get(translations, active_language) @@ -417,10 +748,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do |> 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, @@ -523,14 +850,52 @@ defmodule BDS.Desktop.ShellLive.PostEditor 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") + defp has_published_version?(%Post{} = post), do: not is_nil(post.published_at) or post.file_path not in [nil, ""] + + defp discard_label(%Post{} = post) do + if has_published_version?(post), do: translated("Discard Changes"), else: translated("Discard Draft") + end + + defp discard_title(%Post{} = post) do + if has_published_version?(post), do: translated("Discard changes and restore the published version"), else: translated("Delete this unpublished draft") + end + + defp gallery_count(form) do + form + |> Map.get("content", "") + |> to_string() + |> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1)) + |> length() + end + + defp preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview, do: nil + + defp preview_url(%Post{} = post, active_language, canonical_language, :preview) do + with {:ok, server} <- Preview.start_preview(post.project_id) do + base_url = "http://#{server.host}:#{server.port}" + query = + %{} + |> maybe_put_query("draft", "true") + |> maybe_put_query("post_id", post.id) + |> maybe_put_query("lang", active_language != canonical_language && active_language) + + base_url <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query) else - translated("Translation: %{language}", %{language: String.upcase(active_language)}) + _other -> nil end end + defp canonical_preview_path(created_at_ms, slug) do + datetime = DateTime.from_unix!(created_at_ms, :millisecond) + "/#{datetime.year}/#{pad2(datetime.month)}/#{pad2(datetime.day)}/#{slug || ""}" + end + + defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0") + + defp maybe_put_query(query, _key, false), do: query + defp maybe_put_query(query, _key, nil), do: query + defp maybe_put_query(query, key, value), do: Map.put(query, key, value) + defp save_canonical_draft(%Post{id: post_id}, draft) do Posts.update_post(post_id, %{ title: blank_to_nil(Map.get(draft, "title")), 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 index 892a834..80fb259 100644 --- 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 @@ -1,223 +1,397 @@ -
-
-
-
<%= translated("Post") %>
-
-

<%= @post_editor.display_title %>

+
+
+
+
+ <%= @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) %> - - - - -
-
+ <%= if @post_editor.save_state in [:saving] do %> + <%= post_editor_save_state_label(@post_editor.save_state) %> + <% end %> -
- - -
- <%= for flag <- @post_editor.translation_flags do %> +
+ + <%= if @post_editor.quick_actions_open? do %> +
+ + +
+ + +
+ <% end %> +
+ + <%= if @post_editor.can_publish? do %> + + <% end %> + <%= if @post_editor.can_publish? do %> + + <% end %> + <%= if @post_editor.can_delete? do %> + <% end %>
- <%= editor_toolbar(assigns) %> - -
- - -
- -
- <%= if @post_editor.excerpt_expanded do %> - - <% end %> - -
- <%= translated("Content") %> -
- <%= for mode <- [:visual, :markdown, :preview] do %> +
+ <%= for flag <- @post_editor.translation_flags do %> <% end %>
- <%= if @post_editor.mode == :preview do %> -
<%= raw(Earmark.as_html!(@post_editor.form["content"] || "")) %>
- <% else %> - - <% end %> +
+
+
+ + +
+ +
+ +
+ +
+ <%= for tag <- @post_editor.tag_chips do %> + + <%= tag.name %> + + + <% end %> + + +
+ + <%= if String.trim(@post_editor.tag_query || "") != "" and (Enum.any?(@post_editor.tag_suggestions) or @post_editor.tag_query_addable?) do %> +
+ <%= for tag <- @post_editor.tag_suggestions do %> + + <% end %> + + <%= if @post_editor.tag_query_addable? do %> + + <% end %> +
+ <% end %> +
+
+ +
+ + +
+ +
+ +
+ + + +
+
+ +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+ <%= for category <- @post_editor.category_values do %> + + <%= category %> + + + <% end %> + + +
+ + <%= if String.trim(@post_editor.category_query || "") != "" and (Enum.any?(@post_editor.category_suggestions) or @post_editor.category_query_addable?) do %> +
+ <%= for category <- @post_editor.category_suggestions do %> + + <% end %> + + <%= if @post_editor.category_query_addable? do %> + + <% end %> +
+ <% end %> +
+
+
+ + <%= if @post_editor.show_template_selector? do %> +
+ + +
+ <% end %> + +
+ <%= translated("Post Links") %> +
+
+ <%= translated("Backlinks") %> + <%= if Enum.any?(@post_editor.post_links.backlinks) do %> +
    + <%= for item <- @post_editor.post_links.backlinks do %> +
  • <%= item.title %>
  • + <% end %> +
+ <% else %> + <%= translated("No items") %> + <% end %> +
+
+ <%= translated("Links To") %> + <%= if Enum.any?(@post_editor.post_links.outlinks) do %> +
    + <%= for item <- @post_editor.post_links.outlinks do %> +
  • <%= item.title %>
  • + <% end %> +
+ <% else %> + <%= translated("No items") %> + <% end %> +
+
+
+
+ + +
+ + + +
+
+ + +
+
+ +
+
+
+ +
+ +
+
+ <%= for mode <- [:visual, :markdown, :preview] do %> + + <% end %> +
+
+ +
+ <%= if @post_editor.mode == :markdown do %> + + + <% end %> + + <%= if @post_editor.gallery_count > 0 do %> + + <% end %> +
+
+ + <%= if @post_editor.mode == :preview do %> +
+ <%= if @post_editor.preview_url do %> + + <% else %> +
<%= translated("Preview unavailable") %>
+ <% end %> +
+ <% else %> + + <% end %> +
-
+