fix: more alignment with old app
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -63,6 +63,9 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> assign(:sidebar_filter_panels, %{})
|
||||
|> assign(:post_editor_drafts, %{})
|
||||
|> assign(:post_editor_active_languages, %{})
|
||||
|> assign(:post_editor_tag_queries, %{})
|
||||
|> assign(:post_editor_category_queries, %{})
|
||||
|> assign(:post_editor_quick_actions_open, %{})
|
||||
|> assign(:post_editor_modes, %{})
|
||||
|> assign(:post_editor_expanded, %{})
|
||||
|> assign(:post_editor_save_states, %{})
|
||||
@@ -352,7 +355,40 @@ defmodule BDS.Desktop.ShellLive do
|
||||
{:noreply, PostEditor.select_language(socket, post_id, language, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_event("toggle_post_editor_quick_actions", %{"id" => post_id}, socket) do
|
||||
{:noreply, PostEditor.toggle_quick_actions(socket, post_id, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_event("detect_post_editor_language", %{"id" => post_id}, socket) do
|
||||
{:noreply, PostEditor.detect_language(socket, post_id, &reload_shell/2, &append_output_entry/5)}
|
||||
end
|
||||
|
||||
def handle_event("add_post_editor_tag", %{"id" => post_id, "tag" => tag}, socket) do
|
||||
{:noreply, PostEditor.add_list_value(socket, post_id, :tags, tag, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_event("remove_post_editor_tag", %{"id" => post_id, "tag" => tag}, socket) do
|
||||
{:noreply, PostEditor.remove_list_value(socket, post_id, :tags, tag, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_event("add_post_editor_category", %{"id" => post_id, "category" => category}, socket) do
|
||||
{:noreply, PostEditor.add_list_value(socket, post_id, :categories, category, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_event("remove_post_editor_category", %{"id" => post_id, "category" => category}, socket) do
|
||||
{:noreply, PostEditor.remove_list_value(socket, post_id, :categories, category, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_event("open_overlay", %{"kind" => kind}, socket) do
|
||||
socket =
|
||||
case socket.assigns[:current_tab] do
|
||||
%{type: :post, id: post_id} when kind in ["ai_suggestions", "language_picker"] ->
|
||||
assign(socket, :post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false))
|
||||
|
||||
_other ->
|
||||
socket
|
||||
end
|
||||
|
||||
overlay =
|
||||
with overlay_kind when not is_nil(overlay_kind) <- ShellOverlayComponents.kind(kind),
|
||||
%{type: route} <- socket.assigns[:current_tab] do
|
||||
@@ -405,16 +441,17 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|
||||
def handle_event("overlay_select_result", %{"id" => id}, socket) do
|
||||
overlay = socket.assigns[:shell_overlay]
|
||||
current_tab = socket.assigns[:current_tab]
|
||||
|
||||
socket =
|
||||
case overlay do
|
||||
%{kind: :insert_link} ->
|
||||
case {overlay, current_tab} do
|
||||
{%{kind: :insert_link}, %{type: :post, id: post_id}} ->
|
||||
case Overlay.insert_link_result(overlay, id) do
|
||||
nil -> socket
|
||||
result -> close_overlay_with_output(socket, overlay.title, ShellOverlayComponents.markdown_link(result.title, result.canonical_url))
|
||||
result -> PostEditor.insert_content(socket, post_id, ShellOverlayComponents.markdown_link(result.title, result.canonical_url), &reload_shell/2)
|
||||
end
|
||||
|
||||
%{kind: :insert_media} ->
|
||||
{%{kind: :insert_media}, %{type: :post, id: post_id}} ->
|
||||
case Overlay.insert_media_result(overlay, id) do
|
||||
nil -> socket
|
||||
result ->
|
||||
@@ -425,7 +462,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
"[#{result.original_name}](bds-media://#{result.media_id})"
|
||||
end
|
||||
|
||||
close_overlay_with_output(socket, overlay.title, syntax)
|
||||
PostEditor.insert_content(socket, post_id, syntax, &reload_shell/2)
|
||||
end
|
||||
|
||||
_other ->
|
||||
@@ -436,9 +473,11 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
def handle_event("overlay_insert_external", _params, socket) do
|
||||
current_tab = socket.assigns[:current_tab]
|
||||
|
||||
socket =
|
||||
case socket.assigns[:shell_overlay] do
|
||||
%{kind: :insert_link} = overlay ->
|
||||
case {socket.assigns[:shell_overlay], current_tab} do
|
||||
{%{kind: :insert_link} = overlay, %{type: :post, id: post_id}} ->
|
||||
details =
|
||||
case {overlay.external_url, String.trim(overlay.external_text || "")} do
|
||||
{"", _text} -> nil
|
||||
@@ -447,7 +486,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
if details do
|
||||
close_overlay_with_output(socket, overlay.title, details)
|
||||
PostEditor.insert_content(socket, post_id, details, &reload_shell/2)
|
||||
else
|
||||
socket
|
||||
end
|
||||
@@ -460,9 +499,13 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
def handle_event("overlay_select_language", %{"code" => code}, socket) do
|
||||
current_tab = socket.assigns[:current_tab]
|
||||
|
||||
socket =
|
||||
case socket.assigns[:shell_overlay] do
|
||||
%{kind: :language_picker, title: title} -> close_overlay_with_output(socket, title, code)
|
||||
case {socket.assigns[:shell_overlay], current_tab} do
|
||||
{%{kind: :language_picker}, %{type: :post, id: post_id}} ->
|
||||
PostEditor.translate(socket, post_id, code, &reload_shell/2, &append_output_entry/5)
|
||||
|
||||
_other -> socket
|
||||
end
|
||||
|
||||
@@ -470,17 +513,23 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
def handle_event("overlay_confirm", _params, socket) do
|
||||
socket =
|
||||
case socket.assigns[:shell_overlay] do
|
||||
%{kind: :ai_suggestions, title: title} = overlay ->
|
||||
selected = Overlay.selected_ai_fields(overlay)
|
||||
details = Enum.map_join(selected, ", ", & &1.label)
|
||||
close_overlay_with_output(socket, title, details)
|
||||
current_tab = socket.assigns[:current_tab]
|
||||
|
||||
%{kind: :confirm_delete, title: title, entity_name: entity_name} ->
|
||||
socket =
|
||||
case {socket.assigns[:shell_overlay], current_tab} do
|
||||
{%{kind: :ai_suggestions} = overlay, %{type: :post, id: post_id}} ->
|
||||
PostEditor.apply_ai_suggestions(
|
||||
socket,
|
||||
post_id,
|
||||
Overlay.selected_ai_fields(overlay),
|
||||
&reload_shell/2,
|
||||
&append_output_entry/5
|
||||
)
|
||||
|
||||
{%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} ->
|
||||
close_overlay_with_output(socket, title, entity_name)
|
||||
|
||||
%{kind: :confirm_dialog, title: title, message: message} ->
|
||||
{%{kind: :confirm_dialog, title: title, message: message}, _tab} ->
|
||||
close_overlay_with_output(socket, title, message)
|
||||
|
||||
_other ->
|
||||
|
||||
@@ -135,7 +135,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
defp existing_translations(_tab), do: %{}
|
||||
|
||||
defp blog_languages(metadata) do
|
||||
([metadata.main_language || "en"] ++ (metadata.blog_languages || []))
|
||||
([metadata.main_language || "en"] ++ (metadata.blog_languages || []) ++ Enum.map(I18n.supported_languages(), & &1.code))
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
@@ -4,10 +4,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
use Phoenix.Component
|
||||
|
||||
import Ecto.Query
|
||||
import Phoenix.HTML
|
||||
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.{I18n, Metadata, PostLinks, Posts, Repo, Tags, Templates}
|
||||
alias BDS.{AI, I18n, Metadata, PostLinks, Posts, Preview, Repo, Tags, Templates}
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Posts.{Post, Translation}
|
||||
alias BDS.UI.Workbench
|
||||
@@ -40,16 +39,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
end
|
||||
|
||||
draft = normalize_params(params, current_language, next_language)
|
||||
workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
|
||||
current = current_draft(socket.assigns, post, metadata, next_language)
|
||||
dirty? = draft != current
|
||||
|
||||
socket
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft))
|
||||
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language))
|
||||
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty))
|
||||
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: draft["title"], subtitle: Atom.to_string(post.status || :draft)}))
|
||||
|> maybe_drop_old_language_draft(post_id, current_language, next_language)
|
||||
|> reload.(workbench)
|
||||
|> put_query_state(post_id, :tags, Map.get(params, "tag_query", ""))
|
||||
|> put_query_state(post_id, :categories, Map.get(params, "category_query", ""))
|
||||
|> maybe_update_draft(post_id, post, current_language, next_language, draft, dirty?)
|
||||
|> reload_with_assigned_workbench(reload)
|
||||
end
|
||||
|
||||
_other ->
|
||||
@@ -126,6 +123,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
|
||||
|> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id))
|
||||
|> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id))
|
||||
|> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id))
|
||||
|> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id))
|
||||
|> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, post_id))
|
||||
|> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id))
|
||||
|> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id))
|
||||
|> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id))
|
||||
@@ -162,6 +162,186 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> reload.(workbench)
|
||||
end
|
||||
|
||||
def toggle_quick_actions(socket, post_id, reload) do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
socket
|
||||
|> assign(:post_editor_quick_actions_open, Map.update(socket.assigns.post_editor_quick_actions_open, post_id, true, &(!&1)))
|
||||
|> reload.(workbench)
|
||||
end
|
||||
|
||||
def detect_language(socket, post_id, reload, append_output) do
|
||||
if Map.get(socket.assigns, :offline_mode, true) do
|
||||
socket
|
||||
|> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%Post{} = post ->
|
||||
metadata = project_metadata(post.project_id)
|
||||
canonical_language = canonical_language(post, metadata)
|
||||
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
|
||||
draft = current_draft(socket.assigns, post, metadata, active_language)
|
||||
text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "content", "")], "\n\n")
|
||||
|
||||
case AI.detect_language(text) do
|
||||
{:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" ->
|
||||
socket
|
||||
|> put_draft_field(post_id, post, active_language, "language", normalize_language(language_code, canonical_language))
|
||||
|> reload_with_assigned_workbench(reload)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Detect Language"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
_other ->
|
||||
socket
|
||||
|> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def translate(socket, post_id, language, reload, append_output) do
|
||||
if Map.get(socket.assigns, :offline_mode, true) do
|
||||
socket
|
||||
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
normalized_language = normalize_language(language, "")
|
||||
|
||||
case AI.translate_post(post_id, normalized_language) do
|
||||
{:ok, translation} ->
|
||||
with {:ok, _saved_translation} <-
|
||||
Posts.upsert_post_translation(post_id, normalized_language, %{
|
||||
title: translation.title,
|
||||
excerpt: translation.excerpt,
|
||||
content: translation.content
|
||||
}) do
|
||||
socket
|
||||
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, normalized_language))
|
||||
|> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, normalized_language))
|
||||
|> assign(:post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
else
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Translate"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Translate"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def apply_ai_suggestions(socket, post_id, fields, reload, append_output) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%Post{} ->
|
||||
attrs =
|
||||
fields
|
||||
|> Enum.reduce(%{}, fn field, acc ->
|
||||
case field.key do
|
||||
"title" -> Map.put(acc, :title, blank_to_nil(field.suggested_value))
|
||||
"excerpt" -> Map.put(acc, :excerpt, blank_to_nil(field.suggested_value))
|
||||
"slug" -> Map.put(acc, :slug, blank_to_nil(field.suggested_value))
|
||||
_other -> acc
|
||||
end
|
||||
end)
|
||||
|
||||
if map_size(attrs) == 0 do
|
||||
socket |> assign(:shell_overlay, nil)
|
||||
else
|
||||
case Posts.update_post(post_id, attrs) do
|
||||
{:ok, updated_post} ->
|
||||
metadata = project_metadata(updated_post.project_id)
|
||||
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language(updated_post, metadata))
|
||||
refreshed_form = persisted_form(updated_post, metadata, active_language)
|
||||
|
||||
socket
|
||||
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, refreshed_form))
|
||||
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty))
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("AI Suggestions"), inspect(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def insert_content(socket, post_id, snippet, reload) do
|
||||
socket
|
||||
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{id: post_id, content: snippet})
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def add_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%Post{} = post ->
|
||||
metadata = project_metadata(post.project_id)
|
||||
canonical_language = canonical_language(post, metadata)
|
||||
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
|
||||
draft = current_draft(socket.assigns, post, metadata, active_language)
|
||||
normalized = normalize_list_entry(value)
|
||||
|
||||
if normalized in [nil, ""] do
|
||||
socket
|
||||
else
|
||||
ensure_list_value(post.project_id, kind, normalized)
|
||||
|
||||
updated =
|
||||
draft
|
||||
|> Map.get(field_key(kind), "")
|
||||
|> csv_to_list()
|
||||
|> Kernel.++([normalized])
|
||||
|> Enum.uniq()
|
||||
|> Enum.join(", ")
|
||||
|
||||
socket
|
||||
|> put_query_state(post_id, kind, "")
|
||||
|> put_draft_field(post_id, post, active_language, field_key(kind), updated)
|
||||
|> reload_with_assigned_workbench(reload)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def remove_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%Post{} = post ->
|
||||
metadata = project_metadata(post.project_id)
|
||||
canonical_language = canonical_language(post, metadata)
|
||||
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
|
||||
draft = current_draft(socket.assigns, post, metadata, active_language)
|
||||
updated = draft |> Map.get(field_key(kind), "") |> csv_to_list() |> Enum.reject(&(&1 == value)) |> Enum.join(", ")
|
||||
|
||||
socket
|
||||
|> put_draft_field(post_id, post, active_language, field_key(kind), updated)
|
||||
|> reload_with_assigned_workbench(reload)
|
||||
end
|
||||
end
|
||||
|
||||
def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -190,20 +370,40 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
%{
|
||||
id: post.id,
|
||||
display_title: display_title(form["title"], post.slug, post.id),
|
||||
subtitle: active_language_subtitle(active_language, canonical_language),
|
||||
subtitle: nil,
|
||||
slug: post.slug || post.id,
|
||||
status: current_status(post.status, active_language, canonical_language, current_translation),
|
||||
status: post.status || :draft,
|
||||
dirty?: Workbench.dirty?(assigns.workbench, :post, post.id),
|
||||
save_state: Map.get(assigns.post_editor_save_states, post.id, :idle),
|
||||
quick_actions_open?: Map.get(assigns.post_editor_quick_actions_open, post.id, false),
|
||||
metadata_expanded: Map.get(expanded, :metadata, false),
|
||||
excerpt_expanded: Map.get(expanded, :excerpt, false),
|
||||
mode: Map.get(assigns.post_editor_modes, post.id, :markdown),
|
||||
editing_canonical?: editing_canonical_language?(translations, active_language, canonical_language),
|
||||
can_publish?: (post.status || :draft) == :draft,
|
||||
can_delete?: (post.status || :draft) == :published,
|
||||
has_published_version?: has_published_version?(post),
|
||||
discard_label: discard_label(post),
|
||||
discard_title: discard_title(post),
|
||||
detect_language_enabled?: not blank?(Map.get(form, "title")) or not blank?(Map.get(form, "content")),
|
||||
can_translate?: Enum.any?(languages(metadata), &(&1 != canonical_language)),
|
||||
languages: languages(metadata),
|
||||
form: form,
|
||||
template_options: template_options(post.project_id),
|
||||
tag_options: Enum.map(Tags.list_tags(post.project_id), & &1.name),
|
||||
show_template_selector?: template_options(post.project_id) != [],
|
||||
tag_options: Tags.list_tags(post.project_id),
|
||||
tag_values: tag_values(form),
|
||||
tag_chips: tag_chips(form, Tags.list_tags(post.project_id)),
|
||||
tag_query: query_value(assigns, :tags, post.id),
|
||||
tag_query_addable?: query_addable?(query_value(assigns, :tags, post.id), tag_values(form), Tags.list_tags(post.project_id), fn option -> option.name end),
|
||||
category_values: category_values(form),
|
||||
category_query: query_value(assigns, :categories, post.id),
|
||||
category_options: metadata.categories || [],
|
||||
category_query_addable?: query_addable?(query_value(assigns, :categories, post.id), category_values(form), metadata.categories || [], & &1),
|
||||
tag_suggestions: tag_suggestions(form, Tags.list_tags(post.project_id), query_value(assigns, :tags, post.id)),
|
||||
category_suggestions: category_suggestions(form, metadata.categories || [], query_value(assigns, :categories, post.id)),
|
||||
gallery_count: gallery_count(form),
|
||||
preview_url: preview_url(post, active_language, canonical_language, Map.get(assigns.post_editor_modes, post.id, :markdown)),
|
||||
translation_flags: translation_flags(post, canonical_language, active_language, translations),
|
||||
linked_media: linked_media(post.id),
|
||||
post_links: post_links(post.id),
|
||||
@@ -313,6 +513,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|
||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||
|
||||
def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted)
|
||||
|
||||
def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
|
||||
|
||||
def project_metadata(project_id) do
|
||||
@@ -322,32 +524,161 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
_error -> %{main_language: "en", blog_languages: []}
|
||||
end
|
||||
|
||||
defp editor_toolbar(assigns) do
|
||||
~H"""
|
||||
<%= if Enum.any?(@toolbar_buttons) do %>
|
||||
<div class="editor-toolbar">
|
||||
<%= for button <- @toolbar_buttons do %>
|
||||
<button
|
||||
class={["editor-toolbar-button", if(button.destructive, do: "is-destructive")]}
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind={button.kind}
|
||||
>
|
||||
<%= translated(button.label) %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
"""
|
||||
def tag_chip_style(nil), do: nil
|
||||
|
||||
def tag_chip_style(color) do
|
||||
normalized = normalize_color(color)
|
||||
|
||||
if normalized do
|
||||
"background-color: #{normalized}; color: #{contrast_color(normalized)}; border-color: #{normalized};"
|
||||
end
|
||||
end
|
||||
|
||||
defp assigned_project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{})
|
||||
|
||||
defp current_status(post_status, active_language, canonical_language, current_translation) do
|
||||
if active_language == canonical_language, do: post_status, else: translation_status(current_translation)
|
||||
defp maybe_update_draft(socket, post_id, post, current_language, next_language, draft, true) do
|
||||
workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
|
||||
|
||||
socket
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft))
|
||||
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language))
|
||||
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty))
|
||||
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: draft["title"], subtitle: Atom.to_string(post.status || :draft)}))
|
||||
|> maybe_drop_old_language_draft(post_id, current_language, next_language)
|
||||
end
|
||||
|
||||
defp maybe_update_draft(socket, post_id, _post, _current_language, next_language, _draft, false) do
|
||||
assign(socket, :post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language))
|
||||
end
|
||||
|
||||
defp put_draft_field(socket, post_id, post, active_language, field, value) do
|
||||
metadata = project_metadata(post.project_id)
|
||||
draft = Map.put(current_draft(socket.assigns, post, metadata, active_language), field, value)
|
||||
workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
|
||||
|
||||
socket
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft))
|
||||
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty))
|
||||
end
|
||||
|
||||
defp put_query_state(socket, post_id, kind, value) do
|
||||
key = query_key(kind)
|
||||
assign(socket, key, Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || "")))
|
||||
end
|
||||
|
||||
defp query_value(assigns, kind, post_id) do
|
||||
assigns
|
||||
|> Map.get(query_key(kind), %{})
|
||||
|> Map.get(post_id, "")
|
||||
end
|
||||
|
||||
defp query_key(:tags), do: :post_editor_tag_queries
|
||||
defp query_key(:categories), do: :post_editor_category_queries
|
||||
|
||||
defp field_key(:tags), do: "tags"
|
||||
defp field_key(:categories), do: "categories"
|
||||
|
||||
defp tag_values(form), do: csv_to_list(Map.get(form, "tags", ""))
|
||||
defp category_values(form), do: csv_to_list(Map.get(form, "categories", ""))
|
||||
|
||||
defp tag_suggestions(form, options, query) do
|
||||
selected = MapSet.new(tag_values(form))
|
||||
filter_suggestions(options, query, fn option -> option.name end, selected)
|
||||
end
|
||||
|
||||
defp tag_chips(form, options) do
|
||||
option_map = Map.new(options, fn option -> {option.name, option} end)
|
||||
|
||||
Enum.map(tag_values(form), fn name ->
|
||||
option = Map.get(option_map, name)
|
||||
%{name: name, color: option && option.color}
|
||||
end)
|
||||
end
|
||||
|
||||
defp category_suggestions(form, options, query) do
|
||||
selected = MapSet.new(category_values(form))
|
||||
filter_suggestions(options, query, & &1, selected)
|
||||
end
|
||||
|
||||
defp filter_suggestions(options, query, labeler, selected) do
|
||||
query = normalize_query(query)
|
||||
|
||||
options
|
||||
|> Enum.filter(fn option ->
|
||||
label = labeler.(option)
|
||||
not MapSet.member?(selected, label) and (query == "" or String.contains?(String.downcase(label), query))
|
||||
end)
|
||||
|> Enum.take(8)
|
||||
end
|
||||
|
||||
defp query_addable?(query, selected_values, options, labeler) do
|
||||
normalized = normalize_query(query)
|
||||
|
||||
normalized != "" and
|
||||
normalized not in Enum.map(selected_values, &String.downcase/1) and
|
||||
not Enum.any?(options, fn option -> String.downcase(labeler.(option)) == normalized end)
|
||||
end
|
||||
|
||||
defp normalize_query(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp normalize_list_entry(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp ensure_list_value(project_id, :tags, value) do
|
||||
if Enum.any?(Tags.list_tags(project_id), &(String.downcase(&1.name) == value)) do
|
||||
:ok
|
||||
else
|
||||
_ = Tags.create_tag(%{project_id: project_id, name: value})
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_list_value(project_id, :categories, value) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
|
||||
if value in (metadata.categories || []) do
|
||||
:ok
|
||||
else
|
||||
_ = Metadata.add_category(project_id, value)
|
||||
:ok
|
||||
end
|
||||
rescue
|
||||
_error -> :ok
|
||||
end
|
||||
|
||||
defp normalize_color(nil), do: nil
|
||||
defp normalize_color(""), do: nil
|
||||
|
||||
defp normalize_color("#" <> rest = color) when byte_size(rest) == 6 do
|
||||
if String.match?(rest, ~r/\A[0-9a-fA-F]{6}\z/), do: color, else: nil
|
||||
end
|
||||
|
||||
defp normalize_color(_color), do: nil
|
||||
|
||||
defp contrast_color("#" <> rgb) do
|
||||
<<r::binary-size(2), g::binary-size(2), b::binary-size(2)>> = rgb
|
||||
{red, _} = Integer.parse(r, 16)
|
||||
{green, _} = Integer.parse(g, 16)
|
||||
{blue, _} = Integer.parse(b, 16)
|
||||
luminance = (red * 299 + green * 587 + blue * 114) / 1000
|
||||
if luminance > 150, do: "#1e1e1e", else: "#ffffff"
|
||||
end
|
||||
|
||||
defp contrast_color(_color), do: "#ffffff"
|
||||
|
||||
defp reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench)
|
||||
|
||||
defp persisted_form(post, metadata, active_language, translations) do
|
||||
canonical_language = canonical_language(post, metadata)
|
||||
translation = Map.get(translations, active_language)
|
||||
@@ -417,10 +748,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp translation_status(nil), do: :draft
|
||||
defp translation_status(%Translation{status: status}) when not is_nil(status), do: status
|
||||
defp translation_status(_translation), do: :draft
|
||||
|
||||
defp template_options(project_id) do
|
||||
Repo.all(
|
||||
from template in Templates.Template,
|
||||
@@ -523,14 +850,52 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled")
|
||||
end
|
||||
|
||||
defp active_language_subtitle(active_language, canonical_language) do
|
||||
if active_language == canonical_language do
|
||||
translated("Canonical draft")
|
||||
defp has_published_version?(%Post{} = post), do: not is_nil(post.published_at) or post.file_path not in [nil, ""]
|
||||
|
||||
defp discard_label(%Post{} = post) do
|
||||
if has_published_version?(post), do: translated("Discard Changes"), else: translated("Discard Draft")
|
||||
end
|
||||
|
||||
defp discard_title(%Post{} = post) do
|
||||
if has_published_version?(post), do: translated("Discard changes and restore the published version"), else: translated("Delete this unpublished draft")
|
||||
end
|
||||
|
||||
defp gallery_count(form) do
|
||||
form
|
||||
|> Map.get("content", "")
|
||||
|> to_string()
|
||||
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|
||||
|> length()
|
||||
end
|
||||
|
||||
defp preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview, do: nil
|
||||
|
||||
defp preview_url(%Post{} = post, active_language, canonical_language, :preview) do
|
||||
with {:ok, server} <- Preview.start_preview(post.project_id) do
|
||||
base_url = "http://#{server.host}:#{server.port}"
|
||||
query =
|
||||
%{}
|
||||
|> maybe_put_query("draft", "true")
|
||||
|> maybe_put_query("post_id", post.id)
|
||||
|> maybe_put_query("lang", active_language != canonical_language && active_language)
|
||||
|
||||
base_url <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query)
|
||||
else
|
||||
translated("Translation: %{language}", %{language: String.upcase(active_language)})
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp canonical_preview_path(created_at_ms, slug) do
|
||||
datetime = DateTime.from_unix!(created_at_ms, :millisecond)
|
||||
"/#{datetime.year}/#{pad2(datetime.month)}/#{pad2(datetime.day)}/#{slug || ""}"
|
||||
end
|
||||
|
||||
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||
|
||||
defp maybe_put_query(query, _key, false), do: query
|
||||
defp maybe_put_query(query, _key, nil), do: query
|
||||
defp maybe_put_query(query, key, value), do: Map.put(query, key, value)
|
||||
|
||||
defp save_canonical_draft(%Post{id: post_id}, draft) do
|
||||
Posts.update_post(post_id, %{
|
||||
title: blank_to_nil(Map.get(draft, "title")),
|
||||
|
||||
@@ -1,127 +1,260 @@
|
||||
<div class="post-editor" data-testid="post-editor">
|
||||
<div class="post-editor-header">
|
||||
<div class="post-editor-heading">
|
||||
<div class="editor-kicker"><%= translated("Post") %></div>
|
||||
<div class="post-editor-title-row">
|
||||
<h1 class="editor-title" data-testid="editor-title"><%= @post_editor.display_title %></h1>
|
||||
<div class="post-editor editor" data-testid="post-editor">
|
||||
<div class="editor-header">
|
||||
<div class="editor-tabs">
|
||||
<div class={["editor-tab", "active", if(@post_editor.dirty?, do: "dirty")]}>
|
||||
<span class="editor-tab-title" data-testid="editor-title"><%= @post_editor.display_title %></span>
|
||||
<%= if @post_editor.dirty? do %>
|
||||
<span class="post-editor-dirty-dot">●</span>
|
||||
<span class="editor-tab-dirty" title={translated("Unsaved")}>●</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="editor-subtitle"><%= @post_editor.subtitle %></p>
|
||||
</div>
|
||||
|
||||
<div class="post-editor-actions">
|
||||
<span class={["post-status-badge", "status-#{@post_editor.status}"]} data-testid="post-status-badge">
|
||||
<div class="editor-actions">
|
||||
<span class={["status-badge", "status-#{@post_editor.status}"]} data-testid="post-status-badge">
|
||||
<%= post_status_label(@post_editor.status) %>
|
||||
</span>
|
||||
<span class="post-save-state"><%= post_editor_save_state_label(@post_editor.save_state) %></span>
|
||||
<button class="editor-toolbar-button" type="button" phx-click="save_post_editor" phx-value-id={@post_editor.id}>
|
||||
<%= translated("Save") %>
|
||||
<%= if @post_editor.save_state in [:saving] do %>
|
||||
<span class="auto-save-indicator"><%= post_editor_save_state_label(@post_editor.save_state) %></span>
|
||||
<% end %>
|
||||
|
||||
<div class="quick-actions-wrapper">
|
||||
<button
|
||||
class="secondary quick-actions-btn"
|
||||
type="button"
|
||||
phx-click="toggle_post_editor_quick_actions"
|
||||
phx-value-id={@post_editor.id}
|
||||
>
|
||||
<%= translated("Quick Actions") %>
|
||||
</button>
|
||||
<button class="editor-toolbar-button" data-testid="post-publish-button" type="button" phx-click="publish_post_editor" phx-value-id={@post_editor.id}>
|
||||
|
||||
<%= 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>
|
||||
<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") %>
|
||||
<% 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>
|
||||
<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}>
|
||||
<% end %>
|
||||
<%= if @post_editor.can_delete? do %>
|
||||
<button class="secondary danger" data-testid="post-delete-button" type="button" phx-click="delete_post_editor" phx-value-id={@post_editor.id}>
|
||||
<%= translated("Delete") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-editor-flags-bar">
|
||||
<button class="post-editor-section-toggle" type="button" phx-click="toggle_post_metadata" phx-value-id={@post_editor.id}>
|
||||
<span><%= if @post_editor.metadata_expanded, do: "▼", else: "▶" %></span>
|
||||
<form class="post-editor-form editor-content" data-testid="post-editor-form" phx-change="change_post_editor">
|
||||
<div class="metadata-toggle-header">
|
||||
<button class={["metadata-toggle", if(@post_editor.metadata_expanded, do: "expanded")]} type="button" phx-click="toggle_post_metadata" phx-value-id={@post_editor.id}>
|
||||
<span class="metadata-toggle-chevron"><%= if @post_editor.metadata_expanded, do: "▼", else: "▶" %></span>
|
||||
<span><%= translated("Metadata") %></span>
|
||||
</button>
|
||||
|
||||
<div class="post-editor-flags">
|
||||
<div class="editor-translations-flags" aria-label={translated("Translations")}>
|
||||
<%= for flag <- @post_editor.translation_flags do %>
|
||||
<button
|
||||
class={[
|
||||
"translation-flag-button",
|
||||
if(flag.active, do: "is-active"),
|
||||
"status-#{flag.status}"
|
||||
"editor-translation-flag",
|
||||
"status-#{flag.status}",
|
||||
if(flag.active, do: "active")
|
||||
]}
|
||||
type="button"
|
||||
phx-click="select_post_editor_language"
|
||||
phx-value-id={@post_editor.id}
|
||||
phx-value-language={flag.language}
|
||||
title={flag.label}
|
||||
aria-label={flag.label}
|
||||
>
|
||||
<span><%= flag.flag %></span>
|
||||
<span><%= String.upcase(flag.language) %></span>
|
||||
<%= flag.flag %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= editor_toolbar(assigns) %>
|
||||
|
||||
<form class="post-editor-form" data-testid="post-editor-form" phx-change="change_post_editor">
|
||||
<div class={["post-editor-metadata-grid", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}>
|
||||
<div class="post-editor-column">
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Title") %></span>
|
||||
<div class={["editor-header-row", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}>
|
||||
<div class="editor-meta">
|
||||
<div class="editor-field">
|
||||
<label><%= translated("Title") %></label>
|
||||
<input class="post-editor-input" type="text" name="post_editor[title]" value={@post_editor.form["title"]} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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 %>
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Author") %></span>
|
||||
<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"]} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Language") %></span>
|
||||
<select class="post-editor-input" name="post_editor[language]" disabled={not @post_editor.editing_canonical?}>
|
||||
<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>
|
||||
</label>
|
||||
|
||||
<label class="post-editor-field post-editor-checkbox-field">
|
||||
<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"]} disabled={not @post_editor.editing_canonical?} />
|
||||
<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>
|
||||
|
||||
<label class="post-editor-field">
|
||||
<span><%= translated("Slug") %></span>
|
||||
<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} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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 %>
|
||||
</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?}>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="post-editor-links-panel">
|
||||
<strong><%= translated("Post Links") %></strong>
|
||||
@@ -154,13 +287,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-editor-column post-editor-side-panel">
|
||||
<aside class="editor-media-panel 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 %>
|
||||
@@ -175,28 +304,32 @@
|
||||
<% else %>
|
||||
<div class="post-editor-empty"><%= translated("No linked media") %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</aside>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<%= if @post_editor.excerpt_expanded do %>
|
||||
<label class="post-editor-field">
|
||||
<textarea class="post-editor-textarea post-editor-excerpt" name="post_editor[excerpt]" rows="4"><%= @post_editor.form["excerpt"] %></textarea>
|
||||
</label>
|
||||
<% end %>
|
||||
<div class="editor-body">
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-toolbar-left">
|
||||
<label><%= translated("Content") %></label>
|
||||
</div>
|
||||
|
||||
<div class="post-editor-body-header">
|
||||
<span class="post-editor-body-label"><%= translated("Content") %></span>
|
||||
<div class="post-editor-mode-toggle">
|
||||
<div class="editor-toolbar-center">
|
||||
<div class="editor-mode-toggle">
|
||||
<%= for mode <- [:visual, :markdown, :preview] do %>
|
||||
<button
|
||||
class={["post-editor-mode-button", if(@post_editor.mode == mode, do: "is-active")]}
|
||||
class={if(@post_editor.mode == mode, do: "active")}
|
||||
type="button"
|
||||
phx-click="set_post_editor_mode"
|
||||
phx-value-id={@post_editor.id}
|
||||
@@ -208,16 +341,57 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @post_editor.mode == :preview do %>
|
||||
<div class="post-editor-preview" data-testid="post-editor-preview"><%= raw(Earmark.as_html!(@post_editor.form["content"] || "")) %></div>
|
||||
<% else %>
|
||||
<label class="post-editor-field post-editor-content-field">
|
||||
<textarea class="post-editor-textarea post-editor-content" data-testid="post-editor-content" name="post_editor[content]" rows="18"><%= @post_editor.form["content"] %></textarea>
|
||||
</label>
|
||||
<div class="editor-toolbar-right">
|
||||
<%= if @post_editor.mode == :markdown do %>
|
||||
<button
|
||||
class="insert-post-link-button"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="insert_link"
|
||||
>
|
||||
<%= translated("Insert Link") %>
|
||||
</button>
|
||||
<button
|
||||
class="insert-media-button"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="insert_media"
|
||||
>
|
||||
<%= translated("Insert Media") %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @post_editor.gallery_count > 0 do %>
|
||||
<button
|
||||
class="gallery-button"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="gallery"
|
||||
>
|
||||
<%= translated("Gallery") %> (<%= @post_editor.gallery_count %>)
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @post_editor.mode == :preview do %>
|
||||
<div class="editor-preview post-editor-preview" data-testid="post-editor-preview">
|
||||
<%= if @post_editor.preview_url do %>
|
||||
<iframe class="editor-preview-frame" src={@post_editor.preview_url}></iframe>
|
||||
<% else %>
|
||||
<div class="post-editor-empty"><%= translated("Preview unavailable") %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<textarea id={"post-editor-content-#{@post_editor.id}"} class="post-editor-textarea post-editor-content" data-testid="post-editor-content" phx-hook="PostEditorContent" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18"><%= @post_editor.form["content"] %></textarea>
|
||||
<% end %>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="post-editor-footer">
|
||||
<div class="editor-footer">
|
||||
<span><strong><%= translated("Created") %>:</strong> <%= @post_editor.footer.created_at %></span>
|
||||
<span><strong><%= translated("Updated") %>:</strong> <%= @post_editor.footer.updated_at %></span>
|
||||
<%= if @post_editor.footer.published_at do %>
|
||||
|
||||
844
priv/ui/app.css
844
priv/ui/app.css
@@ -71,7 +71,71 @@ body > [data-phx-main] {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -835,185 +899,559 @@ button {
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.post-editor {
|
||||
.post-editor.editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 14px 16px 18px;
|
||||
background-color: var(--vscode-editor-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-editor-header,
|
||||
.post-editor-title-row,
|
||||
.post-editor-actions,
|
||||
.post-editor-flags-bar,
|
||||
.post-editor-links-columns,
|
||||
.post-editor-side-panel-header,
|
||||
.post-editor-side-actions,
|
||||
.post-editor-excerpt-header,
|
||||
.post-editor-body-header,
|
||||
.post-editor-mode-toggle,
|
||||
.post-editor-footer {
|
||||
.post-editor .editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.post-editor-header,
|
||||
.post-editor-flags-bar,
|
||||
.post-editor-body-header,
|
||||
.post-editor-footer,
|
||||
.post-editor-side-panel-header {
|
||||
justify-content: space-between;
|
||||
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-column,
|
||||
.post-editor-links-panel,
|
||||
.post-editor-side-panel {
|
||||
.post-editor .editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.post-editor-title-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.post-editor-dirty-dot {
|
||||
color: var(--vscode-editorWarning-foreground, #e2c08d);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.post-status-badge,
|
||||
.translation-flag-button,
|
||||
.post-editor-mode-button,
|
||||
.post-editor-section-toggle {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.post-status-badge {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.post-save-state {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.post-editor-flags-bar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.post-editor-flags,
|
||||
.post-editor-side-actions,
|
||||
.post-editor-mode-toggle {
|
||||
.post-editor .editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.translation-flag-button,
|
||||
.post-editor-mode-button,
|
||||
.post-editor-section-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
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;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.translation-flag-button.is-active,
|
||||
.post-editor-mode-button.is-active,
|
||||
.post-editor-section-toggle:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
.post-editor .quick-action-item:hover:not(:disabled) {
|
||||
background: var(--vscode-list-hoverBackground, #2a2d2e);
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.post-editor-metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.85fr);
|
||||
.post-editor .quick-action-text strong {
|
||||
font-size: 13px;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.post-editor-column {
|
||||
.post-editor .editor-meta {
|
||||
display: flex;
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.post-editor-input,
|
||||
.post-editor-textarea {
|
||||
.post-editor .editor-field label,
|
||||
.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%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-input-background, rgba(255, 255, 255, 0.03));
|
||||
background: var(--vscode-input-background, rgba(255, 255, 255, 0.06));
|
||||
color: var(--vscode-input-foreground, var(--vscode-foreground));
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.post-editor-input.is-readonly {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
.post-editor .post-editor-input.is-readonly {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.post-editor-textarea {
|
||||
resize: vertical;
|
||||
.post-editor .post-editor-textarea {
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.post-editor-checkbox-field {
|
||||
flex-direction: row;
|
||||
.post-editor .post-editor-excerpt {
|
||||
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;
|
||||
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-side-panel {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
.post-editor .tag-input-wrapper:focus-within {
|
||||
border-color: var(--vscode-focusBorder, #007fd4);
|
||||
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;
|
||||
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 {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
.post-editor .tag-suggestion {
|
||||
display: flex;
|
||||
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;
|
||||
align-items: flex-start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.post-editor-links-columns > div,
|
||||
.post-editor-side-panel {
|
||||
.post-editor .post-editor-links-columns > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.post-editor-links-label,
|
||||
.post-editor-body-label,
|
||||
.post-editor-media-meta,
|
||||
.post-editor-empty {
|
||||
.post-editor .post-editor-empty,
|
||||
.post-editor .post-editor-media-meta {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.post-editor-media-list {
|
||||
.post-editor .post-editor-media-list {
|
||||
list-style: none;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
@@ -1022,48 +1460,194 @@ button {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.post-editor-media-item {
|
||||
.post-editor .post-editor-media-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.post-editor-content-field {
|
||||
margin: 0;
|
||||
.post-editor .editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.post-editor-content {
|
||||
min-height: 360px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
.post-editor .editor-toolbar {
|
||||
display: grid;
|
||||
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;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
background-color: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.post-editor-footer {
|
||||
flex-wrap: wrap;
|
||||
.post-editor .editor-preview-frame {
|
||||
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);
|
||||
font-size: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.post-editor-header,
|
||||
.post-editor-flags-bar,
|
||||
.post-editor-body-header {
|
||||
.post-editor .editor-header,
|
||||
.post-editor .metadata-toggle-header,
|
||||
.post-editor .editor-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.post-editor-metadata-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.post-editor .editor-header-row,
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -371,6 +371,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
destroyed() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias BDS.Persistence
|
||||
alias BDS.Metadata
|
||||
alias BDS.Posts
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Projects
|
||||
@@ -561,12 +562,19 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
end
|
||||
|
||||
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} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Draft Shell Post",
|
||||
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)
|
||||
@@ -586,8 +594,45 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
assert html =~ ~s(name="post_editor[excerpt]")
|
||||
assert html =~ ~s(data-testid="post-publish-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."
|
||||
|
||||
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 =
|
||||
view
|
||||
|> form("[data-testid='post-editor-form']", %{
|
||||
@@ -599,8 +644,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
categories: "notes, guides",
|
||||
author: "Ada Lovelace",
|
||||
language: "de",
|
||||
do_not_translate: "false",
|
||||
template_slug: ""
|
||||
do_not_translate: "false"
|
||||
}
|
||||
})
|
||||
|> render_change()
|
||||
@@ -622,6 +666,9 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
html = render_click(view, "publish_post_editor", %{"id" => post.id})
|
||||
|
||||
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
|
||||
|
||||
_html =
|
||||
@@ -635,8 +682,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
categories: "notes, guides",
|
||||
author: "Ada Lovelace",
|
||||
language: "de",
|
||||
do_not_translate: "false",
|
||||
template_slug: ""
|
||||
do_not_translate: "false"
|
||||
}
|
||||
})
|
||||
|> render_change()
|
||||
|
||||
Reference in New Issue
Block a user