fix: more alignment with old app
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
<div class="editor-toolbar">
|
||||
<%= for button <- @toolbar_buttons do %>
|
||||
<button
|
||||
class={["editor-toolbar-button", if(button.destructive, do: "is-destructive")]}
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind={button.kind}
|
||||
>
|
||||
<%= translated(button.label) %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
"""
|
||||
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
|
||||
<<r::binary-size(2), g::binary-size(2), b::binary-size(2)>> = 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")),
|
||||
|
||||
@@ -1,223 +1,397 @@
|
||||
<div class="post-editor" data-testid="post-editor">
|
||||
<div class="post-editor-header">
|
||||
<div class="post-editor-heading">
|
||||
<div class="editor-kicker"><%= translated("Post") %></div>
|
||||
<div class="post-editor-title-row">
|
||||
<h1 class="editor-title" data-testid="editor-title"><%= @post_editor.display_title %></h1>
|
||||
<div class="post-editor editor" data-testid="post-editor">
|
||||
<div class="editor-header">
|
||||
<div class="editor-tabs">
|
||||
<div class={["editor-tab", "active", if(@post_editor.dirty?, do: "dirty")]}>
|
||||
<span class="editor-tab-title" data-testid="editor-title"><%= @post_editor.display_title %></span>
|
||||
<%= if @post_editor.dirty? do %>
|
||||
<span class="post-editor-dirty-dot">●</span>
|
||||
<span class="editor-tab-dirty" title={translated("Unsaved")}>●</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="editor-subtitle"><%= @post_editor.subtitle %></p>
|
||||
</div>
|
||||
|
||||
<div class="post-editor-actions">
|
||||
<span class={["post-status-badge", "status-#{@post_editor.status}"]} data-testid="post-status-badge">
|
||||
<div class="editor-actions">
|
||||
<span class={["status-badge", "status-#{@post_editor.status}"]} data-testid="post-status-badge">
|
||||
<%= post_status_label(@post_editor.status) %>
|
||||
</span>
|
||||
<span class="post-save-state"><%= post_editor_save_state_label(@post_editor.save_state) %></span>
|
||||
<button class="editor-toolbar-button" type="button" phx-click="save_post_editor" phx-value-id={@post_editor.id}>
|
||||
<%= translated("Save") %>
|
||||
</button>
|
||||
<button class="editor-toolbar-button" data-testid="post-publish-button" type="button" phx-click="publish_post_editor" phx-value-id={@post_editor.id}>
|
||||
<%= translated("Publish") %>
|
||||
</button>
|
||||
<button class="editor-toolbar-button" data-testid="post-discard-button" type="button" phx-click="discard_post_editor" phx-value-id={@post_editor.id}>
|
||||
<%= translated("Discard") %>
|
||||
</button>
|
||||
<button class="editor-toolbar-button is-destructive" data-testid="post-delete-button" type="button" phx-click="delete_post_editor" phx-value-id={@post_editor.id}>
|
||||
<%= translated("Delete") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<%= if @post_editor.save_state in [:saving] do %>
|
||||
<span class="auto-save-indicator"><%= post_editor_save_state_label(@post_editor.save_state) %></span>
|
||||
<% end %>
|
||||
|
||||
<div class="post-editor-flags-bar">
|
||||
<button class="post-editor-section-toggle" type="button" phx-click="toggle_post_metadata" phx-value-id={@post_editor.id}>
|
||||
<span><%= if @post_editor.metadata_expanded, do: "▼", else: "▶" %></span>
|
||||
<span><%= translated("Metadata") %></span>
|
||||
</button>
|
||||
|
||||
<div class="post-editor-flags">
|
||||
<%= for flag <- @post_editor.translation_flags do %>
|
||||
<div class="quick-actions-wrapper">
|
||||
<button
|
||||
class={[
|
||||
"translation-flag-button",
|
||||
if(flag.active, do: "is-active"),
|
||||
"status-#{flag.status}"
|
||||
]}
|
||||
class="secondary quick-actions-btn"
|
||||
type="button"
|
||||
phx-click="select_post_editor_language"
|
||||
phx-click="toggle_post_editor_quick_actions"
|
||||
phx-value-id={@post_editor.id}
|
||||
phx-value-language={flag.language}
|
||||
title={flag.label}
|
||||
>
|
||||
<span><%= flag.flag %></span>
|
||||
<span><%= String.upcase(flag.language) %></span>
|
||||
<%= translated("Quick Actions") %>
|
||||
</button>
|
||||
|
||||
<%= if @post_editor.quick_actions_open? do %>
|
||||
<div class="quick-actions-menu">
|
||||
<button
|
||||
class="quick-action-item"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="ai_suggestions"
|
||||
disabled={not @post_editor.detect_language_enabled?}
|
||||
>
|
||||
<span class="quick-action-icon">🤖</span>
|
||||
<span class="quick-action-text">
|
||||
<strong><%= translated("AI Suggestions") %></strong>
|
||||
<small><%= translated("Review title, excerpt, and content suggestions") %></small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="quick-actions-divider"></div>
|
||||
|
||||
<button
|
||||
class="quick-action-item"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="language_picker"
|
||||
disabled={not @post_editor.can_translate?}
|
||||
>
|
||||
<span class="quick-action-icon">🌍</span>
|
||||
<span class="quick-action-text">
|
||||
<strong><%= translated("Translate") %></strong>
|
||||
<small><%= translated("Select a target language for this post") %></small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @post_editor.can_publish? do %>
|
||||
<button class="success" data-testid="post-publish-button" type="button" phx-click="publish_post_editor" phx-value-id={@post_editor.id}>
|
||||
<%= translated("Publish") %>
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if @post_editor.can_publish? do %>
|
||||
<button class="secondary danger" data-testid="post-discard-button" type="button" phx-click="discard_post_editor" phx-value-id={@post_editor.id} title={@post_editor.discard_title}>
|
||||
<%= @post_editor.discard_label %>
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if @post_editor.can_delete? do %>
|
||||
<button class="secondary danger" data-testid="post-delete-button" type="button" phx-click="delete_post_editor" phx-value-id={@post_editor.id}>
|
||||
<%= translated("Delete") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= editor_toolbar(assigns) %>
|
||||
|
||||
<form class="post-editor-form" data-testid="post-editor-form" phx-change="change_post_editor">
|
||||
<div class={["post-editor-metadata-grid", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}>
|
||||
<div class="post-editor-column">
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Title") %></span>
|
||||
<input class="post-editor-input" type="text" name="post_editor[title]" value={@post_editor.form["title"]} />
|
||||
</label>
|
||||
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Tags") %></span>
|
||||
<input class="post-editor-input" type="text" name="post_editor[tags]" value={@post_editor.form["tags"]} list={"post-editor-tags-#{@post_editor.id}"} />
|
||||
<datalist id={"post-editor-tags-#{@post_editor.id}"}>
|
||||
<%= for tag_name <- @post_editor.tag_options do %>
|
||||
<option value={tag_name}></option>
|
||||
<% end %>
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Author") %></span>
|
||||
<input class="post-editor-input" type="text" name="post_editor[author]" value={@post_editor.form["author"]} />
|
||||
</label>
|
||||
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Language") %></span>
|
||||
<select class="post-editor-input" name="post_editor[language]" disabled={not @post_editor.editing_canonical?}>
|
||||
<%= for language <- @post_editor.languages do %>
|
||||
<option value={language} selected={language == @post_editor.form["language"]}><%= String.upcase(language) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="post-editor-field post-editor-checkbox-field">
|
||||
<input type="hidden" name="post_editor[do_not_translate]" value="false" />
|
||||
<input type="checkbox" name="post_editor[do_not_translate]" value="true" checked={@post_editor.form["do_not_translate"]} disabled={not @post_editor.editing_canonical?} />
|
||||
<span><%= translated("Do Not Translate") %></span>
|
||||
</label>
|
||||
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Slug") %></span>
|
||||
<input class="post-editor-input is-readonly" type="text" readonly value={@post_editor.slug} />
|
||||
</label>
|
||||
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Categories") %></span>
|
||||
<input class="post-editor-input" type="text" name="post_editor[categories]" value={@post_editor.form["categories"]} list={"post-editor-categories-#{@post_editor.id}"} disabled={not @post_editor.editing_canonical?} />
|
||||
<datalist id={"post-editor-categories-#{@post_editor.id}"}>
|
||||
<%= for category <- @post_editor.category_options do %>
|
||||
<option value={category}></option>
|
||||
<% end %>
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Template") %></span>
|
||||
<select class="post-editor-input" name="post_editor[template_slug]" disabled={not @post_editor.editing_canonical?}>
|
||||
<option value=""><%= translated("Default") %></option>
|
||||
<%= for template <- @post_editor.template_options do %>
|
||||
<option value={template.slug} selected={template.slug == @post_editor.form["template_slug"]}><%= template.title %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="post-editor-links-panel">
|
||||
<strong><%= translated("Post Links") %></strong>
|
||||
<div class="post-editor-links-columns">
|
||||
<div>
|
||||
<span class="post-editor-links-label"><%= translated("Backlinks") %></span>
|
||||
<%= if Enum.any?(@post_editor.post_links.backlinks) do %>
|
||||
<ul class="editor-list compact">
|
||||
<%= for item <- @post_editor.post_links.backlinks do %>
|
||||
<li><%= item.title %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<span class="post-editor-empty"><%= translated("No items") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<span class="post-editor-links-label"><%= translated("Links To") %></span>
|
||||
<%= if Enum.any?(@post_editor.post_links.outlinks) do %>
|
||||
<ul class="editor-list compact">
|
||||
<%= for item <- @post_editor.post_links.outlinks do %>
|
||||
<li><%= item.title %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<span class="post-editor-empty"><%= translated("No items") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-editor-column post-editor-side-panel">
|
||||
<div class="post-editor-side-panel-header">
|
||||
<strong><%= translated("Linked Media") %></strong>
|
||||
<div class="post-editor-side-actions">
|
||||
<button class="editor-toolbar-button" type="button" phx-click="open_overlay" phx-value-kind="insert_media"><%= translated("Insert Media") %></button>
|
||||
<button class="editor-toolbar-button" type="button" phx-click="open_overlay" phx-value-kind="gallery"><%= translated("Gallery") %></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if Enum.any?(@post_editor.linked_media) do %>
|
||||
<ul class="post-editor-media-list">
|
||||
<%= for item <- @post_editor.linked_media do %>
|
||||
<li class="post-editor-media-item">
|
||||
<span class="post-editor-media-title"><%= item.name %></span>
|
||||
<span class="post-editor-media-meta"><%= translated("Order") %>: <%= item.sort_order %></span>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<div class="post-editor-empty"><%= translated("No linked media") %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-editor-excerpt-header">
|
||||
<button class="post-editor-section-toggle" type="button" phx-click="toggle_post_excerpt" phx-value-id={@post_editor.id}>
|
||||
<span><%= if @post_editor.excerpt_expanded, do: "▼", else: "▶" %></span>
|
||||
<span><%= translated("Excerpt") %></span>
|
||||
<form class="post-editor-form editor-content" data-testid="post-editor-form" phx-change="change_post_editor">
|
||||
<div class="metadata-toggle-header">
|
||||
<button class={["metadata-toggle", if(@post_editor.metadata_expanded, do: "expanded")]} type="button" phx-click="toggle_post_metadata" phx-value-id={@post_editor.id}>
|
||||
<span class="metadata-toggle-chevron"><%= if @post_editor.metadata_expanded, do: "▼", else: "▶" %></span>
|
||||
<span><%= translated("Metadata") %></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @post_editor.excerpt_expanded do %>
|
||||
<label class="post-editor-field">
|
||||
<textarea class="post-editor-textarea post-editor-excerpt" name="post_editor[excerpt]" rows="4"><%= @post_editor.form["excerpt"] %></textarea>
|
||||
</label>
|
||||
<% end %>
|
||||
|
||||
<div class="post-editor-body-header">
|
||||
<span class="post-editor-body-label"><%= translated("Content") %></span>
|
||||
<div class="post-editor-mode-toggle">
|
||||
<%= for mode <- [:visual, :markdown, :preview] do %>
|
||||
<div class="editor-translations-flags" aria-label={translated("Translations")}>
|
||||
<%= for flag <- @post_editor.translation_flags do %>
|
||||
<button
|
||||
class={["post-editor-mode-button", if(@post_editor.mode == mode, do: "is-active")]}
|
||||
class={[
|
||||
"editor-translation-flag",
|
||||
"status-#{flag.status}",
|
||||
if(flag.active, do: "active")
|
||||
]}
|
||||
type="button"
|
||||
phx-click="set_post_editor_mode"
|
||||
phx-click="select_post_editor_language"
|
||||
phx-value-id={@post_editor.id}
|
||||
phx-value-mode={mode}
|
||||
phx-value-language={flag.language}
|
||||
title={flag.label}
|
||||
aria-label={flag.label}
|
||||
>
|
||||
<%= post_editor_mode_label(mode) %>
|
||||
<%= flag.flag %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @post_editor.mode == :preview do %>
|
||||
<div class="post-editor-preview" data-testid="post-editor-preview"><%= raw(Earmark.as_html!(@post_editor.form["content"] || "")) %></div>
|
||||
<% else %>
|
||||
<label class="post-editor-field post-editor-content-field">
|
||||
<textarea class="post-editor-textarea post-editor-content" data-testid="post-editor-content" name="post_editor[content]" rows="18"><%= @post_editor.form["content"] %></textarea>
|
||||
</label>
|
||||
<% end %>
|
||||
<div class={["editor-header-row", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}>
|
||||
<div class="editor-meta">
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Title") %></label>
|
||||
<input class="post-editor-input" type="text" name="post_editor[title]" value={@post_editor.form["title"]} />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Tags") %></label>
|
||||
<div class="tag-input-container">
|
||||
<input type="hidden" name="post_editor[tags]" value={@post_editor.form["tags"]} />
|
||||
<div class="tag-input-wrapper">
|
||||
<%= for tag <- @post_editor.tag_chips do %>
|
||||
<span class={["tag-chip", if(tag.color, do: "has-color")]} style={tag_chip_style(tag.color)}>
|
||||
<span><%= tag.name %></span>
|
||||
<button class="tag-chip-remove" type="button" phx-click="remove_post_editor_tag" phx-value-id={@post_editor.id} phx-value-tag={tag.name} aria-label={translated("Remove tag")}>×</button>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<input
|
||||
class="tag-input-field"
|
||||
type="text"
|
||||
name="post_editor[tag_query]"
|
||||
value={@post_editor.tag_query}
|
||||
placeholder={translated("Add tag")}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if String.trim(@post_editor.tag_query || "") != "" and (Enum.any?(@post_editor.tag_suggestions) or @post_editor.tag_query_addable?) do %>
|
||||
<div class="tag-suggestions">
|
||||
<%= for tag <- @post_editor.tag_suggestions do %>
|
||||
<button class="tag-suggestion" type="button" phx-click="add_post_editor_tag" phx-value-id={@post_editor.id} phx-value-tag={tag.name}>
|
||||
<%= if tag.color do %>
|
||||
<span class="tag-suggestion-color" style={"background-color: #{tag.color}"}></span>
|
||||
<% end %>
|
||||
<span class="tag-suggestion-name"><%= tag.name %></span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @post_editor.tag_query_addable? do %>
|
||||
<button class="tag-suggestion create-new" type="button" phx-click="add_post_editor_tag" phx-value-id={@post_editor.id} phx-value-tag={@post_editor.tag_query}>
|
||||
<span class="tag-suggestion-icon">+</span>
|
||||
<span><%= translated("Create tag") %>: <strong><%= @post_editor.tag_query %></strong></span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Author") %></label>
|
||||
<input class="post-editor-input" type="text" name="post_editor[author]" value={@post_editor.form["author"]} />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Language") %></label>
|
||||
<div class="editor-language-row">
|
||||
<select class="post-editor-input" name="post_editor[language]">
|
||||
<%= for language <- @post_editor.languages do %>
|
||||
<option value={language} selected={language == @post_editor.form["language"]}><%= String.upcase(language) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="secondary compact"
|
||||
data-testid="post-detect-language-button"
|
||||
type="button"
|
||||
phx-click="detect_post_editor_language"
|
||||
phx-value-id={@post_editor.id}
|
||||
disabled={not @post_editor.detect_language_enabled?}
|
||||
>
|
||||
<%= translated("Detect") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label class="editor-checkbox-label">
|
||||
<input type="hidden" name="post_editor[do_not_translate]" value="false" />
|
||||
<input type="checkbox" name="post_editor[do_not_translate]" value="true" checked={@post_editor.form["do_not_translate"]} />
|
||||
<span><%= translated("Do Not Translate") %></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="editor-field-row">
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Slug") %></label>
|
||||
<input class="post-editor-input is-readonly" type="text" readonly value={@post_editor.slug} />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Categories") %></label>
|
||||
<div class="tag-input-container">
|
||||
<input type="hidden" name="post_editor[categories]" value={@post_editor.form["categories"]} />
|
||||
<div class="tag-input-wrapper">
|
||||
<%= for category <- @post_editor.category_values do %>
|
||||
<span class="tag-chip">
|
||||
<span><%= category %></span>
|
||||
<button class="tag-chip-remove" type="button" phx-click="remove_post_editor_category" phx-value-id={@post_editor.id} phx-value-category={category} aria-label={translated("Remove category")}>×</button>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<input
|
||||
class="tag-input-field"
|
||||
type="text"
|
||||
name="post_editor[category_query]"
|
||||
value={@post_editor.category_query}
|
||||
placeholder={translated("Add category")}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if String.trim(@post_editor.category_query || "") != "" and (Enum.any?(@post_editor.category_suggestions) or @post_editor.category_query_addable?) do %>
|
||||
<div class="tag-suggestions">
|
||||
<%= for category <- @post_editor.category_suggestions do %>
|
||||
<button class="tag-suggestion" type="button" phx-click="add_post_editor_category" phx-value-id={@post_editor.id} phx-value-category={category}>
|
||||
<span class="tag-suggestion-name"><%= category %></span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @post_editor.category_query_addable? do %>
|
||||
<button class="tag-suggestion create-new" type="button" phx-click="add_post_editor_category" phx-value-id={@post_editor.id} phx-value-category={@post_editor.category_query}>
|
||||
<span class="tag-suggestion-icon">+</span>
|
||||
<span><%= translated("Create category") %>: <strong><%= @post_editor.category_query %></strong></span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @post_editor.show_template_selector? do %>
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Template") %></label>
|
||||
<select class="post-editor-input" name="post_editor[template_slug]">
|
||||
<option value=""><%= translated("Default") %></option>
|
||||
<%= for template <- @post_editor.template_options do %>
|
||||
<option value={template.slug} selected={template.slug == @post_editor.form["template_slug"]}><%= template.title %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="post-editor-links-panel">
|
||||
<strong><%= translated("Post Links") %></strong>
|
||||
<div class="post-editor-links-columns">
|
||||
<div>
|
||||
<span class="post-editor-links-label"><%= translated("Backlinks") %></span>
|
||||
<%= if Enum.any?(@post_editor.post_links.backlinks) do %>
|
||||
<ul class="editor-list compact">
|
||||
<%= for item <- @post_editor.post_links.backlinks do %>
|
||||
<li><%= item.title %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<span class="post-editor-empty"><%= translated("No items") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<span class="post-editor-links-label"><%= translated("Links To") %></span>
|
||||
<%= if Enum.any?(@post_editor.post_links.outlinks) do %>
|
||||
<ul class="editor-list compact">
|
||||
<%= for item <- @post_editor.post_links.outlinks do %>
|
||||
<li><%= item.title %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<span class="post-editor-empty"><%= translated("No items") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="editor-media-panel post-editor-side-panel">
|
||||
<div class="post-editor-side-panel-header">
|
||||
<strong><%= translated("Linked Media") %></strong>
|
||||
</div>
|
||||
|
||||
<%= if Enum.any?(@post_editor.linked_media) do %>
|
||||
<ul class="post-editor-media-list">
|
||||
<%= for item <- @post_editor.linked_media do %>
|
||||
<li class="post-editor-media-item">
|
||||
<span class="post-editor-media-title"><%= item.name %></span>
|
||||
<span class="post-editor-media-meta"><%= translated("Order") %>: <%= item.sort_order %></span>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<div class="post-editor-empty"><%= translated("No linked media") %></div>
|
||||
<% end %>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<button class={["metadata-toggle", if(@post_editor.excerpt_expanded, do: "expanded")]} type="button" phx-click="toggle_post_excerpt" phx-value-id={@post_editor.id}>
|
||||
<span class="metadata-toggle-chevron"><%= if @post_editor.excerpt_expanded, do: "▼", else: "▶" %></span>
|
||||
<span><%= translated("Excerpt") %></span>
|
||||
</button>
|
||||
|
||||
<div class={["editor-excerpt-panel", if(not @post_editor.excerpt_expanded, do: "is-collapsed")]}>
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Excerpt") %></label>
|
||||
<textarea class="post-editor-textarea post-editor-excerpt" name="post_editor[excerpt]" rows="4"><%= @post_editor.form["excerpt"] %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-body">
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-toolbar-left">
|
||||
<label><%= translated("Content") %></label>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar-center">
|
||||
<div class="editor-mode-toggle">
|
||||
<%= for mode <- [:visual, :markdown, :preview] do %>
|
||||
<button
|
||||
class={if(@post_editor.mode == mode, do: "active")}
|
||||
type="button"
|
||||
phx-click="set_post_editor_mode"
|
||||
phx-value-id={@post_editor.id}
|
||||
phx-value-mode={mode}
|
||||
>
|
||||
<%= post_editor_mode_label(mode) %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar-right">
|
||||
<%= if @post_editor.mode == :markdown do %>
|
||||
<button
|
||||
class="insert-post-link-button"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="insert_link"
|
||||
>
|
||||
<%= translated("Insert Link") %>
|
||||
</button>
|
||||
<button
|
||||
class="insert-media-button"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="insert_media"
|
||||
>
|
||||
<%= translated("Insert Media") %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @post_editor.gallery_count > 0 do %>
|
||||
<button
|
||||
class="gallery-button"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="gallery"
|
||||
>
|
||||
<%= translated("Gallery") %> (<%= @post_editor.gallery_count %>)
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @post_editor.mode == :preview do %>
|
||||
<div class="editor-preview post-editor-preview" data-testid="post-editor-preview">
|
||||
<%= if @post_editor.preview_url do %>
|
||||
<iframe class="editor-preview-frame" src={@post_editor.preview_url}></iframe>
|
||||
<% else %>
|
||||
<div class="post-editor-empty"><%= translated("Preview unavailable") %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<textarea id={"post-editor-content-#{@post_editor.id}"} class="post-editor-textarea post-editor-content" data-testid="post-editor-content" phx-hook="PostEditorContent" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18"><%= @post_editor.form["content"] %></textarea>
|
||||
<% end %>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="post-editor-footer">
|
||||
<div class="editor-footer">
|
||||
<span><strong><%= translated("Created") %>:</strong> <%= @post_editor.footer.created_at %></span>
|
||||
<span><strong><%= translated("Updated") %>:</strong> <%= @post_editor.footer.updated_at %></span>
|
||||
<%= if @post_editor.footer.published_at do %>
|
||||
|
||||
Reference in New Issue
Block a user