feat: some refactoring to make shell_live smaller

This commit is contained in:
2026-04-26 15:39:04 +02:00
parent 92fde24aa1
commit 5aefa7ae41
10 changed files with 1727 additions and 570 deletions

View File

@@ -364,29 +364,33 @@
</div>
</div>
<% else %>
<div class="editor-frame">
<section class="editor-main">
<div class="editor-kicker"><%= tab_route_label(@current_tab) %></div>
<h1 class="editor-title" data-testid="editor-title"><%= tab_title(@current_tab, @tab_meta) %></h1>
<p class="editor-subtitle"><%= tab_subtitle(@current_tab, @tab_meta) %></p>
<%= if @current_tab.type == :post and @post_editor do %>
<PostEditor.post_editor post_editor={@post_editor} toolbar_buttons={editor_toolbar_buttons(@current_tab)} />
<% else %>
<div class="editor-frame">
<section class="editor-main">
<div class="editor-kicker"><%= tab_route_label(@current_tab) %></div>
<h1 class="editor-title" data-testid="editor-title"><%= tab_title(@current_tab, @tab_meta) %></h1>
<p class="editor-subtitle"><%= tab_subtitle(@current_tab, @tab_meta) %></p>
<%= render_editor_toolbar(assigns) %>
<%= render_editor_toolbar(assigns) %>
<div class="editor-section">
<h2><%= tab_title(@current_tab, @tab_meta) %></h2>
<p>Desktop workbench content routed through the Elixir shell.</p>
</div>
</section>
<div class="editor-section">
<h2><%= tab_title(@current_tab, @tab_meta) %></h2>
<p>Desktop workbench content routed through the Elixir shell.</p>
</div>
</section>
<aside class="editor-meta">
<%= for item <- @editor_meta do %>
<section class="editor-meta-row">
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
<span><%= translated(item.value) %></span>
</section>
<% end %>
</aside>
</div>
<aside class="editor-meta">
<%= for item <- @editor_meta do %>
<section class="editor-meta-row">
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
<span><%= translated(item.value) %></span>
</section>
<% end %>
</aside>
</div>
<% end %>
<% end %>
</section>
@@ -624,5 +628,5 @@
</div>
</footer>
<%= render_shell_overlay(assigns) %>
<ShellOverlayComponents.shell_overlay shell_overlay={@shell_overlay} />
</div>

View File

