fix: more alignment with old app

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-26 17:36:45 +02:00
parent a52bf20271
commit 4548531f4e
7 changed files with 1632 additions and 385 deletions

View File

@@ -63,6 +63,9 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:sidebar_filter_panels, %{}) |> assign(:sidebar_filter_panels, %{})
|> assign(:post_editor_drafts, %{}) |> assign(:post_editor_drafts, %{})
|> assign(:post_editor_active_languages, %{}) |> 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_modes, %{})
|> assign(:post_editor_expanded, %{}) |> assign(:post_editor_expanded, %{})
|> assign(:post_editor_save_states, %{}) |> 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)} {:noreply, PostEditor.select_language(socket, post_id, language, &reload_shell/2)}
end 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 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 = overlay =
with overlay_kind when not is_nil(overlay_kind) <- ShellOverlayComponents.kind(kind), with overlay_kind when not is_nil(overlay_kind) <- ShellOverlayComponents.kind(kind),
%{type: route} <- socket.assigns[:current_tab] do %{type: route} <- socket.assigns[:current_tab] do
@@ -405,16 +441,17 @@ defmodule BDS.Desktop.ShellLive do
def handle_event("overlay_select_result", %{"id" => id}, socket) do def handle_event("overlay_select_result", %{"id" => id}, socket) do
overlay = socket.assigns[:shell_overlay] overlay = socket.assigns[:shell_overlay]
current_tab = socket.assigns[:current_tab]
socket = socket =
case overlay do case {overlay, current_tab} do
%{kind: :insert_link} -> {%{kind: :insert_link}, %{type: :post, id: post_id}} ->
case Overlay.insert_link_result(overlay, id) do case Overlay.insert_link_result(overlay, id) do
nil -> socket nil -> socket
result -> close_overlay_with_output(socket, overlay.title, 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 end
%{kind: :insert_media} -> {%{kind: :insert_media}, %{type: :post, id: post_id}} ->
case Overlay.insert_media_result(overlay, id) do case Overlay.insert_media_result(overlay, id) do
nil -> socket nil -> socket
result -> result ->
@@ -425,7 +462,7 @@ defmodule BDS.Desktop.ShellLive do
"[#{result.original_name}](bds-media://#{result.media_id})" "[#{result.original_name}](bds-media://#{result.media_id})"
end end
close_overlay_with_output(socket, overlay.title, syntax) PostEditor.insert_content(socket, post_id, syntax, &reload_shell/2)
end end
_other -> _other ->
@@ -436,9 +473,11 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event("overlay_insert_external", _params, socket) do def handle_event("overlay_insert_external", _params, socket) do
current_tab = socket.assigns[:current_tab]
socket = socket =
case socket.assigns[:shell_overlay] do case {socket.assigns[:shell_overlay], current_tab} do
%{kind: :insert_link} = overlay -> {%{kind: :insert_link} = overlay, %{type: :post, id: post_id}} ->
details = details =
case {overlay.external_url, String.trim(overlay.external_text || "")} do case {overlay.external_url, String.trim(overlay.external_text || "")} do
{"", _text} -> nil {"", _text} -> nil
@@ -447,7 +486,7 @@ defmodule BDS.Desktop.ShellLive do
end end
if details do if details do
close_overlay_with_output(socket, overlay.title, details) PostEditor.insert_content(socket, post_id, details, &reload_shell/2)
else else
socket socket
end end
@@ -460,9 +499,13 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event("overlay_select_language", %{"code" => code}, socket) do def handle_event("overlay_select_language", %{"code" => code}, socket) do
current_tab = socket.assigns[:current_tab]
socket = socket =
case socket.assigns[:shell_overlay] do case {socket.assigns[:shell_overlay], current_tab} do
%{kind: :language_picker, title: title} -> close_overlay_with_output(socket, title, code) {%{kind: :language_picker}, %{type: :post, id: post_id}} ->
PostEditor.translate(socket, post_id, code, &reload_shell/2, &append_output_entry/5)
_other -> socket _other -> socket
end end
@@ -470,17 +513,23 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event("overlay_confirm", _params, socket) do def handle_event("overlay_confirm", _params, socket) do
socket = current_tab = socket.assigns[:current_tab]
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)
%{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) 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) close_overlay_with_output(socket, title, message)
_other -> _other ->

View File

@@ -135,7 +135,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
defp existing_translations(_tab), do: %{} defp existing_translations(_tab), do: %{}
defp blog_languages(metadata) 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.reject(&is_nil/1)
|> Enum.uniq() |> Enum.uniq()
end end

View File

