Files
bDS2/lib/bds/desktop/shell_live/post_editor.ex
2026-05-09 17:33:51 +02:00

824 lines
25 KiB
Elixir

defmodule BDS.Desktop.ShellLive.PostEditor do
@moduledoc false
use Phoenix.LiveComponent
alias BDS.{AI, Posts, Preview}
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.Notify
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
alias BDS.Posts.Post
alias BDS.Tags
import DraftManagement,
only: [
current_draft: 4,
editing_canonical_language?: 3,
normalize_language: 2,
normalize_mode: 1,
normalize_params: 3,
persisted_form: 3,
persisted_form: 4,
record_status: 1,
record_title: 2,
save_state_for_action: 1
]
import ListValues,
only: [
category_suggestions: 3,
category_values: 1,
csv_to_list: 1,
ensure_list_value: 3,
field_key: 1,
normalize_list_entry: 1,
query_addable?: 4,
tag_chips: 2,
tag_suggestions: 3,
tag_values: 1
]
import Persistence,
only: [
discard: 3,
discard_label: 1,
discard_title: 1,
has_published_version?: 1,
persist: 5
]
use Gettext, backend: BDS.Gettext
import PostMetadata,
only: [
blank?: 1,
blank_to_nil: 1,
canonical_language: 2,
display_title: 3,
footer: 4,
gallery_count: 1,
languages: 1,
linked_media: 1,
post_links: 1,
preview_url: 4,
project_metadata: 1,
template_options: 1,
translation_flags: 4,
translations: 1
]
defdelegate tag_chip_style(color), to: ListValues
embed_templates("post_editor_html/*")
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
@impl true
def update(%{action: :save} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action]))
|> do_save()
{:ok, socket}
end
def update(%{action: :publish} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action]))
|> do_publish()
{:ok, socket}
end
def update(%{action: :close_quick_actions} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action]))
|> assign(:quick_actions_open?, false)
|> build_data()
{:ok, socket}
end
def update(%{action: :insert_content, content: content} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action, :content]))
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
id: socket.assigns.post_id,
content: content
})
|> assign(:shell_overlay, nil)
{:ok, socket}
end
def update(%{action: :translate, language: language} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action, :language]))
|> do_translate(language)
{:ok, socket}
end
def update(%{action: :apply_ai_suggestions, fields: fields} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action, :fields]))
|> do_apply_ai_suggestions(fields)
{:ok, socket}
end
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> ensure_state()
|> build_data()
{:ok, socket}
end
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
@impl true
def render(%{post_editor: nil} = assigns), do: ~H"<div></div>"
def render(assigns) do
post_editor(assigns)
end
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@impl true
def handle_event("change_post_editor", %{"post_editor" => params}, socket) do
post_id = socket.assigns.post_id
current_language = socket.assigns.active_language
metadata = socket.assigns.project_metadata
post = socket.assigns.post
requested_language = normalize_language(Map.get(params, "language"), current_language)
next_language =
if current_language == socket.assigns.canonical_language do
requested_language
else
current_language
end
draft = normalize_params(params, current_language, next_language)
current = component_current_draft(socket, post, metadata, next_language)
dirty? = draft != current
was_dirty? = socket.assigns.dirty?
socket =
socket
|> assign(:tag_query, Map.get(params, "tag_query", ""))
|> assign(:category_query, Map.get(params, "category_query", ""))
|> maybe_update_component_draft(next_language, draft)
|> assign(:dirty?, dirty?)
|> build_data()
if dirty? != was_dirty? do
Notify.dirty(:post, post_id, dirty?)
end
{:noreply, socket}
end
def handle_event("save_post_editor", _params, socket) do
{:noreply, do_save(socket)}
end
def handle_event("publish_post_editor", _params, socket) do
{:noreply, do_publish(socket)}
end
def handle_event("discard_post_editor", _params, socket) do
{:noreply, do_discard(socket)}
end
def handle_event("delete_post_editor", _params, socket) do
{:noreply, do_delete(socket)}
end
def handle_event("set_post_editor_mode", %{"mode" => mode}, socket) do
normalized_mode = normalize_mode(mode)
if normalized_mode == :preview do
case socket.assigns.post do
%Post{} = post -> _ = Preview.ensure_preview(post.project_id)
_other -> :ok
end
end
socket =
socket
|> assign(:mode, normalized_mode)
|> build_data()
{:noreply, socket}
end
def handle_event("toggle_post_metadata", _params, socket) do
socket =
socket
|> assign(:expanded, Map.update(socket.assigns.expanded, :metadata, false, &(!&1)))
|> build_data()
{:noreply, socket}
end
def handle_event("toggle_post_excerpt", _params, socket) do
socket =
socket
|> assign(:expanded, Map.update(socket.assigns.expanded, :excerpt, false, &(!&1)))
|> build_data()
{:noreply, socket}
end
def handle_event("select_post_editor_language", %{"language" => language}, socket) do
socket =
socket
|> assign(:active_language, normalize_language(language, language))
|> build_data()
{:noreply, socket}
end
def handle_event("toggle_post_editor_quick_actions", _params, socket) do
socket =
socket
|> assign(:quick_actions_open?, not socket.assigns.quick_actions_open?)
|> build_data()
{:noreply, socket}
end
def handle_event("detect_post_editor_language", _params, socket) do
{:noreply, do_detect_language(socket)}
end
def handle_event("add_post_editor_tag", %{"tag" => tag}, socket) do
{:noreply, do_add_list_value(socket, :tags, tag)}
end
def handle_event("remove_post_editor_tag", %{"tag" => tag}, socket) do
{:noreply, do_remove_list_value(socket, :tags, tag)}
end
def handle_event("add_post_editor_category", %{"category" => category}, socket) do
{:noreply, do_add_list_value(socket, :categories, category)}
end
def handle_event("remove_post_editor_category", %{"category" => category}, socket) do
{:noreply, do_remove_list_value(socket, :categories, category)}
end
def handle_event("insert_content", %{"content" => content}, socket) do
socket =
socket
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
id: socket.assigns.post_id,
content: content
})
|> assign(:shell_overlay, nil)
{:noreply, socket}
end
def handle_event("close_quick_actions", _params, socket) do
socket =
socket
|> assign(:quick_actions_open?, false)
|> build_data()
{:noreply, socket}
end
defp component_current_draft(socket, post, metadata, active_language) do
persisted = persisted_form(post, metadata, active_language)
Map.get(socket.assigns.drafts, active_language, persisted)
end
defp ensure_state(socket) do
post_id = socket.assigns.current_tab.id
post = Posts.get_post(post_id)
metadata = project_metadata(post && post.project_id)
canonical = if post, do: canonical_language(post, metadata), else: "en"
defaults = %{
post_id: post_id,
post: post,
project_metadata: metadata,
canonical_language: canonical,
active_language: canonical,
drafts: %{},
tag_query: "",
category_query: "",
quick_actions_open?: false,
mode: :markdown,
expanded: %{metadata: post && blank?(post.title), excerpt: post && not blank?(post.excerpt)},
save_state: :idle,
dirty?: false
}
Enum.reduce(defaults, socket, fn {key, default}, acc ->
if is_nil(Map.get(acc.assigns, key)) do
assign(acc, key, default)
else
acc
end
end)
end
defp build_data(socket) do
case socket.assigns.post do
nil ->
assign(socket, :post_editor, nil)
%Post{} = post ->
metadata = socket.assigns.project_metadata
canonical_language = socket.assigns.canonical_language
active_language = socket.assigns.active_language
translations = translations(post.id)
persisted = persisted_form(post, metadata, active_language, translations)
form =
socket.assigns.drafts
|> Map.get(active_language, persisted)
expanded = socket.assigns.expanded
current_translation = Map.get(translations, active_language)
data = %{
id: post.id,
display_title: display_title(form["title"], post.slug, post.id),
subtitle: nil,
slug: post.slug || post.id,
status: post.status,
dirty?: socket.assigns.dirty?,
save_state: socket.assigns.save_state,
quick_actions_open?: socket.assigns.quick_actions_open?,
metadata_expanded: Map.get(expanded, :metadata, false),
excerpt_expanded: Map.get(expanded, :excerpt, false),
mode: socket.assigns.mode,
editing_canonical?:
editing_canonical_language?(translations, active_language, canonical_language),
can_publish?: post.status == :draft,
can_delete?: post.status == :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),
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: socket.assigns.tag_query,
tag_query_addable?:
query_addable?(
socket.assigns.tag_query,
tag_values(form),
Tags.list_tags(post.project_id),
fn option -> option.name end
),
category_values: category_values(form),
category_query: socket.assigns.category_query,
category_options: metadata.categories || [],
category_query_addable?:
query_addable?(
socket.assigns.category_query,
category_values(form),
metadata.categories || [],
& &1
),
tag_suggestions:
tag_suggestions(
form,
Tags.list_tags(post.project_id),
socket.assigns.tag_query
),
category_suggestions:
category_suggestions(
form,
metadata.categories || [],
socket.assigns.category_query
),
gallery_count: gallery_count(form),
preview_url:
preview_url(
post,
active_language,
canonical_language,
socket.assigns.mode
),
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)
}
assign(socket, :post_editor, data)
end
end
defp do_save(socket) do
post = socket.assigns.post
case post do
nil ->
socket
%Post{} = post ->
metadata = socket.assigns.project_metadata
active_language = socket.assigns.active_language
draft = component_current_draft(socket, post, metadata, active_language)
case persist(post, draft, active_language, metadata, :save) do
{:ok, record} ->
refreshed_post = Posts.get_post!(post.id)
_refreshed_form = persisted_form(refreshed_post, metadata, active_language)
socket =
socket
|> assign(:post, refreshed_post)
|> assign(:drafts, Map.delete(socket.assigns.drafts, active_language))
|> assign(:save_state, save_state_for_action(:save))
|> assign(:dirty?, false)
|> build_data()
Notify.tab_meta(:post, post.id, record_title(record, refreshed_post),
Atom.to_string(record_status(record)))
Notify.dirty(:post, post.id, false)
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post saved"))
socket
{:error, reason} ->
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|> build_data()
end
end
end
defp do_publish(socket) do
post = socket.assigns.post
case post do
nil ->
socket
%Post{} = post ->
metadata = socket.assigns.project_metadata
active_language = socket.assigns.active_language
draft = component_current_draft(socket, post, metadata, active_language)
case persist(post, draft, active_language, metadata, :publish) do
{:ok, record} ->
refreshed_post = Posts.get_post!(post.id)
_refreshed_form = persisted_form(refreshed_post, metadata, active_language)
socket =
socket
|> assign(:post, refreshed_post)
|> assign(:drafts, Map.delete(socket.assigns.drafts, active_language))
|> assign(:save_state, save_state_for_action(:publish))
|> assign(:dirty?, false)
|> build_data()
Notify.tab_meta(:post, post.id, record_title(record, refreshed_post),
Atom.to_string(record_status(record)))
Notify.dirty(:post, post.id, false)
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post published"))
socket
{:error, reason} ->
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|> build_data()
end
end
end
defp do_discard(socket) do
post = socket.assigns.post
case post do
nil ->
socket
%Post{} = post ->
metadata = socket.assigns.project_metadata
active_language = socket.assigns.active_language
case discard(post, active_language, metadata) do
{:ok, restored_post} ->
socket =
socket
|> assign(:post, restored_post)
|> assign(:drafts, Map.delete(socket.assigns.drafts, active_language))
|> assign(:save_state, :discarded)
|> assign(:dirty?, false)
|> build_data()
Notify.tab_meta(:post, post.id,
restored_post.title || restored_post.slug || restored_post.id,
Atom.to_string(restored_post.status || :draft))
Notify.dirty(:post, post.id, false)
socket
{:error, reason} ->
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|> build_data()
end
end
end
defp do_delete(socket) do
post_id = socket.assigns.post_id
case Posts.delete_post(post_id) do
{:ok, :deleted} ->
Notify.close_tab(:post, post_id)
socket
{:error, reason} ->
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|> build_data()
end
end
defp do_detect_language(socket) do
if Map.get(socket.assigns, :offline_mode, true) do
notify_output(
socket,
dgettext("ui", "Detect Language"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
"info"
)
|> build_data()
else
post = socket.assigns.post
case post do
nil ->
socket
%Post{} = post ->
metadata = socket.assigns.project_metadata
active_language = socket.assigns.active_language
draft = component_current_draft(socket, 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_component_draft_field(
"language",
normalize_language(language_code, socket.assigns.canonical_language)
)
|> build_data()
{:error, reason} ->
notify_output(socket, dgettext("ui", "Detect Language"), inspect(reason), "error")
|> build_data()
_other ->
notify_output(
socket,
dgettext("ui", "Detect Language"),
dgettext("ui", "Language detection failed."),
"error"
)
|> build_data()
end
end
end
end
defp do_translate(socket, language) do
if Map.get(socket.assigns, :offline_mode, true) do
notify_output(
socket,
dgettext("ui", "Translate"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
"info"
)
|> build_data()
else
post_id = socket.assigns.post_id
normalized_language = normalize_language(language, "")
source_language = socket.assigns.canonical_language
case AI.translate_post(post_id, normalized_language, source_language: source_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 =
socket
|> assign(:active_language, normalized_language)
|> assign(:drafts, Map.delete(socket.assigns.drafts, normalized_language))
|> assign(:quick_actions_open?, false)
|> build_data()
Notify.dirty(:post, post_id, false)
socket
else
{:error, reason} ->
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|> build_data()
end
{:error, reason} ->
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|> build_data()
end
end
end
defp do_apply_ai_suggestions(socket, fields) do
post_id = socket.assigns.post_id
case Posts.get_post(post_id) do
nil ->
socket
%Post{} = _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
assign(socket, :shell_overlay, nil)
else
case Posts.update_post(post_id, attrs) do
{:ok, updated_post} ->
metadata = project_metadata(updated_post.project_id)
active_language = socket.assigns.active_language
refreshed_form = persisted_form(updated_post, metadata, active_language)
socket =
socket
|> assign(:post, updated_post)
|> assign(:project_metadata, metadata)
|> assign(
:drafts,
Map.put(socket.assigns.drafts, active_language, refreshed_form)
)
|> assign(:save_state, :dirty)
|> assign(:dirty?, true)
|> assign(:shell_overlay, nil)
|> build_data()
Notify.dirty(:post, post_id, true)
socket
{:error, reason} ->
notify_output(socket, dgettext("ui", "AI Suggestions"), inspect(reason), "error")
|> build_data()
end
end
end
end
defp do_add_list_value(socket, kind, value) do
post = socket.assigns.post
case post do
nil ->
socket
%Post{} = post ->
metadata = socket.assigns.project_metadata
active_language = socket.assigns.active_language
draft = component_current_draft(socket, post, metadata, active_language)
normalized = normalize_list_entry(value)
if normalized == "" 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 =
socket
|> assign_query(kind, "")
|> put_component_draft_field(field_key(kind), updated)
|> build_data()
Notify.dirty(:post, socket.assigns.post_id, true)
assign(socket, :dirty?, true)
end
end
end
defp do_remove_list_value(socket, kind, value) do
post = socket.assigns.post
case post do
nil ->
socket
%Post{} = post ->
metadata = socket.assigns.project_metadata
active_language = socket.assigns.active_language
draft = component_current_draft(socket, post, metadata, active_language)
updated =
draft
|> Map.get(field_key(kind), "")
|> csv_to_list()
|> Enum.reject(&(&1 == value))
|> Enum.join(", ")
socket =
socket
|> put_component_draft_field(field_key(kind), updated)
|> build_data()
Notify.dirty(:post, socket.assigns.post_id, true)
assign(socket, :dirty?, true)
end
end
defp maybe_update_component_draft(socket, next_language, draft) do
current_language = socket.assigns.active_language
cond do
current_language == next_language ->
assign(socket, :drafts, Map.put(socket.assigns.drafts, next_language, draft))
Map.has_key?(socket.assigns.drafts, current_language) ->
socket
|> assign(:active_language, next_language)
|> assign(:drafts, Map.put(socket.assigns.drafts, next_language, draft))
true ->
socket
|> assign(:active_language, next_language)
|> assign(:drafts, Map.put(%{}, next_language, draft))
end
end
defp put_component_draft_field(socket, field, value) do
active_language = socket.assigns.active_language
post = socket.assigns.post
metadata = socket.assigns.project_metadata
draft = current_draft(socket.assigns, post, metadata, active_language)
updated = Map.put(draft, field, value)
assign(socket, :drafts, Map.put(socket.assigns.drafts, active_language, updated))
end
defp assign_query(socket, :tags, value), do: assign(socket, :tag_query, value)
defp assign_query(socket, :categories, value), do: assign(socket, :category_query, value)
defp notify_output(socket, title, message, level \\ "info") do
Notify.output(title, message, level)
socket
end
@spec post_status_label(term()) :: term()
def post_status_label(status), do: ShellData.dashboard_status_label(status)
@spec post_editor_save_state_label(term()) :: term()
def post_editor_save_state_label(:dirty), do: dgettext("ui", "Unsaved")
def post_editor_save_state_label(:saved), do: dgettext("ui", "Saved")
def post_editor_save_state_label(:published), do: dgettext("ui", "Published")
def post_editor_save_state_label(:discarded), do: dgettext("ui", "Reverted")
def post_editor_save_state_label(_state), do: dgettext("ui", "Idle")
@spec post_editor_mode_label(term()) :: term()
def post_editor_mode_label(:markdown), do: dgettext("ui", "Markdown")
def post_editor_mode_label(:preview), do: dgettext("ui", "Preview")
end