@@ -0,0 +1,286 @@
defmodule BDS.Desktop.ShellLive.OverlayComponents do
@moduledoc false
use Phoenix.Component
import Ecto.Query
alias BDS.Desktop.ShellData
alias BDS.{I18n, Metadata, Repo}
alias BDS.Media.Media
alias BDS.Posts.{Post, Translation}
alias BDS.Tags.Tag
embed_templates "overlay_html/*"
def context(assigns, tab_title, tab_subtitle) do
project_id = assigns.projects.active_project_id
metadata = project_metadata(project_id)
current_tab = assigns.current_tab
page_language = assigns.page_language
posts = posts(project_id)
media = media(project_id)
%{
current_tab: %{type: current_tab.type, id: current_tab.id, title: tab_title, subtitle: tab_subtitle},
current_post_language: source_language(current_tab, metadata),
current_media_language: source_language(current_tab, metadata),
posts: posts,
media: media,
post_media_ids: post_media_ids(current_tab),
blog_languages: blog_languages(metadata),
language_names: language_names(),
language_flags: language_flags(),
existing_translations: existing_translations(current_tab),
ai_title: ShellData.translate("AI Suggestions", %{}, page_language),
insert_link_title: ShellData.translate("Insert Link", %{}, page_language),
insert_media_title: ShellData.translate("Insert Media", %{}, page_language),
language_picker_title: ShellData.translate("Translate", %{}, page_language),
gallery_title: tab_title,
ai_fields: ai_fields(current_tab, tab_title, tab_subtitle, page_language),
delete_details: delete_details(current_tab, page_language),
merge_details: merge_details(project_id, page_language)
}
end
def kind("ai_suggestions"), do: :ai_suggestions
def kind("insert_link"), do: :insert_link
def kind("insert_media"), do: :insert_media
def kind("language_picker"), do: :language_picker
def kind("confirm_delete"), do: :confirm_delete
def kind("confirm_merge"), do: :confirm_merge
def kind("gallery"), do: :gallery
def kind(_kind), do: nil
def tab("internal"), do: :internal
def tab("external"), do: :external
def tab(_tab), do: :internal
def markdown_link(text, url), do: "[#{text}](#{url})"
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
def project_metadata(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
metadata
rescue
_error -> %{main_language: "en", blog_languages: []}
end
defp posts(nil), do: []
defp posts(project_id) do
Repo.all(
from post in Post,
where: post.project_id == ^project_id,
order_by: [desc: post.updated_at, desc: post.created_at],
select: %{id: post.id, title: post.title, slug: post.slug, status: post.status, published_at: post.published_at, updated_at: post.updated_at, language: post.language}
)
|> Enum.map(fn post ->
%{
id: post.id,
title: post.title || post.slug || post.id,
status: Atom.to_string(post.status || :draft),
canonical_url: canonical_post_url(post)
}
end)
end
defp media(nil), do: []
defp media(project_id) do
Repo.all(
from media in Media,
where: media.project_id == ^project_id,
order_by: [desc: media.updated_at, desc: media.created_at],
select: %{id: media.id, title: media.title, original_name: media.original_name, mime_type: media.mime_type, alt: media.alt, caption: media.caption}
)
|> Enum.map(fn media ->
%{
id: media.id,
title: media.title || media.original_name || media.id,
original_name: media.original_name || media.id,
is_image: String.starts_with?(to_string(media.mime_type || ""), "image/"),
thumbnail_url: "/media-thumbnail/#{media.id}",
image_url: "/media-thumbnail/#{media.id}?size=large",
alt_text: media.alt || media.caption || media.title
}
end)
end
defp post_media_ids(%{type: :post, id: post_id}) do
case Repo.query("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do
{:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end)
_other -> []
end
rescue
_error -> []
end
defp post_media_ids(_tab), do: []
defp existing_translations(%{type: :post, id: post_id}) do
Repo.all(
from translation in Translation,
where: translation.translation_for == ^post_id,
select: {translation.language, translation.status}
)
|> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end)
rescue
_error -> %{}
end
defp existing_translations(_tab), do: %{}
defp blog_languages(metadata) do
([metadata.main_language || "en"] ++ (metadata.blog_languages || []))
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
end
defp source_language(%{type: :post, id: post_id}, metadata) do
case Repo.get(Post, post_id) do
%Post{language: language} when is_binary(language) and language != "" -> language
_other -> metadata.main_language || "en"
end
rescue
_error -> metadata.main_language || "en"
end
defp source_language(_tab, metadata), do: metadata.main_language || "en"
defp language_names do
%{
"en" => "English",
"de" => "Deutsch",
"fr" => "Francais",
"it" => "Italiano",
"es" => "Espanol"
}
end
defp language_flags do
I18n.supported_languages()
|> Enum.into(%{}, fn language -> {language.code, I18n.flag(language.code)} end)
end
defp ai_fields(%{type: :post, id: post_id}, title, subtitle, page_language) do
case Repo.get(Post, post_id) do
%Post{} = post ->
[
%{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: post.title || title, suggested_value: refine_title(post.title || title), locked: false},
%{key: "excerpt", label: ShellData.translate("Excerpt", %{}, page_language), current_value: post.excerpt || subtitle, suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), locked: false},
%{key: "slug", label: ShellData.translate("Slug", %{}, page_language), current_value: post.slug || slugify(post.title || title), suggested_value: refine_slug(post.slug || slugify(post.title || title)), locked: post.status == :published}
]
_other ->
[]
end
rescue
_error -> []
end
defp ai_fields(%{type: :media, id: media_id}, title, _subtitle, page_language) do
case Repo.get(Media, media_id) do
%Media{} = media ->
[
%{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: media.title || title, suggested_value: refine_title(media.title || title), locked: false},
%{key: "alt", label: ShellData.translate("Alt Text", %{}, page_language), current_value: media.alt || "", suggested_value: media.alt || title, locked: false},
%{key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false}
]
_other ->
[]
end
rescue
_error -> []
end
defp ai_fields(_tab, _title, _subtitle, _page_language), do: []
defp delete_details(%{type: :media, id: media_id}, page_language) do
entity_name =
case Repo.get(Media, media_id) do
%Media{} = media -> media.title || media.original_name || media.id
_other -> media_id
end
reference_list =
case Repo.query("SELECT posts.title FROM posts JOIN post_media ON posts.id = post_media.post_id WHERE post_media.media_id = ? ORDER BY post_media.sort_order ASC, posts.updated_at DESC", [media_id]) do
{:ok, %{rows: rows}} -> Enum.map(rows, fn [title] -> title || media_id end)
_other -> []
end
%{
title: ShellData.translate("Delete Media", %{}, page_language),
entity_name: entity_name,
entity_type: "media",
reference_list: reference_list
}
rescue
_error -> %{title: ShellData.translate("Delete Media", %{}, page_language), entity_name: media_id, entity_type: "media", reference_list: []}
end
defp delete_details(%{type: :tags}, page_language) do
tag_name =
Repo.one(from tag in Tag, order_by: [asc: tag.name], limit: 1, select: tag.name)
|> Kernel.||("tag")
%{
title: ShellData.translate("Delete Tag", %{}, page_language),
entity_name: tag_name,
entity_type: "tag",
reference_list: []
}
rescue
_error -> %{title: ShellData.translate("Delete Tag", %{}, page_language), entity_name: "tag", entity_type: "tag", reference_list: []}
end
defp delete_details(_tab, page_language) do
%{title: ShellData.translate("Delete", %{}, page_language), entity_name: "", entity_type: "item", reference_list: []}
end
defp merge_details(project_id, page_language) do
tags =
Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name], limit: 3, select: tag.name)
target = List.first(tags) || "tag"
%{
target: target,
count: max(length(tags), 1),
title: ShellData.translate("Merge Tags", %{}, page_language),
message: ShellData.translate("Cannot be undone.", %{}, page_language)
}
rescue
_error -> %{target: "tag", count: 1, title: ShellData.translate("Merge Tags", %{}, page_language), message: ShellData.translate("Cannot be undone.", %{}, page_language)}
end
defp canonical_post_url(post) do
timestamp = post.published_at || post.updated_at || System.system_time(:millisecond)
date = DateTime.from_unix!(timestamp, :millisecond)
"/#{date.year}/#{pad2(date.month)}/#{pad2(date.day)}/#{post.slug || post.id}"
end
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
defp refine_title(nil), do: ""
defp refine_title(title), do: String.trim(title <> " Notes")
defp refine_excerpt(title, excerpt) do
base = excerpt |> to_string() |> String.trim()
if base == "", do: "#{title} overview", else: base <> "."
end
defp refine_slug(slug), do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated")
defp slugify(value) do
value
|> to_string()
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
end
end