@@ -4,10 +4,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
use Phoenix.Component use Phoenix.Component
import Ecto.Query import Ecto.Query
import Phoenix.HTML
alias BDS.Desktop.ShellData 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.Media.Media
alias BDS.Posts.{Post, Translation} alias BDS.Posts.{Post, Translation}
alias BDS.UI.Workbench alias BDS.UI.Workbench
@@ -40,16 +39,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
draft = normalize_params(params, current_language, next_language) 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 socket
|> assign(:workbench, workbench) |> put_query_state(post_id, :tags, Map.get(params, "tag_query", ""))
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)) |> put_query_state(post_id, :categories, Map.get(params, "category_query", ""))
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)) |> maybe_update_draft(post_id, post, current_language, next_language, draft, dirty?)
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) |> reload_with_assigned_workbench(reload)
|> 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)
end end
_other -> _other ->
@@ -126,6 +123,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) |> 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_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_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_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_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)) |> 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) |> reload.(workbench)
end 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 def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do
case Repo.get(Post, post_id) do case Repo.get(Post, post_id) do
nil -> nil ->
@@ -190,20 +370,40 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%{ %{
id: post.id, id: post.id,
display_title: display_title(form["title"], post.slug, 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, 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), dirty?: Workbench.dirty?(assigns.workbench, :post, post.id),
save_state: Map.get(assigns.post_editor_save_states, post.id, :idle), 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), metadata_expanded: Map.get(expanded, :metadata, false),
excerpt_expanded: Map.get(expanded, :excerpt, false), excerpt_expanded: Map.get(expanded, :excerpt, false),
mode: Map.get(assigns.post_editor_modes, post.id, :markdown), mode: Map.get(assigns.post_editor_modes, post.id, :markdown),
editing_canonical?: editing_canonical_language?(translations, active_language, canonical_language), 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), languages: languages(metadata),
form: form, form: form,
template_options: template_options(post.project_id), 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_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), translation_flags: translation_flags(post, canonical_language, active_language, translations),
linked_media: linked_media(post.id), linked_media: linked_media(post.id),
post_links: post_links(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 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(nil), do: %{main_language: "en", blog_languages: []}
def project_metadata(project_id) do def project_metadata(project_id) do
@@ -322,32 +524,161 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
_error -> %{main_language: "en", blog_languages: []} _error -> %{main_language: "en", blog_languages: []}
end end
defp editor_toolbar(assigns) do def tag_chip_style(nil), do: nil
~H"""
<%= if Enum.any?(@toolbar_buttons) do %> def tag_chip_style(color) do
<div class="editor-toolbar"> normalized = normalize_color(color)
<%= for button <- @toolbar_buttons do %>
<button if normalized do
class={["editor-toolbar-button", if(button.destructive, do: "is-destructive")]} "background-color: #{normalized}; color: #{contrast_color(normalized)}; border-color: #{normalized};"
data-testid="editor-toolbar-overlay-button" end
type="button"
phx-click="open_overlay"
phx-value-kind={button.kind}
>
<%= translated(button.label) %>
</button>
<% end %>
</div>
<% end %>
"""
end end
defp assigned_project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{}) defp assigned_project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{})
defp current_status(post_status, active_language, canonical_language, current_translation) do defp maybe_update_draft(socket, post_id, post, current_language, next_language, draft, true) do
if active_language == canonical_language, do: post_status, else: translation_status(current_translation) 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 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 defp persisted_form(post, metadata, active_language, translations) do
canonical_language = canonical_language(post, metadata) canonical_language = canonical_language(post, metadata)
translation = Map.get(translations, active_language) translation = Map.get(translations, active_language)
@@ -417,10 +748,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|> Enum.uniq() |> Enum.uniq()
end 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 defp template_options(project_id) do
Repo.all( Repo.all(
from template in Templates.Template, 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") blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled")
end end
defp active_language_subtitle(active_language, canonical_language) do defp has_published_version?(%Post{} = post), do: not is_nil(post.published_at) or post.file_path not in [nil, ""]
if active_language == canonical_language do
translated("Canonical draft") 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 else
translated("Translation: %{language}", %{language: String.upcase(active_language)}) _other -> nil
end end
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 defp save_canonical_draft(%Post{id: post_id}, draft) do
Posts.update_post(post_id, %{ Posts.update_post(post_id, %{
title: blank_to_nil(Map.get(draft, "title")), title: blank_to_nil(Map.get(draft, "title")),

View File

@@ -1,223 +1,397 @@
<div class="post-editor" data-testid="post-editor"> <div class="post-editor editor" data-testid="post-editor">
<div class="post-editor-header"> <div class="editor-header">
<div class="post-editor-heading"> <div class="editor-tabs">
<div class="editor-kicker"><%= translated("Post") %></div> <div class={["editor-tab", "active", if(@post_editor.dirty?, do: "dirty")]}>
<div class="post-editor-title-row"> <span class="editor-tab-title" data-testid="editor-title"><%= @post_editor.display_title %></span>
<h1 class="editor-title" data-testid="editor-title"><%= @post_editor.display_title %></h1>
<%= if @post_editor.dirty? do %> <%= if @post_editor.dirty? do %>
<span class="post-editor-dirty-dot">●</span> <span class="editor-tab-dirty" title={translated("Unsaved")}>●</span>
<% end %> <% end %>
</div> </div>
<p class="editor-subtitle"><%= @post_editor.subtitle %></p>
</div> </div>
<div class="post-editor-actions"> <div class="editor-actions">
<span class={["post-status-badge", "status-#{@post_editor.status}"]} data-testid="post-status-badge"> <span class={["status-badge", "status-#{@post_editor.status}"]} data-testid="post-status-badge">
<%= post_status_label(@post_editor.status) %> <%= post_status_label(@post_editor.status) %>
</span> </span>
<span class="post-save-state"><%= post_editor_save_state_label(@post_editor.save_state) %></span> <%= if @post_editor.save_state in [:saving] do %>
<button class="editor-toolbar-button" type="button" phx-click="save_post_editor" phx-value-id={@post_editor.id}> <span class="auto-save-indicator"><%= post_editor_save_state_label(@post_editor.save_state) %></span>
<%= translated("Save") %> <% end %>
</button>
<button class="editor-toolbar-button" data-testid="post-publish-button" type="button" phx-click="publish_post_editor" phx-value-id={@post_editor.id}>
<%= translated("Publish") %>
</button>
<button class="editor-toolbar-button" data-testid="post-discard-button" type="button" phx-click="discard_post_editor" phx-value-id={@post_editor.id}>
<%= translated("Discard") %>
</button>
<button class="editor-toolbar-button is-destructive" data-testid="post-delete-button" type="button" phx-click="delete_post_editor" phx-value-id={@post_editor.id}>
<%= translated("Delete") %>
</button>
</div>
</div>
<div class="post-editor-flags-bar"> <div class="quick-actions-wrapper">
<button class="post-editor-section-toggle" type="button" phx-click="toggle_post_metadata" phx-value-id={@post_editor.id}>
<span><%= if @post_editor.metadata_expanded, do: "▼", else: "▶" %></span>
<span><%= translated("Metadata") %></span>
</button>
<div class="post-editor-flags">
<%= for flag <- @post_editor.translation_flags do %>
<button <button
class={[ class="secondary quick-actions-btn"
"translation-flag-button",
if(flag.active, do: "is-active"),
"status-#{flag.status}"
]}
type="button" type="button"
phx-click="select_post_editor_language" phx-click="toggle_post_editor_quick_actions"
phx-value-id={@post_editor.id} phx-value-id={@post_editor.id}
phx-value-language={flag.language}
title={flag.label}
> >
<span><%= flag.flag %></span> <%= translated("Quick Actions") %>
<span><%= String.upcase(flag.language) %></span> </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> </button>
<% end %> <% end %>
</div> </div>
</div> </div>
<%= editor_toolbar(assigns) %> <form class="post-editor-form editor-content" data-testid="post-editor-form" phx-change="change_post_editor">
<div class="metadata-toggle-header">
<form class="post-editor-form" data-testid="post-editor-form" phx-change="change_post_editor"> <button class={["metadata-toggle", if(@post_editor.metadata_expanded, do: "expanded")]} type="button" phx-click="toggle_post_metadata" phx-value-id={@post_editor.id}>
<div class={["post-editor-metadata-grid", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}> <span class="metadata-toggle-chevron"><%= if @post_editor.metadata_expanded, do: "▼", else: "▶" %></span>
<div class="post-editor-column"> <span><%= translated("Metadata") %></span>
<label class="post-editor-field">
<span><%= translated("Title") %></span>
<input class="post-editor-input" type="text" name="post_editor[title]" value={@post_editor.form["title"]} />
</label>
<label class="post-editor-field">
<span><%= translated("Tags") %></span>
<input class="post-editor-input" type="text" name="post_editor[tags]" value={@post_editor.form["tags"]} list={"post-editor-tags-#{@post_editor.id}"} />
<datalist id={"post-editor-tags-#{@post_editor.id}"}>
<%= for tag_name <- @post_editor.tag_options do %>
<option value={tag_name}></option>
<% end %>
</datalist>
</label>
<label class="post-editor-field">
<span><%= translated("Author") %></span>
<input class="post-editor-input" type="text" name="post_editor[author]" value={@post_editor.form["author"]} />
</label>
<label class="post-editor-field">
<span><%= translated("Language") %></span>
<select class="post-editor-input" name="post_editor[language]" disabled={not @post_editor.editing_canonical?}>
<%= for language <- @post_editor.languages do %>
<option value={language} selected={language == @post_editor.form["language"]}><%= String.upcase(language) %></option>
<% end %>
</select>
</label>
<label class="post-editor-field post-editor-checkbox-field">
<input type="hidden" name="post_editor[do_not_translate]" value="false" />
<input type="checkbox" name="post_editor[do_not_translate]" value="true" checked={@post_editor.form["do_not_translate"]} disabled={not @post_editor.editing_canonical?} />
<span><%= translated("Do Not Translate") %></span>
</label>
<label class="post-editor-field">
<span><%= translated("Slug") %></span>
<input class="post-editor-input is-readonly" type="text" readonly value={@post_editor.slug} />
</label>
<label class="post-editor-field">
<span><%= translated("Categories") %></span>
<input class="post-editor-input" type="text" name="post_editor[categories]" value={@post_editor.form["categories"]} list={"post-editor-categories-#{@post_editor.id}"} disabled={not @post_editor.editing_canonical?} />
<datalist id={"post-editor-categories-#{@post_editor.id}"}>
<%= for category <- @post_editor.category_options do %>
<option value={category}></option>
<% end %>
</datalist>
</label>
<label class="post-editor-field">
<span><%= translated("Template") %></span>
<select class="post-editor-input" name="post_editor[template_slug]" disabled={not @post_editor.editing_canonical?}>
<option value=""><%= translated("Default") %></option>
<%= for template <- @post_editor.template_options do %>
<option value={template.slug} selected={template.slug == @post_editor.form["template_slug"]}><%= template.title %></option>
<% end %>
</select>
</label>
<div class="post-editor-links-panel">
<strong><%= translated("Post Links") %></strong>
<div class="post-editor-links-columns">
<div>
<span class="post-editor-links-label"><%= translated("Backlinks") %></span>
<%= if Enum.any?(@post_editor.post_links.backlinks) do %>
<ul class="editor-list compact">
<%= for item <- @post_editor.post_links.backlinks do %>
<li><%= item.title %></li>
<% end %>
</ul>
<% else %>
<span class="post-editor-empty"><%= translated("No items") %></span>
<% end %>
</div>
<div>
<span class="post-editor-links-label"><%= translated("Links To") %></span>
<%= if Enum.any?(@post_editor.post_links.outlinks) do %>
<ul class="editor-list compact">
<%= for item <- @post_editor.post_links.outlinks do %>
<li><%= item.title %></li>
<% end %>
</ul>
<% else %>
<span class="post-editor-empty"><%= translated("No items") %></span>
<% end %>
</div>
</div>
</div>
</div>
<div class="post-editor-column post-editor-side-panel">
<div class="post-editor-side-panel-header">
<strong><%= translated("Linked Media") %></strong>
<div class="post-editor-side-actions">
<button class="editor-toolbar-button" type="button" phx-click="open_overlay" phx-value-kind="insert_media"><%= translated("Insert Media") %></button>
<button class="editor-toolbar-button" type="button" phx-click="open_overlay" phx-value-kind="gallery"><%= translated("Gallery") %></button>
</div>
</div>
<%= if Enum.any?(@post_editor.linked_media) do %>
<ul class="post-editor-media-list">
<%= for item <- @post_editor.linked_media do %>
<li class="post-editor-media-item">
<span class="post-editor-media-title"><%= item.name %></span>
<span class="post-editor-media-meta"><%= translated("Order") %>: <%= item.sort_order %></span>
</li>
<% end %>
</ul>
<% else %>
<div class="post-editor-empty"><%= translated("No linked media") %></div>
<% end %>
</div>
</div>
<div class="post-editor-excerpt-header">
<button class="post-editor-section-toggle" type="button" phx-click="toggle_post_excerpt" phx-value-id={@post_editor.id}>
<span><%= if @post_editor.excerpt_expanded, do: "▼", else: "▶" %></span>
<span><%= translated("Excerpt") %></span>
</button> </button>
</div>
<%= if @post_editor.excerpt_expanded do %> <div class="editor-translations-flags" aria-label={translated("Translations")}>
<label class="post-editor-field"> <%= for flag <- @post_editor.translation_flags do %>
<textarea class="post-editor-textarea post-editor-excerpt" name="post_editor[excerpt]" rows="4"><%= @post_editor.form["excerpt"] %></textarea>
</label>
<% end %>
<div class="post-editor-body-header">
<span class="post-editor-body-label"><%= translated("Content") %></span>
<div class="post-editor-mode-toggle">
<%= for mode <- [:visual, :markdown, :preview] do %>
<button <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" type="button"
phx-click="set_post_editor_mode" phx-click="select_post_editor_language"
phx-value-id={@post_editor.id} 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> </button>
<% end %> <% end %>
</div> </div>
</div> </div>
<%= if @post_editor.mode == :preview do %> <div class={["editor-header-row", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}>
<div class="post-editor-preview" data-testid="post-editor-preview"><%= raw(Earmark.as_html!(@post_editor.form["content"] || "")) %></div> <div class="editor-meta">
<% else %> <div class="editor-field">
<label class="post-editor-field post-editor-content-field"> <label><%= translated("Title") %></label>
<textarea class="post-editor-textarea post-editor-content" data-testid="post-editor-content" name="post_editor[content]" rows="18"><%= @post_editor.form["content"] %></textarea> <input class="post-editor-input" type="text" name="post_editor[title]" value={@post_editor.form["title"]} />
</label> </div>
<% end %>
<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> </form>
<div class="post-editor-footer"> <div class="editor-footer">
<span><strong><%= translated("Created") %>:</strong> <%= @post_editor.footer.created_at %></span> <span><strong><%= translated("Created") %>:</strong> <%= @post_editor.footer.created_at %></span>
<span><strong><%= translated("Updated") %>:</strong> <%= @post_editor.footer.updated_at %></span> <span><strong><%= translated("Updated") %>:</strong> <%= @post_editor.footer.updated_at %></span>
<%= if @post_editor.footer.published_at do %> <%= if @post_editor.footer.published_at do %>

View File

@@ -71,7 +71,71 @@ body > [data-phx-main] {
} }
button { button {
font: inherit; font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border: none;
padding: 6px 14px;
cursor: pointer;
border-radius: 2px;
}
button:hover {
background-color: var(--vscode-button-hoverBackground);
}
button:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
button.secondary {
background-color: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background-color: #4a4d51;
}
button.compact {
padding: 4px 8px;
font-size: 12px;
}
button.primary {
background-color: var(--vscode-button-background);
font-weight: 500;
}
button.primary:hover {
background-color: var(--vscode-button-hoverBackground);
}
button.success {
background-color: #28a745;
}
button.success:hover {
background-color: #218838;
}
button.danger {
background-color: #dc3545;
}
button.danger:hover {
background-color: #c82333;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button svg,
button svg * {
pointer-events: none;
} }
.app { .app {
@@ -835,185 +899,559 @@ button {
border-bottom: 1px solid var(--vscode-panel-border); border-bottom: 1px solid var(--vscode-panel-border);
} }
.post-editor { .post-editor.editor {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; background-color: var(--vscode-editor-background);
padding: 14px 16px 18px; overflow: hidden;
} }
.post-editor-header, .post-editor .editor-header {
.post-editor-title-row,
.post-editor-actions,
.post-editor-flags-bar,
.post-editor-links-columns,
.post-editor-side-panel-header,
.post-editor-side-actions,
.post-editor-excerpt-header,
.post-editor-body-header,
.post-editor-mode-toggle,
.post-editor-footer {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px;
}
.post-editor-header,
.post-editor-flags-bar,
.post-editor-body-header,
.post-editor-footer,
.post-editor-side-panel-header {
justify-content: space-between; justify-content: space-between;
gap: 12px;
padding: 0 12px;
min-height: 35px;
background-color: var(--vscode-tab-activeBackground);
border-bottom: 1px solid var(--vscode-panel-border);
} }
.post-editor-heading, .post-editor .editor-tabs {
.post-editor-column, display: flex;
.post-editor-links-panel, align-items: center;
.post-editor-side-panel { gap: 2px;
min-width: 0; min-width: 0;
} }
.post-editor-title-row { .post-editor .editor-tab {
gap: 8px;
}
.post-editor-dirty-dot {
color: var(--vscode-editorWarning-foreground, #e2c08d);
font-size: 12px;
}
.post-status-badge,
.translation-flag-button,
.post-editor-mode-button,
.post-editor-section-toggle {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: transparent;
color: inherit;
}
.post-status-badge {
padding: 4px 8px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.post-save-state {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.post-editor-flags-bar {
flex-wrap: wrap;
}
.post-editor-flags,
.post-editor-side-actions,
.post-editor-mode-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.translation-flag-button,
.post-editor-mode-button,
.post-editor-section-toggle {
display: inline-flex;
align-items: center;
gap: 6px; gap: 6px;
padding: 4px 8px; min-width: 0;
padding: 6px 12px;
background-color: var(--vscode-tab-inactiveBackground);
color: var(--vscode-tab-inactiveForeground);
font-size: 13px;
border-radius: 4px 4px 0 0;
}
.post-editor .editor-tab.active {
background-color: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
.post-editor .editor-tab-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-editor .editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
font-size: 10px;
}
.post-editor .editor-tab-meta {
color: var(--vscode-descriptionForeground);
font-size: 11px;
white-space: nowrap;
}
.post-editor .editor-actions {
display: flex;
align-items: center;
gap: 8px;
}
.post-editor .quick-actions-wrapper {
position: relative;
display: inline-block;
}
.post-editor .quick-actions-btn {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.post-editor .quick-actions-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 280px;
background: var(--vscode-dropdown-background, #3c3c3c);
border: 1px solid var(--vscode-dropdown-border, #454545);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.post-editor .quick-actions-divider {
height: 1px;
background: var(--vscode-dropdown-border, #454545);
}
.post-editor .quick-action-item {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px 12px;
background: none;
border: none;
color: var(--vscode-dropdown-foreground, #ccc);
cursor: pointer; cursor: pointer;
text-align: left;
transition: background 0.1s;
} }
.translation-flag-button.is-active, .post-editor .quick-action-item:hover:not(:disabled) {
.post-editor-mode-button.is-active, background: var(--vscode-list-hoverBackground, #2a2d2e);
.post-editor-section-toggle:hover {
background: var(--vscode-toolbar-hoverBackground);
} }
.post-editor-form { .post-editor .quick-action-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.post-editor .quick-action-icon {
font-size: 16px;
flex-shrink: 0;
margin-top: 2px;
}
.post-editor .quick-action-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 2px;
} }
.post-editor-metadata-grid { .post-editor .quick-action-text strong {
display: grid; font-size: 13px;
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.85fr); font-weight: 500;
}
.post-editor .quick-action-text small {
font-size: 11px;
opacity: 0.7;
}
.post-editor .status-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.post-editor .status-badge.status-draft {
background-color: rgba(204, 167, 0, 0.2);
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
}
.post-editor .status-badge.status-published {
background-color: rgba(115, 201, 145, 0.2);
color: var(--vscode-testing-iconPassed);
}
.post-editor .status-badge.status-archived {
background-color: rgba(133, 133, 133, 0.2);
color: var(--vscode-descriptionForeground);
}
.post-editor .auto-save-indicator {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
.post-editor .editor-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px; gap: 16px;
padding: 16px;
overflow-y: auto;
} }
.post-editor-metadata-grid.is-collapsed { .post-editor .metadata-toggle-header {
display: flex;
align-items: center;
gap: 8px;
}
.post-editor .metadata-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
background: none;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s;
flex-shrink: 0;
}
.post-editor .metadata-toggle:hover {
color: var(--vscode-foreground);
}
.post-editor .metadata-toggle-chevron {
font-size: 10px;
}
.post-editor .editor-header-row {
display: flex;
gap: 16px;
align-items: flex-start;
}
.post-editor .editor-header-row.is-collapsed {
display: none; display: none;
} }
.post-editor-column { .post-editor .editor-meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 8px;
flex: 1;
min-width: 0;
} }
.post-editor-field { .post-editor .editor-media-panel {
width: 200px;
flex-shrink: 0;
}
.post-editor .editor-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
flex: 1;
min-width: 200px;
} }
.post-editor-input, .post-editor .editor-field label,
.post-editor-textarea { .post-editor .editor-body label,
.post-editor .post-editor-links-label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.post-editor .editor-checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
text-transform: none;
letter-spacing: 0;
color: var(--vscode-foreground);
}
.post-editor .post-editor-input,
.post-editor .post-editor-textarea {
width: 100%; width: 100%;
padding: 8px 10px;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
border-radius: 4px; border-radius: 4px;
background: var(--vscode-input-background, rgba(255, 255, 255, 0.03)); background: var(--vscode-input-background, rgba(255, 255, 255, 0.06));
color: var(--vscode-input-foreground, var(--vscode-foreground)); color: var(--vscode-input-foreground, var(--vscode-foreground));
padding: 8px 10px;
font: inherit; font: inherit;
} }
.post-editor-input.is-readonly { .post-editor .post-editor-input.is-readonly {
color: var(--vscode-descriptionForeground); opacity: 0.7;
cursor: not-allowed;
} }
.post-editor-textarea { .post-editor .post-editor-textarea {
resize: vertical;
line-height: 1.5; line-height: 1.5;
resize: vertical;
} }
.post-editor-checkbox-field { .post-editor .post-editor-excerpt {
flex-direction: row; min-height: 96px;
}
.post-editor .tag-input-container {
position: relative;
width: 100%;
}
.post-editor .tag-input-container.is-disabled {
opacity: 0.72;
}
.post-editor .tag-input-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 6px;
padding: 6px 8px;
min-height: 38px;
border: 1px solid var(--vscode-input-border, #3c3c3c);
border-radius: 4px;
background: var(--vscode-input-background, #3c3c3c);
cursor: text;
} }
.post-editor-links-panel, .post-editor .tag-input-wrapper:focus-within {
.post-editor-side-panel { border-color: var(--vscode-focusBorder, #007fd4);
border: 1px solid var(--vscode-panel-border); outline: none;
}
.post-editor .tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 0.85rem;
background: var(--vscode-badge-background, #4d4d4d);
border: 1px solid var(--vscode-widget-border, #454545);
border-radius: 4px;
color: var(--vscode-badge-foreground, #ffffff);
white-space: nowrap;
}
.post-editor .tag-chip.has-color {
border-radius: 12px;
padding: 3px 10px;
}
.post-editor .tag-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
margin-left: 2px;
border: none;
background: transparent;
color: inherit;
font-size: 1rem;
line-height: 1;
cursor: pointer;
opacity: 0.6;
border-radius: 50%;
transition: opacity 0.15s, background 0.15s;
}
.post-editor .tag-chip-remove:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.1);
}
.post-editor .tag-chip.has-color .tag-chip-remove:hover {
background: rgba(0, 0, 0, 0.2);
}
.post-editor .tag-input-field {
flex: 1;
min-width: 120px;
padding: 2px 4px;
border: none;
background: transparent;
color: var(--vscode-input-foreground, #cccccc);
font-family: inherit;
font-size: 0.9rem;
outline: none;
}
.post-editor .tag-input-field::placeholder {
color: var(--vscode-input-placeholderForeground, #a6a6a6);
}
.post-editor .tag-input-field:disabled {
cursor: not-allowed;
}
.post-editor .tag-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
padding: 4px;
background: var(--vscode-dropdown-background, #3c3c3c);
border: 1px solid var(--vscode-widget-border, #454545);
border-radius: 6px; border-radius: 6px;
padding: 12px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.2);
z-index: 1000;
max-height: 240px;
overflow-y: auto;
} }
.post-editor-links-columns { .post-editor .tag-suggestion {
align-items: flex-start; display: flex;
justify-content: flex-start; align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--vscode-dropdown-foreground, #f0f0f0);
font-family: inherit;
font-size: 0.9rem;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: background 0.1s;
}
.post-editor .tag-suggestion:hover,
.post-editor .tag-suggestion.selected {
background: var(--vscode-list-hoverBackground, #2a2d2e);
}
.post-editor .tag-suggestion-color {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.post-editor .tag-suggestion-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-editor .tag-suggestion.create-new {
border-top: 1px solid var(--vscode-widget-border, #454545);
margin-top: 4px;
padding: 6px 8px;
padding-top: 12px;
color: var(--vscode-notificationsInfoIcon-foreground, #75beff);
}
.post-editor .tag-suggestion.create-new:first-child {
border-top: none;
margin-top: 0;
padding-top: 8px;
}
.post-editor .tag-suggestion-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px dashed currentColor;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 600;
}
.post-editor .editor-field-row {
display: flex;
gap: 12px;
width: 100%;
}
.post-editor .editor-language-row {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: nowrap;
}
.post-editor .editor-language-row select {
flex: 1;
min-width: 0;
}
.post-editor .editor-translations-flags {
display: flex;
gap: 4px;
align-items: center;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.post-editor .editor-translation-flag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
}
.post-editor .editor-translation-flag.status-draft {
opacity: 0.82;
}
.post-editor .editor-translation-flag.status-archived {
opacity: 0.45;
filter: grayscale(0.35);
}
.post-editor .editor-translation-flag.active {
border-color: var(--vscode-testing-iconQueued, #cca700);
background: color-mix(in srgb, var(--vscode-testing-iconQueued, #cca700) 14%, transparent);
}
.post-editor .editor-translation-flag:hover {
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 75%, transparent);
}
.post-editor .post-editor-links-panel,
.post-editor .post-editor-side-panel {
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 82%, white 3%);
}
.post-editor .post-editor-side-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.post-editor .post-editor-links-columns {
display: flex;
gap: 18px; gap: 18px;
align-items: flex-start;
margin-top: 10px; margin-top: 10px;
} }
.post-editor-links-columns > div, .post-editor .post-editor-links-columns > div {
.post-editor-side-panel {
flex: 1; flex: 1;
min-width: 0;
} }
.post-editor-links-label, .post-editor .post-editor-empty,
.post-editor-body-label, .post-editor .post-editor-media-meta {
.post-editor-media-meta,
.post-editor-empty {
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
font-size: 12px; font-size: 12px;
} }
.post-editor-media-list { .post-editor .post-editor-media-list {
list-style: none; list-style: none;
margin: 10px 0 0; margin: 10px 0 0;
padding: 0; padding: 0;
@@ -1022,48 +1460,194 @@ button {
gap: 8px; gap: 8px;
} }
.post-editor-media-item { .post-editor .post-editor-media-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
padding: 8px 10px; padding: 8px 10px;
border-radius: 4px; border-radius: 6px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.post-editor-content-field { .post-editor .editor-body {
margin: 0; flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 320px;
} }
.post-editor-content { .post-editor .editor-toolbar {
min-height: 360px; display: grid;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
margin-bottom: 8px;
} }
.post-editor-preview { .post-editor .editor-toolbar-left {
display: flex;
align-items: center;
justify-content: flex-start;
}
.post-editor .editor-toolbar-center {
display: flex;
align-items: center;
justify-content: center;
}
.post-editor .editor-toolbar-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.post-editor .editor-mode-toggle {
display: flex;
gap: 4px;
}
.post-editor .editor-mode-toggle button,
.post-editor .editor-toolbar-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .editor-mode-toggle button {
background-color: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
.post-editor .editor-mode-toggle button:hover,
.post-editor .editor-toolbar-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-toolbar-hoverBackground));
}
.post-editor .editor-mode-toggle button.active {
background-color: var(--vscode-button-background, var(--accent-color));
color: var(--vscode-button-foreground, #ffffff);
}
.post-editor .editor-toolbar-button {
background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
.post-editor .editor-excerpt-panel.is-collapsed {
display: none;
}
.post-editor .gallery-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .gallery-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .insert-post-link-button,
.post-editor .insert-media-button {
padding: 4px 8px;
font-size: 14px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .insert-post-link-button:hover,
.post-editor .insert-media-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .editor-preview {
flex: 1;
background-color: var(--vscode-input-background);
border-radius: 4px;
overflow: hidden;
position: relative;
min-height: 240px;
padding: 0;
border: none;
}
.post-editor .editor-preview {
flex: 1;
min-height: 240px; min-height: 240px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 14px; padding: 14px;
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
overflow: auto;
line-height: 1.6; line-height: 1.6;
} }
.post-editor-footer { .post-editor .editor-preview-frame {
flex-wrap: wrap; width: 100%;
min-height: 520px;
border: none;
background: #ffffff;
}
.post-editor .post-editor-content {
flex: 1;
min-height: 380px;
resize: none;
font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: var(--vscode-editor-font-size, 13px);
}
.post-editor .editor-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
border-top: 1px solid var(--vscode-panel-border);
background-color: var(--vscode-sideBar-background);
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
font-size: 12px; font-size: 12px;
flex-wrap: wrap;
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.post-editor-header, .post-editor .editor-header,
.post-editor-flags-bar, .post-editor .metadata-toggle-header,
.post-editor-body-header { .post-editor .editor-toolbar {
display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.post-editor-metadata-grid { .post-editor .editor-header-row,
grid-template-columns: 1fr; .post-editor .editor-field-row,
.post-editor .post-editor-links-columns {
flex-direction: column;
}
.post-editor .editor-media-panel {
width: 100%;
}
.post-editor .editor-toolbar-right,
.post-editor .editor-actions {
justify-content: flex-start;
} }
} }

View File

@@ -371,6 +371,35 @@ document.addEventListener("DOMContentLoaded", () => {
destroyed() { destroyed() {
this.el.removeEventListener("dblclick", this.handleDblClick); this.el.removeEventListener("dblclick", this.handleDblClick);
} }
},
PostEditorContent: {
mounted() {
this.handleInsert = ({ id, content }) => {
if (!content || String(id) !== String(this.el.dataset.postEditorId)) {
return;
}
const start = this.el.selectionStart ?? this.el.value.length;
const end = this.el.selectionEnd ?? start;
const before = this.el.value.slice(0, start);
const after = this.el.value.slice(end);
const separator = before !== "" && !before.endsWith("\n") ? "\n" : "";
const suffix = after !== "" && !content.endsWith("\n") ? "\n" : "";
const inserted = `${separator}${content}${suffix}`;
const nextValue = `${before}${inserted}${after}`;
this.el.focus();
this.el.value = nextValue;
const caret = before.length + inserted.length;
this.el.setSelectionRange(caret, caret);
this.el.dispatchEvent(new Event("input", { bubbles: true }));
this.el.dispatchEvent(new Event("change", { bubbles: true }));
};
this.handleEvent("post-editor-insert-content", this.handleInsert);
}
} }
}; };

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLiveTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias BDS.Persistence alias BDS.Persistence
alias BDS.Metadata
alias BDS.Posts alias BDS.Posts
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Projects alias BDS.Projects
@@ -561,12 +562,19 @@ defmodule BDS.Desktop.ShellLiveTest do
end end
test "post tabs render a real editor and drive save publish discard flows", %{project: project} do test "post tabs render a real editor and drive save publish discard flows", %{project: project} do
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "alpha", color: "#112233"})
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "beta", color: "#445566"})
assert {:ok, _metadata} = Metadata.add_category(project.id, "notes")
assert {:ok, _metadata} = Metadata.add_category(project.id, "guides")
{:ok, post} = {:ok, post} =
Posts.create_post(%{ Posts.create_post(%{
project_id: project.id, project_id: project.id,
title: "Draft Shell Post", title: "Draft Shell Post",
content: "Initial body", content: "Initial body",
excerpt: "Initial excerpt" excerpt: "Initial excerpt",
tags: ["alpha", "beta"],
categories: ["notes", "guides"]
}) })
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
@@ -586,8 +594,45 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(name="post_editor[excerpt]") assert html =~ ~s(name="post_editor[excerpt]")
assert html =~ ~s(data-testid="post-publish-button") assert html =~ ~s(data-testid="post-publish-button")
assert html =~ ~s(data-testid="post-discard-button") assert html =~ ~s(data-testid="post-discard-button")
assert html =~ ~s(data-testid="post-detect-language-button")
assert html =~ "quick-actions-wrapper"
assert html =~ "quick-actions-btn"
assert html =~ "editor-header"
assert html =~ "editor-content"
assert html =~ "metadata-toggle-header"
assert html =~ "editor-translations-flags"
assert html =~ "editor-header-row"
assert html =~ "editor-media-panel"
assert html =~ "editor-body"
assert html =~ "editor-toolbar"
assert html =~ "editor-footer"
assert html =~ "tag-input-container"
assert html =~ "tag-chip"
assert html =~ "alpha"
assert html =~ "beta"
assert html =~ "notes"
assert html =~ "guides"
refute html =~ ~s(phx-click="save_post_editor")
refute html =~ ~s(data-testid="post-delete-button")
refute html =~ "gallery-button"
refute html =~ "Desktop workbench content routed through the Elixir shell." refute html =~ "Desktop workbench content routed through the Elixir shell."
html = render_click(view, "toggle_post_editor_quick_actions", %{"id" => post.id})
assert html =~ "quick-actions-menu"
assert html =~ "quick-action-item"
assert html =~ "quick-actions-divider"
html = render_click(view, "set_post_editor_mode", %{"id" => post.id, "mode" => "preview"})
assert html =~ ~s(data-testid="post-editor-preview")
assert html =~ "editor-preview-frame"
refute html =~ ~s(data-testid="post-editor-content")
html = render_click(view, "set_post_editor_mode", %{"id" => post.id, "mode" => "markdown"})
assert html =~ ~s(data-testid="post-editor-content")
html = html =
view view
|> form("[data-testid='post-editor-form']", %{ |> form("[data-testid='post-editor-form']", %{
@@ -599,8 +644,7 @@ defmodule BDS.Desktop.ShellLiveTest do
categories: "notes, guides", categories: "notes, guides",
author: "Ada Lovelace", author: "Ada Lovelace",
language: "de", language: "de",
do_not_translate: "false", do_not_translate: "false"
template_slug: ""
} }
}) })
|> render_change() |> render_change()
@@ -622,6 +666,9 @@ defmodule BDS.Desktop.ShellLiveTest do
html = render_click(view, "publish_post_editor", %{"id" => post.id}) html = render_click(view, "publish_post_editor", %{"id" => post.id})
assert html =~ ~s(data-testid="post-status-badge") assert html =~ ~s(data-testid="post-status-badge")
assert html =~ ~s(data-testid="post-delete-button")
refute html =~ ~s(data-testid="post-publish-button")
refute html =~ ~s(data-testid="post-discard-button")
assert Posts.get_post!(post.id).status == :published assert Posts.get_post!(post.id).status == :published
_html = _html =
@@ -635,8 +682,7 @@ defmodule BDS.Desktop.ShellLiveTest do
categories: "notes, guides", categories: "notes, guides",
author: "Ada Lovelace", author: "Ada Lovelace",
language: "de", language: "de",
do_not_translate: "false", do_not_translate: "false"
template_slug: ""
} }
}) })
|> render_change() |> render_change()