View File

@@ -0,0 +1,219 @@
<%= if @shell_overlay do %>
<%= case @shell_overlay.kind do %>
<% :ai_suggestions -> %>
<div class="shell-overlay-backdrop ai-suggestions-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="ai-suggestions-modal" role="dialog" aria-modal="true">
<div class="ai-suggestions-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="ai-suggestions-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="ai-suggestions-modal-body">
<div class="ai-suggestions-list">
<%= for field <- @shell_overlay.fields do %>
<div class="ai-suggestion-item">
<label class="ai-suggestion-checkbox">
<input
type="checkbox"
checked={field.accepted}
disabled={field.locked}
phx-click="overlay_toggle_ai_field"
phx-value-key={field.key}
/>
<span class="checkmark"></span>
</label>
<div class="ai-suggestion-content">
<div class="ai-suggestion-label"><%= field.label %></div>
<div class="ai-suggestion-current"><%= field.current_value %></div>
<div class="ai-suggestion-value"><%= field.suggested_value %></div>
</div>
</div>
<% end %>
</div>
</div>
<div class="ai-suggestions-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= translated("Apply Selected") %></button>
</div>
</div>
</div>
<% :insert_link -> %>
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="insert-modal" role="dialog" aria-modal="true">
<div class="insert-modal-header">
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
<div class="insert-modal-tabs">
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :internal, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="internal"><%= translated("Internal") %></button>
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :external, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="external"><%= translated("External") %></button>
</div>
</div>
<%= if @shell_overlay.active_tab == :internal do %>
<form class="insert-modal-search" phx-change="overlay_set_search">
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={translated("sidebar.searchPostsPlaceholder")} />
</form>
<div class="insert-modal-results">
<%= for result <- if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts) do %>
<button class="insert-modal-result-item" type="button" phx-click="overlay_select_result" phx-value-id={result.post_id}>
<div class="insert-modal-result-title"><%= result.title %></div>
<div class="insert-modal-result-meta"><%= result.canonical_url %></div>
</button>
<% end %>
<%= if Enum.empty?(if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts)) do %>
<div class="insert-modal-status"><%= translated("No items") %></div>
<% end %>
</div>
<% else %>
<form class="insert-modal-external" phx-change="overlay_update_form">
<label class="insert-modal-field">
<span class="insert-modal-label"><%= translated("URL") %></span>
<input class="insert-modal-input" type="text" name="overlay[url]" value={@shell_overlay.external_url} />
</label>
<label class="insert-modal-field">
<span class="insert-modal-label"><%= translated("Display Text") %></span>
<input class="insert-modal-input" type="text" name="overlay[text]" value={@shell_overlay.external_text} />
</label>
<button class="insert-modal-submit" type="button" phx-click="overlay_insert_external"><%= translated("Insert") %></button>
</form>
<% end %>
</div>
</div>
<% :insert_media -> %>
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="insert-modal" role="dialog" aria-modal="true">
<div class="insert-modal-header">
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
</div>
<form class="insert-modal-search" phx-change="overlay_set_search">
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={translated("sidebar.searchMediaPlaceholder")} />
</form>
<div class="insert-modal-results insert-modal-media-grid">
<%= for result <- @shell_overlay.results do %>
<button class="insert-modal-media-item" type="button" phx-click="overlay_select_result" phx-value-id={result.media_id}>
<%= if result.thumbnail_url do %>
<img class="insert-modal-media-thumb" src={result.thumbnail_url} alt="" loading="lazy" />
<% else %>
<span class="insert-modal-media-fallback"><%= result.original_name %></span>
<% end %>
<span class="insert-modal-media-title"><%= result.title %></span>
</button>
<% end %>
</div>
</div>
</div>
<% :language_picker -> %>
<div class="shell-overlay-backdrop language-picker-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="language-picker-modal" role="dialog" aria-modal="true">
<div class="language-picker-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="language-picker-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="language-picker-modal-body">
<div class="language-picker-label"><%= translated("Available languages") %></div>
<div class="language-picker-options">
<%= for target <- @shell_overlay.available_targets do %>
<button class="language-picker-option" type="button" phx-click="overlay_select_language" phx-value-code={target.code}>
<span class="language-picker-flag"><%= target.flag_emoji %></span>
<span class="language-picker-name"><%= target.name %></span>
<%= if target.has_existing_translation do %>
<span class="language-picker-status"><%= target.existing_status %></span>
<% end %>
</button>
<% end %>
</div>
</div>
</div>
</div>
<% :confirm_delete -> %>
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
<div class="confirm-delete-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="confirm-delete-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="confirm-delete-modal-body">
<div class="confirm-delete-message"><strong><%= @shell_overlay.entity_name %></strong></div>
<%= if @shell_overlay.reference_count > 0 do %>
<div class="confirm-delete-warning">
<div class="warning-content">
<strong><%= translated("This item is referenced by:") %></strong>
<ul class="reference-list">
<%= for title <- @shell_overlay.reference_list do %>
<li><span class="reference-title"><%= title %></span></li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
<div class="confirm-delete-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
<button class="button-delete" type="button" phx-click="overlay_confirm"><%= translated("Delete") %></button>
</div>
</div>
</div>
<% :confirm_dialog -> %>
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
<div class="confirm-delete-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="confirm-delete-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="confirm-delete-modal-body">
<div class="confirm-delete-message"><%= @shell_overlay.message %></div>
</div>
<div class="confirm-delete-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= translated("Confirm") %></button>
</div>
</div>
</div>
<% :gallery -> %>
<div class="shell-overlay-backdrop gallery-overlay-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
<div class="gallery-overlay" role="dialog" aria-modal="true">
<div class="gallery-overlay-header">
<h2><%= translated("Gallery") %></h2>
<button class="gallery-overlay-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="gallery-overlay-grid">
<%= for image <- @shell_overlay.images do %>
<button class="gallery-overlay-item" type="button" phx-click="overlay_select_gallery_image" phx-value-id={image.media_id}>
<img src={image.thumbnail_url} alt={image.alt_text || ""} loading="lazy" />
</button>
<% end %>
</div>
</div>
<%= if @shell_overlay.lightbox do %>
<div class="lightbox-overlay">
<button class="shell-overlay-dismiss" type="button" phx-click="overlay_close_lightbox" aria-label={translated("Cancel")}></button>
<div class="lightbox-container">
<button class="lightbox-close" type="button" phx-click="overlay_close_lightbox">×</button>
<%= if @shell_overlay.lightbox.total_count > 1 do %>
<button class="lightbox-nav lightbox-prev" type="button" phx-click="overlay_lightbox_previous"></button>
<% end %>
<img class="lightbox-image" src={@shell_overlay.lightbox.image_url} alt={@shell_overlay.lightbox.alt_text || ""} />
<%= if @shell_overlay.lightbox.total_count > 1 do %>
<button class="lightbox-nav lightbox-next" type="button" phx-click="overlay_lightbox_next"></button>
<div class="lightbox-counter"><%= @shell_overlay.lightbox.position %>/<%= @shell_overlay.lightbox.total_count %></div>
<% end %>
</div>
</div>
<% end %>
</div>
<% _other -> %>
<% end %>
<% end %>

View File

@@ -0,0 +1,404 @@
defmodule BDS.Desktop.ShellLive.PostEditor do
@moduledoc false
use Phoenix.Component
import Ecto.Query
import Phoenix.HTML
alias BDS.Desktop.ShellData
alias BDS.{I18n, PostLinks, Posts, Repo, Tags, Templates}
alias BDS.Media.Media
alias BDS.Posts.{Post, Translation}
alias BDS.UI.Workbench
embed_templates "post_editor_html/*"
def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do
case Repo.get(Post, post_id) do
nil ->
nil
%Post{} = post ->
metadata = project_metadata(assigns)
canonical_language = canonical_language(post, metadata)
active_language = Map.get(assigns.post_editor_active_languages, post.id, canonical_language)
translations = translations(post.id)
persisted_form = persisted_form(post, metadata, active_language, translations)
form =
assigns.post_editor_drafts
|> Map.get(post.id, %{})
|> Map.get(active_language, persisted_form)
expanded =
Map.get(assigns.post_editor_expanded, post.id, %{
metadata: blank?(post.title),
excerpt: not blank?(post.excerpt)
})
current_translation = Map.get(translations, active_language)
%{
id: post.id,
display_title: display_title(form["title"], post.slug, post.id),
subtitle: active_language_subtitle(active_language, canonical_language),
slug: post.slug || post.id,
status: current_status(post.status, active_language, canonical_language, current_translation),
dirty?: Workbench.dirty?(assigns.workbench, :post, post.id),
save_state: Map.get(assigns.post_editor_save_states, post.id, :idle),
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),
languages: languages(metadata),
form: form,
template_options: template_options(post.project_id),
tag_options: Enum.map(Tags.list_tags(post.project_id), & &1.name),
category_options: metadata.categories || [],
translation_flags: translation_flags(post, canonical_language, active_language, translations),
linked_media: linked_media(post.id),
post_links: post_links(post.id),
footer: footer(post, current_translation, active_language, canonical_language)
}
end
end
def build(_assigns), do: nil
def normalize_mode(mode) when mode in [:visual, :markdown, :preview], do: mode
def normalize_mode("visual"), do: :visual
def normalize_mode("preview"), do: :preview
def normalize_mode(_mode), do: :markdown
def normalize_language(value, fallback) do
case value |> to_string() |> String.trim() do
"" -> fallback
normalized -> String.downcase(normalized)
end
end
def normalize_params(params, current_language, next_language) do
%{
"title" => Map.get(params, "title", ""),
"excerpt" => Map.get(params, "excerpt", ""),
"content" => Map.get(params, "content", ""),
"tags" => Map.get(params, "tags", ""),
"categories" => Map.get(params, "categories", ""),
"author" => Map.get(params, "author", ""),
"language" => if(current_language == next_language, do: normalize_language(Map.get(params, "language"), current_language), else: next_language),
"do_not_translate" => truthy?(Map.get(params, "do_not_translate")),
"template_slug" => Map.get(params, "template_slug", "")
}
end
def current_draft(assigns, %Post{} = post, metadata, active_language) do
persisted = persisted_form(post, metadata, active_language)
assigns.post_editor_drafts
|> Map.get(post.id, %{})
|> Map.get(active_language, persisted)
end
def persisted_form(%Post{} = post, metadata, active_language) do
persisted_form(post, metadata, active_language, translations(post.id))
end
def persist(%Post{} = post, draft, active_language, metadata, action) do
canonical_language = canonical_language(post, metadata)
translations = translations(post.id)
result =
if editing_canonical_language?(translations, active_language, canonical_language) do
post
|> save_canonical_draft(draft)
|> maybe_publish_post(post.id, action)
else
post.id
|> save_translation_draft(active_language, draft)
|> maybe_publish_translation(post.id, active_language, action)
end
result
end
def discard(%Post{} = post, active_language, metadata) do
canonical_language = canonical_language(post, metadata)
current_translations = translations(post.id)
cond do
not editing_canonical_language?(current_translations, active_language, canonical_language) ->
{:ok, post}
post.file_path not in [nil, ""] and post.status == :draft ->
Posts.discard_post_changes(post.id)
true ->
{:ok, post}
end
end
def save_state_for_action(:publish), do: :published
def save_state_for_action(_action), do: :saved
def record_title(%Translation{title: title}, post), do: blank_to_nil(title) || post.title || post.slug || post.id
def record_title(%Post{title: title, slug: slug, id: id}, _post), do: blank_to_nil(title) || blank_to_nil(slug) || id
def record_status(%Translation{status: status}), do: status || :draft
def record_status(%Post{status: status}), do: status || :draft
def editing_canonical_language?(translations, active_language, canonical_language) do
active_language == canonical_language or not Map.has_key?(translations, active_language)
end
def post_status_label(status), do: ShellData.dashboard_status_label(status)
def post_editor_save_state_label(:dirty), do: translated("Unsaved")
def post_editor_save_state_label(:saved), do: translated("Saved")
def post_editor_save_state_label(:published), do: translated("Published")
def post_editor_save_state_label(:discarded), do: translated("Reverted")
def post_editor_save_state_label(_state), do: translated("Idle")
def post_editor_mode_label(:visual), do: translated("Visual")
def post_editor_mode_label(:markdown), do: translated("Markdown")
def post_editor_mode_label(:preview), do: translated("Preview")
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
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 %>
"""
end
defp 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)
end
defp persisted_form(post, metadata, active_language, translations) do
canonical_language = canonical_language(post, metadata)
translation = Map.get(translations, active_language)
if active_language == canonical_language do
%{
"title" => post.title || "",
"excerpt" => post.excerpt || "",
"content" => post.content || "",
"tags" => Enum.join(post.tags || [], ", "),
"categories" => Enum.join(post.categories || [], ", "),
"author" => post.author || metadata.default_author || "",
"language" => canonical_language,
"do_not_translate" => post.do_not_translate || false,
"template_slug" => post.template_slug || ""
}
else
%{
"title" => translation && translation.title || "",
"excerpt" => translation && translation.excerpt || "",
"content" => translation && translation.content || "",
"tags" => Enum.join(post.tags || [], ", "),
"categories" => Enum.join(post.categories || [], ", "),
"author" => post.author || metadata.default_author || "",
"language" => active_language,
"do_not_translate" => post.do_not_translate || false,
"template_slug" => post.template_slug || ""
}
end
end
defp canonical_language(post, metadata) do
normalize_language(post.language, metadata.main_language || "en")
end
defp truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
defp truthy?(_value), do: false
defp blank?(value), do: blank_to_nil(value) == nil
defp blank_to_nil(value) do
value
|> to_string()
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
defp csv_to_list(value) do
value
|> to_string()
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp translations(post_id) do
{:ok, translations} = Posts.list_post_translations(post_id)
Map.new(translations, fn translation -> {translation.language, translation} end)
end
defp languages(metadata) do
(([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++ Enum.map(I18n.supported_languages(), & &1.code))
|> Enum.reject(&is_nil/1)
|> 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,
where: template.project_id == ^project_id,
order_by: [asc: template.title, asc: template.slug],
select: %{slug: template.slug, title: fragment("COALESCE(?, ?)", template.title, template.slug)}
)
rescue
_error -> []
end
defp linked_media(post_id) do
case Repo.query("SELECT media_id, sort_order FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do
{:ok, %{rows: rows}} ->
Enum.map(rows, fn [media_id, sort_order] ->
case Repo.get(Media, media_id) do
%Media{} = media ->
%{
media_id: media.id,
has_thumbnail: String.starts_with?(to_string(media.mime_type || ""), "image/"),
name: media.title || media.original_name || media.id,
sort_order: sort_order || 0
}
_other ->
nil
end
end)
|> Enum.reject(&is_nil/1)
_other ->
[]
end
rescue
_error -> []
end
defp post_links(post_id) do
%{
backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id),
outlinks: related_posts(PostLinks.list_outgoing_links(post_id), :target_post_id)
}
end
defp related_posts(links, key) do
Enum.map(links, fn link ->
case Repo.get(Post, Map.fetch!(link, key)) do
%Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id}
_other -> nil
end
end)
|> Enum.reject(&is_nil/1)
end
defp translation_flags(post, canonical_language, active_language, translations) do
canonical = %{language: canonical_language, flag: I18n.flag(canonical_language), status: Atom.to_string(post.status || :draft), active: active_language == canonical_language, label: canonical_language}
others =
translations
|> Map.values()
|> Enum.sort_by(& &1.language)
|> Enum.map(fn translation ->
%{
language: translation.language,
flag: I18n.flag(translation.language),
status: Atom.to_string(translation.status || :draft),
active: active_language == translation.language,
label: translation.language
}
end)
[canonical | others]
end
defp footer(post, translation, active_language, canonical_language) do
if active_language == canonical_language do
%{
created_at: format_timestamp(post.created_at),
updated_at: format_timestamp(post.updated_at),
published_at: format_timestamp(post.published_at)
}
else
%{
created_at: format_timestamp(translation && translation.created_at || post.created_at),
updated_at: format_timestamp(translation && translation.updated_at || post.updated_at),
published_at: format_timestamp(translation && translation.published_at)
}
end
end
defp format_timestamp(nil), do: ""
defp format_timestamp(timestamp) do
timestamp
|> DateTime.from_unix!(:millisecond)
|> Calendar.strftime("%x")
end
defp display_title(title, slug, fallback_id) 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")
else
translated("Translation: %{language}", %{language: String.upcase(active_language)})
end
end
defp save_canonical_draft(%Post{id: post_id}, draft) do
Posts.update_post(post_id, %{
title: blank_to_nil(Map.get(draft, "title")),
excerpt: blank_to_nil(Map.get(draft, "excerpt")),
content: blank_to_nil(Map.get(draft, "content")),
tags: csv_to_list(Map.get(draft, "tags")),
categories: csv_to_list(Map.get(draft, "categories")),
author: blank_to_nil(Map.get(draft, "author")),
language: blank_to_nil(Map.get(draft, "language")),
do_not_translate: Map.get(draft, "do_not_translate", false),
template_slug: blank_to_nil(Map.get(draft, "template_slug"))
})
end
defp save_translation_draft(post_id, language, draft) do
Posts.upsert_post_translation(post_id, language, %{
title: Map.get(draft, "title", ""),
excerpt: blank_to_nil(Map.get(draft, "excerpt")),
content: blank_to_nil(Map.get(draft, "content"))
})
end
defp maybe_publish_post({:ok, %Post{}}, post_id, :publish), do: Posts.publish_post(post_id)
defp maybe_publish_post(result, _post_id, _action), do: result
defp maybe_publish_translation({:ok, _translation}, post_id, language, :publish), do: Posts.publish_post_translation(post_id, language)
defp maybe_publish_translation(result, _post_id, _language, _action), do: result
end

View File

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