Compare commits
2 Commits
52857f2959
...
f76e48e409
| Author | SHA1 | Date | |
|---|---|---|---|
| f76e48e409 | |||
| 7463875b81 |
16
CODESMELL.md
16
CODESMELL.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`).
|
Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`).
|
||||||
|
|
||||||
Last refreshed: 2026-05-04.
|
Last refreshed: 2026-05-06.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,8 +14,6 @@ Last refreshed: 2026-05-04.
|
|||||||
|
|
||||||
| # | Module | Current lines | Target | Strategy |
|
| # | Module | Current lines | Target | Strategy |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 5 | `BDS.Desktop.ShellLive.MenuEditor` | 871 | ≤ 350 | Extract `TreeOps` (~280), `TreePredicates` (~60), `DraftManagement` (~140), `PageCategory` (~120), `State` (~80). |
|
|
||||||
| 6 | `BDS.Desktop.ShellLive.PostEditor` | 963 | ≤ 400 | Extract `DraftManagement` (~180), `ListValues` (~160), `Persistence` (~140), `PostMetadata` (~150). |
|
|
||||||
| 7 | `BDS.Desktop.ShellLive.SettingsEditor` | 872 | ≤ 350 | Extract `ProjectSettings` (~140), `AISettings` (~150), `PublishingSettings` (~80), `ManagedCategories` (~140), `StyleEditor` (~80), `MCPConfig` (~60). |
|
| 7 | `BDS.Desktop.ShellLive.SettingsEditor` | 872 | ≤ 350 | Extract `ProjectSettings` (~140), `AISettings` (~150), `PublishingSettings` (~80), `ManagedCategories` (~140), `StyleEditor` (~80), `MCPConfig` (~60). |
|
||||||
| 8 | `BDS.Desktop.ShellLive.ChatEditor` | 972 | ≤ 400 | Extract `ToolSurfaces` (~280), `ToolTracking` (~140), `MessageBuild` (~160), `ModelSelection` (~100). Defer — highest internal coupling. |
|
| 8 | `BDS.Desktop.ShellLive.ChatEditor` | 972 | ≤ 400 | Extract `ToolSurfaces` (~280), `ToolTracking` (~140), `MessageBuild` (~160), `ModelSelection` (~100). Defer — highest internal coupling. |
|
||||||
| 9 | `BDS.MCP` | 677 | ≤ 350 | Split tools / resources / proposals / serialization clusters. (Carried over from original priority list.) |
|
| 9 | `BDS.MCP` | 677 | ≤ 350 | Split tools / resources / proposals / serialization clusters. (Carried over from original priority list.) |
|
||||||
@@ -33,6 +31,8 @@ Last refreshed: 2026-05-04.
|
|||||||
- `BDS.Media` 993 → 324 (67 %)
|
- `BDS.Media` 993 → 324 (67 %)
|
||||||
- `BDS.Desktop.ShellLive.ImportEditor` 1436 → 776 (46 %)
|
- `BDS.Desktop.ShellLive.ImportEditor` 1436 → 776 (46 %)
|
||||||
- `BDS.Rendering` 838 → 33 (96 %)
|
- `BDS.Rendering` 838 → 33 (96 %)
|
||||||
|
- `BDS.Desktop.ShellLive.MenuEditor` 871 → 335 (62 %)
|
||||||
|
- `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -166,6 +166,16 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search`
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### 2026-05-06
|
||||||
|
|
||||||
|
- **God modules**:
|
||||||
|
- `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %). Submodules under `lib/bds/desktop/shell_live/post_editor/`: `PostMetadata` (190, project_metadata + canonical_language + translations + languages + template_options + linked_media + post_links + translation_flags + footer + format_timestamp + display_title + gallery_count + preview_url + canonical_preview_path + pad2 + maybe_put_query + truthy? + blank? + blank_to_nil), `ListValues` (125, field_key + tag_values/category_values + tag_suggestions/category_suggestions + filter_suggestions + tag_chips + query_addable? + normalize_query + normalize_list_entry + ensure_list_value + csv_to_list + tag_chip_style + normalize_color + contrast_color + ai_overlay_fields), `DraftManagement` (183, normalize_mode + normalize_language + normalize_params + current_draft + persisted_form/3,4 + maybe_update_draft + put_draft_field + put_query_state + query_value + query_key + maybe_drop_old_language_draft + toggled_sections + put_nested_map + delete_nested_map + reload_with_assigned_workbench + save_state_for_action + record_title + record_status + editing_canonical_language?), `Persistence` (105, persist + discard + has_published_version? + discard_label + discard_title + save_canonical_draft + save_translation_draft + maybe_publish_post + maybe_publish_translation). Coordinator keeps the 16 public event handlers (assign_socket, update, persist_socket, discard_socket, delete_socket, set_mode, toggle_section, select_language, toggle_quick_actions, detect_language, translate, apply_ai_suggestions, insert_content, add_list_value, remove_list_value), build/1,2, and the HEEx-callable helpers (`translated/1,2`, `post_status_label/1`, `post_editor_save_state_label/1`, `post_editor_mode_label/1`); `tag_chip_style/1` is exposed via `defdelegate` so HEEx call sites stay unchanged. Cross-submodule deps form a runtime cycle between PostMetadata.canonical_language → DraftManagement.normalize_language and DraftManagement.persisted_form → PostMetadata.translations/canonical_language (compile-safe, no compile cycle). Persistence → DraftManagement + PostMetadata; ListValues is a leaf. Each submodule that needs it duplicates the small `translated/2`, `blank_to_nil/1`, `csv_to_list/1` helpers locally per the established convention. Submodules use `Phoenix.Component.assign/3` directly (only DraftManagement needs it). The 400-line target was not reachable while keeping all 16 public event handlers + build + HEEx helpers in the coordinator. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (0 errors), `mix test` (342 tests, 0 failures, 4 skipped).
|
||||||
|
|
||||||
|
### 2026-05-05
|
||||||
|
|
||||||
|
- **God modules**:
|
||||||
|
- `BDS.Desktop.ShellLive.MenuEditor` 871 → 335 (62 %). Submodules under `lib/bds/desktop/shell_live/menu_editor/`: `TreeOps` (296, home_item/home_item_id + ui_item + persisted_item + first_item_id + insert_target + path_prefix? + find_path + item_at_path + items_at_path + replace_items_at_path + update_item + insert_item + remove_item + remove_item_with_value + append_child + move_selected + indent_selected + unindent_selected + delete_selected + drop_selected + insert_dropped_item), `TreePredicates` (55, can_move_up?/can_move_down?/can_indent?/can_unindent?/can_delete? + draft_item?), `DraftManagement` (132, current_draft + start_page_draft + start_category_draft + finalize_submenu_draft + assign_page_to_draft + assign_category_to_draft + cancel_draft + confirm_category_draft), `PageCategory` (58, page_posts + page_post + filter_page_posts + category_options + filter_categories + blank_to_nil), `State` (85, ensure_state + update_state + build + save + load_state). Coordinator keeps the 11 public event handlers (assign_socket, select_item, change_entry, submit_entry, cancel_entry, select_page, select_category, toolbar_action, drop_item, handle_keydown), the 3 HEEx components (menu_editor, menu_tree_level, kind_icon), and the render-time helpers (translated, row_label, kind_label, editing_title/hint/placeholder); `draft_item?/2` is exposed via `defdelegate` so HEEx call sites stay unchanged. Cross-submodule deps are linear: State → PageCategory + TreeOps + TreePredicates; DraftManagement → PageCategory + TreeOps; TreePredicates → TreeOps; PageCategory and TreeOps are leaves. `confirm_category_draft/2` takes the State.update_state function as an argument to avoid a cycle. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (0 errors), `mix test` (342 tests, 0 failures, 4 skipped).
|
||||||
|
|
||||||
### 2026-05-04
|
### 2026-05-04
|
||||||
|
|
||||||
- **God modules**:
|
- **God modules**:
|
||||||
|
|||||||
@@ -3,21 +3,22 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
|
|
||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
|
|
||||||
import Ecto.Query
|
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
alias BDS.{Menu, Metadata, Repo}
|
alias BDS.Desktop.ShellLive.MenuEditor.{
|
||||||
alias BDS.Posts.Post
|
DraftManagement,
|
||||||
|
PageCategory,
|
||||||
|
State,
|
||||||
|
TreeOps,
|
||||||
|
TreePredicates
|
||||||
|
}
|
||||||
|
|
||||||
embed_templates "menu_editor_html/*"
|
embed_templates "menu_editor_html/*"
|
||||||
|
|
||||||
@home_item_id "menu-home"
|
|
||||||
|
|
||||||
def assign_socket(socket) do
|
def assign_socket(socket) do
|
||||||
case socket.assigns[:current_tab] do
|
case socket.assigns[:current_tab] do
|
||||||
%{type: :menu_editor, id: tab_id} ->
|
%{type: :menu_editor, id: tab_id} ->
|
||||||
state = ensure_state(socket.assigns)
|
state = State.ensure_state(socket.assigns)
|
||||||
menu_editor = build(socket.assigns, state)
|
menu_editor = State.build(socket.assigns, state)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:menu_editor_state, state)
|
|> assign(:menu_editor_state, state)
|
||||||
@@ -37,7 +38,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
|
|
||||||
def select_item(socket, item_id, reload) do
|
def select_item(socket, item_id, reload) do
|
||||||
socket
|
socket
|
||||||
|> update_state(fn state -> %{state | selected_id: item_id} end)
|
|> State.update_state(fn state -> %{state | selected_id: item_id} end)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -45,20 +46,20 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
query = Map.get(params, "query", "")
|
query = Map.get(params, "query", "")
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> update_state(fn state -> put_in(state, [:draft, :query], query) end)
|
|> State.update_state(fn state -> put_in(state, [:draft, :query], query) end)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
|
|
||||||
def submit_entry(socket, reload) do
|
def submit_entry(socket, reload) do
|
||||||
case current_draft(socket.assigns) do
|
case DraftManagement.current_draft(socket.assigns) do
|
||||||
%{type: :page} ->
|
%{type: :page} ->
|
||||||
socket
|
socket
|
||||||
|> update_state(&finalize_submenu_draft/1)
|
|> State.update_state(&DraftManagement.finalize_submenu_draft/1)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
%{type: :category} ->
|
%{type: :category} ->
|
||||||
socket
|
socket
|
||||||
|> confirm_category_draft()
|
|> DraftManagement.confirm_category_draft(&State.update_state/2)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
@@ -68,17 +69,18 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
|
|
||||||
def cancel_entry(socket, reload) do
|
def cancel_entry(socket, reload) do
|
||||||
socket
|
socket
|
||||||
|> update_state(&cancel_draft/1)
|
|> State.update_state(&DraftManagement.cancel_draft/1)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
|
|
||||||
def select_page(socket, post_id, reload) do
|
def select_page(socket, post_id, reload) do
|
||||||
case page_post(socket.assigns.projects.active_project_id, post_id) do
|
case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do
|
||||||
nil -> reload.(socket, socket.assigns.workbench)
|
nil ->
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
post ->
|
post ->
|
||||||
socket
|
socket
|
||||||
|> update_state(&assign_page_to_draft(&1, post))
|
|> State.update_state(&DraftManagement.assign_page_to_draft(&1, post))
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -86,12 +88,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
def select_category(socket, name, reload) do
|
def select_category(socket, name, reload) do
|
||||||
project_id = socket.assigns.projects.active_project_id
|
project_id = socket.assigns.projects.active_project_id
|
||||||
|
|
||||||
case Enum.find(category_options(project_id), &(&1.name == name)) do
|
case Enum.find(PageCategory.category_options(project_id), &(&1.name == name)) do
|
||||||
nil -> reload.(socket, socket.assigns.workbench)
|
nil ->
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
category ->
|
category ->
|
||||||
socket
|
socket
|
||||||
|> update_state(&assign_category_to_draft(&1, category))
|
|> State.update_state(&DraftManagement.assign_category_to_draft(&1, category))
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -100,40 +103,40 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
case action do
|
case action do
|
||||||
"add-entry" ->
|
"add-entry" ->
|
||||||
socket
|
socket
|
||||||
|> update_state(&start_page_draft/1)
|
|> State.update_state(&DraftManagement.start_page_draft/1)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
"add-category-archive" ->
|
"add-category-archive" ->
|
||||||
socket
|
socket
|
||||||
|> update_state(&start_category_draft/1)
|
|> State.update_state(&DraftManagement.start_category_draft/1)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
"save" ->
|
"save" ->
|
||||||
save(socket, reload, append_output)
|
State.save(socket, reload, append_output)
|
||||||
|
|
||||||
"move-up" ->
|
"move-up" ->
|
||||||
socket
|
socket
|
||||||
|> update_state(&move_selected(&1, :up))
|
|> State.update_state(&TreeOps.move_selected(&1, :up))
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
"move-down" ->
|
"move-down" ->
|
||||||
socket
|
socket
|
||||||
|> update_state(&move_selected(&1, :down))
|
|> State.update_state(&TreeOps.move_selected(&1, :down))
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
"indent" ->
|
"indent" ->
|
||||||
socket
|
socket
|
||||||
|> update_state(&indent_selected/1)
|
|> State.update_state(&TreeOps.indent_selected/1)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
"unindent" ->
|
"unindent" ->
|
||||||
socket
|
socket
|
||||||
|> update_state(&unindent_selected/1)
|
|> State.update_state(&TreeOps.unindent_selected/1)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
"delete" ->
|
"delete" ->
|
||||||
socket
|
socket
|
||||||
|> update_state(&delete_selected/1)
|
|> State.update_state(&TreeOps.delete_selected/1)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
@@ -143,7 +146,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
|
|
||||||
def drop_item(socket, drag_item_id, target_item_id, position, reload) do
|
def drop_item(socket, drag_item_id, target_item_id, position, reload) do
|
||||||
socket
|
socket
|
||||||
|> update_state(&drop_selected(&1, drag_item_id, target_item_id, position))
|
|> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position))
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -303,7 +306,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
def translated(text, bindings \\ %{}),
|
||||||
|
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||||
|
|
||||||
def row_label(item, category_titles) do
|
def row_label(item, category_titles) do
|
||||||
if item.kind == :category_archive do
|
if item.kind == :category_archive do
|
||||||
@@ -318,9 +322,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive")
|
def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive")
|
||||||
def kind_label(:submenu), do: translated("menuEditor.type.submenu")
|
def kind_label(:submenu), do: translated("menuEditor.type.submenu")
|
||||||
|
|
||||||
def draft_item?(menu_editor, item_id) do
|
defdelegate draft_item?(menu_editor, item_id), to: TreePredicates
|
||||||
match?(%{item_id: ^item_id}, menu_editor.draft)
|
|
||||||
end
|
|
||||||
|
|
||||||
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
|
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
|
||||||
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title")
|
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title")
|
||||||
@@ -330,542 +332,4 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
|
|
||||||
def editing_placeholder(%{draft: %{type: :category}}), do: translated("menuEditor.newCategoryPlaceholder")
|
def editing_placeholder(%{draft: %{type: :category}}), do: translated("menuEditor.newCategoryPlaceholder")
|
||||||
def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder")
|
def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder")
|
||||||
|
|
||||||
defp ensure_state(assigns) do
|
|
||||||
project_id = assigns.projects.active_project_id
|
|
||||||
|
|
||||||
case assigns[:menu_editor_state] do
|
|
||||||
%{project_id: ^project_id} = state -> state
|
|
||||||
_other -> load_state(project_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_state(nil) do
|
|
||||||
%{project_id: nil, items: [home_item()], selected_id: @home_item_id, draft: nil}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_state(project_id) do
|
|
||||||
{:ok, %{items: items}} = Menu.get_menu(project_id)
|
|
||||||
items = Enum.map(items, &ui_item/1)
|
|
||||||
|
|
||||||
%{
|
|
||||||
project_id: project_id,
|
|
||||||
items: items,
|
|
||||||
selected_id: first_item_id(items),
|
|
||||||
draft: nil
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build(_assigns, state) do
|
|
||||||
categories = category_options(state.project_id)
|
|
||||||
draft = state.draft
|
|
||||||
draft_query = Map.get(draft || %{}, :query, "")
|
|
||||||
|
|
||||||
%{
|
|
||||||
title: translated("menuEditor.title"),
|
|
||||||
description: translated("menuEditor.description"),
|
|
||||||
items: state.items,
|
|
||||||
selected_id: state.selected_id,
|
|
||||||
draft: draft,
|
|
||||||
draft_query: draft_query,
|
|
||||||
filtered_pages:
|
|
||||||
if(match?(%{type: :page}, draft),
|
|
||||||
do: filter_page_posts(page_posts(state.project_id), draft_query),
|
|
||||||
else: []
|
|
||||||
),
|
|
||||||
filtered_categories:
|
|
||||||
if(match?(%{type: :category}, draft),
|
|
||||||
do: filter_categories(categories, draft_query),
|
|
||||||
else: []
|
|
||||||
),
|
|
||||||
category_titles: Map.new(categories, &{&1.name, &1.title}),
|
|
||||||
can_move_up?: can_move_up?(state.items, state.selected_id),
|
|
||||||
can_move_down?: can_move_down?(state.items, state.selected_id),
|
|
||||||
can_indent?: can_indent?(state.items, state.selected_id),
|
|
||||||
can_unindent?: can_unindent?(state.items, state.selected_id),
|
|
||||||
can_delete?: can_delete?(state.selected_id),
|
|
||||||
has_items?: state.items != []
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp save(socket, reload, append_output) do
|
|
||||||
state = socket.assigns.menu_editor_state
|
|
||||||
|
|
||||||
{:ok, _menu} = Menu.update_menu(state.project_id, Enum.map(state.items, &persisted_item/1))
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> append_output.(translated("menuEditor.tabTitle"), translated("menuEditor.saved"), nil, "info")
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp confirm_category_draft(socket) do
|
|
||||||
project_id = socket.assigns.projects.active_project_id
|
|
||||||
draft = current_draft(socket.assigns)
|
|
||||||
normalized = String.trim(Map.get(draft || %{}, :query, ""))
|
|
||||||
|
|
||||||
category =
|
|
||||||
Enum.find(category_options(project_id), fn option ->
|
|
||||||
String.downcase(option.name) == String.downcase(normalized) or
|
|
||||||
String.downcase(option.title) == String.downcase(normalized)
|
|
||||||
end)
|
|
||||||
|
|
||||||
category =
|
|
||||||
cond do
|
|
||||||
category != nil -> category
|
|
||||||
normalized == "" -> %{name: "", title: ""}
|
|
||||||
true ->
|
|
||||||
{:ok, _metadata} = Metadata.add_category(project_id, normalized)
|
|
||||||
%{name: normalized, title: normalized}
|
|
||||||
end
|
|
||||||
|
|
||||||
update_state(socket, &assign_category_to_draft(&1, category))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp update_state(socket, updater) do
|
|
||||||
state = ensure_state(socket.assigns)
|
|
||||||
assign(socket, :menu_editor_state, updater.(state))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp start_page_draft(state) do
|
|
||||||
item = %{item_id: Ecto.UUID.generate(), kind: :page, label: translated("menuEditor.newPage"), slug: nil, children: [], is_home: false}
|
|
||||||
{parent_path, index} = insert_target(state.items, state.selected_id)
|
|
||||||
items = insert_item(state.items, parent_path, index, item)
|
|
||||||
%{state | items: items, selected_id: item.item_id, draft: %{item_id: item.item_id, type: :page, query: ""}}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp start_category_draft(state) do
|
|
||||||
item = %{item_id: Ecto.UUID.generate(), kind: :category_archive, label: "", slug: nil, children: [], is_home: false}
|
|
||||||
{parent_path, index} = insert_target(state.items, state.selected_id)
|
|
||||||
items = insert_item(state.items, parent_path, index, item)
|
|
||||||
%{state | items: items, selected_id: item.item_id, draft: %{item_id: item.item_id, type: :category, query: ""}}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do
|
|
||||||
label = if(String.trim(query) == "", do: translated("menuEditor.newSubmenu"), else: String.trim(query))
|
|
||||||
|
|
||||||
%{state | items: update_item(state.items, item_id, fn item -> %{item | kind: :submenu, label: label, slug: nil, children: item.children || []} end), draft: nil}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp finalize_submenu_draft(state), do: state
|
|
||||||
|
|
||||||
defp assign_page_to_draft(%{draft: %{item_id: item_id}} = state, post) do
|
|
||||||
%{
|
|
||||||
state
|
|
||||||
| items:
|
|
||||||
update_item(state.items, item_id, fn item ->
|
|
||||||
%{item | kind: :page, label: post.title, slug: blank_to_nil(post.slug), children: []}
|
|
||||||
end),
|
|
||||||
draft: nil
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp assign_page_to_draft(state, _post), do: state
|
|
||||||
|
|
||||||
defp assign_category_to_draft(%{draft: %{item_id: item_id}} = state, category) do
|
|
||||||
label = blank_to_nil(category.title) || category.name
|
|
||||||
|
|
||||||
%{
|
|
||||||
state
|
|
||||||
| items:
|
|
||||||
update_item(state.items, item_id, fn item ->
|
|
||||||
%{item | kind: :category_archive, label: label, slug: category.name, children: []}
|
|
||||||
end),
|
|
||||||
draft: nil
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp assign_category_to_draft(state, _category), do: state
|
|
||||||
|
|
||||||
defp cancel_draft(%{draft: %{item_id: item_id}} = state) do
|
|
||||||
items = remove_item(state.items, item_id)
|
|
||||||
%{state | items: items, selected_id: first_item_id(items), draft: nil}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp cancel_draft(state), do: state
|
|
||||||
|
|
||||||
defp move_selected(%{selected_id: selected_id} = state, direction) when direction in [:up, :down] do
|
|
||||||
case find_path(state.items, selected_id) do
|
|
||||||
nil -> state
|
|
||||||
[] -> state
|
|
||||||
path ->
|
|
||||||
parent_path = Enum.drop(path, -1)
|
|
||||||
index = List.last(path)
|
|
||||||
siblings = items_at_path(state.items, parent_path)
|
|
||||||
delta = if(direction == :up, do: -1, else: 1)
|
|
||||||
target_index = index + delta
|
|
||||||
|
|
||||||
if target_index < 0 or target_index >= length(siblings) do
|
|
||||||
state
|
|
||||||
else
|
|
||||||
reordered =
|
|
||||||
List.pop_at(siblings, index)
|
|
||||||
|> then(fn {item, rest} -> List.insert_at(rest, target_index, item) end)
|
|
||||||
|
|
||||||
%{state | items: replace_items_at_path(state.items, parent_path, reordered)}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp indent_selected(%{selected_id: selected_id} = state) do
|
|
||||||
case find_path(state.items, selected_id) do
|
|
||||||
nil -> state
|
|
||||||
[] -> state
|
|
||||||
path ->
|
|
||||||
parent_path = Enum.drop(path, -1)
|
|
||||||
index = List.last(path)
|
|
||||||
|
|
||||||
cond do
|
|
||||||
index <= 0 ->
|
|
||||||
state
|
|
||||||
|
|
||||||
true ->
|
|
||||||
previous_sibling_path = parent_path ++ [index - 1]
|
|
||||||
|
|
||||||
case item_at_path(state.items, previous_sibling_path) do
|
|
||||||
%{kind: :submenu, item_id: sibling_id} ->
|
|
||||||
case remove_item_with_value(state.items, selected_id) do
|
|
||||||
{_next_items, nil} -> state
|
|
||||||
{next_items, removed_item} ->
|
|
||||||
%{
|
|
||||||
state
|
|
||||||
| items: append_child(next_items, sibling_id, removed_item)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
state
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp unindent_selected(%{selected_id: selected_id} = state) do
|
|
||||||
case find_path(state.items, selected_id) do
|
|
||||||
nil -> state
|
|
||||||
[] -> state
|
|
||||||
[_root_index] -> state
|
|
||||||
path ->
|
|
||||||
parent_path = Enum.drop(path, -1)
|
|
||||||
parent_index = List.last(parent_path)
|
|
||||||
grand_parent_path = Enum.drop(parent_path, -1)
|
|
||||||
|
|
||||||
case remove_item_with_value(state.items, selected_id) do
|
|
||||||
{_next_items, nil} -> state
|
|
||||||
{next_items, removed_item} ->
|
|
||||||
%{
|
|
||||||
state
|
|
||||||
| items: insert_item(next_items, grand_parent_path, parent_index + 1, removed_item)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp delete_selected(%{selected_id: @home_item_id} = state), do: state
|
|
||||||
|
|
||||||
defp delete_selected(%{selected_id: selected_id} = state) do
|
|
||||||
items = remove_item(state.items, selected_id)
|
|
||||||
%{state | items: items, selected_id: first_item_id(items), draft: nil}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp drop_selected(state, drag_item_id, target_item_id, _position)
|
|
||||||
when drag_item_id in [nil, ""] or target_item_id in [nil, ""] do
|
|
||||||
state
|
|
||||||
end
|
|
||||||
|
|
||||||
defp drop_selected(state, drag_item_id, target_item_id, _position) when drag_item_id == target_item_id,
|
|
||||||
do: state
|
|
||||||
|
|
||||||
defp drop_selected(state, drag_item_id, target_item_id, position) do
|
|
||||||
drag_path = find_path(state.items, drag_item_id)
|
|
||||||
target_path = find_path(state.items, target_item_id)
|
|
||||||
|
|
||||||
cond do
|
|
||||||
is_nil(drag_path) or is_nil(target_path) ->
|
|
||||||
state
|
|
||||||
|
|
||||||
path_prefix?(drag_path, target_path) ->
|
|
||||||
state
|
|
||||||
|
|
||||||
true ->
|
|
||||||
case remove_item_with_value(state.items, drag_item_id) do
|
|
||||||
{_next_items, nil} ->
|
|
||||||
state
|
|
||||||
|
|
||||||
{next_items, dragged_item} ->
|
|
||||||
case find_path(next_items, target_item_id) do
|
|
||||||
nil ->
|
|
||||||
state
|
|
||||||
|
|
||||||
next_target_path ->
|
|
||||||
insert_dropped_item(state, next_items, dragged_item, next_target_path, position)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp insert_dropped_item(state, next_items, dragged_item, target_path, "inside") do
|
|
||||||
case item_at_path(next_items, target_path) do
|
|
||||||
%{kind: :submenu} ->
|
|
||||||
%{state | items: insert_item(next_items, target_path, 0, dragged_item), selected_id: dragged_item.item_id}
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
state
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp insert_dropped_item(state, next_items, dragged_item, target_path, "before") do
|
|
||||||
parent_path = Enum.drop(target_path, -1)
|
|
||||||
index = List.last(target_path)
|
|
||||||
%{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp insert_dropped_item(state, next_items, dragged_item, target_path, _position) do
|
|
||||||
parent_path = Enum.drop(target_path, -1)
|
|
||||||
index = List.last(target_path) + 1
|
|
||||||
%{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft)
|
|
||||||
|
|
||||||
defp page_posts(nil), do: []
|
|
||||||
|
|
||||||
defp page_posts(project_id) do
|
|
||||||
Repo.all(from post in Post, where: post.project_id == ^project_id, order_by: [asc: post.title, asc: post.slug])
|
|
||||||
|> Enum.filter(&("page" in (&1.categories || [])))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp page_post(nil, _post_id), do: nil
|
|
||||||
|
|
||||||
defp page_post(project_id, post_id) do
|
|
||||||
Enum.find(page_posts(project_id), &(&1.id == post_id))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp filter_page_posts(posts, query) do
|
|
||||||
normalized = query |> to_string() |> String.trim() |> String.downcase()
|
|
||||||
|
|
||||||
Enum.filter(posts, fn post ->
|
|
||||||
normalized == "" or
|
|
||||||
String.contains?(String.downcase(post.title || ""), normalized) or
|
|
||||||
String.contains?(String.downcase(post.slug || ""), normalized)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp category_options(nil), do: []
|
|
||||||
|
|
||||||
defp category_options(project_id) do
|
|
||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
|
||||||
|
|
||||||
Enum.map(metadata.categories || [], fn name ->
|
|
||||||
title = get_in(metadata.category_settings || %{}, [name, "title"])
|
|
||||||
%{name: name, title: blank_to_nil(title) || name}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp filter_categories(categories, query) do
|
|
||||||
normalized = query |> to_string() |> String.trim() |> String.downcase()
|
|
||||||
|
|
||||||
Enum.filter(categories, fn category ->
|
|
||||||
normalized == "" or
|
|
||||||
String.contains?(String.downcase(category.name), normalized) or
|
|
||||||
String.contains?(String.downcase(category.title), normalized)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp can_move_up?(items, selected_id) do
|
|
||||||
case find_path(items, selected_id) do
|
|
||||||
[_parent, index] -> index > 0
|
|
||||||
[index] -> index > 0
|
|
||||||
path when is_list(path) -> List.last(path) > 0
|
|
||||||
_other -> false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp can_move_down?(items, selected_id) do
|
|
||||||
case find_path(items, selected_id) do
|
|
||||||
nil -> false
|
|
||||||
path ->
|
|
||||||
parent_path = Enum.drop(path, -1)
|
|
||||||
index = List.last(path)
|
|
||||||
index < length(items_at_path(items, parent_path)) - 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp can_indent?(items, selected_id) do
|
|
||||||
case find_path(items, selected_id) do
|
|
||||||
nil -> false
|
|
||||||
[] -> false
|
|
||||||
[_index] = path ->
|
|
||||||
index = List.last(path)
|
|
||||||
index > 0 and match?(%{kind: :submenu}, item_at_path(items, [index - 1]))
|
|
||||||
|
|
||||||
path ->
|
|
||||||
index = List.last(path)
|
|
||||||
|
|
||||||
index > 0 and
|
|
||||||
match?(%{kind: :submenu}, item_at_path(items, Enum.drop(path, -1) ++ [index - 1]))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp can_unindent?(items, selected_id) do
|
|
||||||
case find_path(items, selected_id) do
|
|
||||||
[_index] -> false
|
|
||||||
path when is_list(path) -> length(path) > 1
|
|
||||||
_other -> false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp can_delete?(selected_id), do: is_binary(selected_id) and selected_id != @home_item_id
|
|
||||||
|
|
||||||
defp home_item do
|
|
||||||
%{item_id: @home_item_id, kind: :home, label: "Home", slug: nil, children: [], is_home: true}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp ui_item(%{kind: :home}), do: home_item()
|
|
||||||
|
|
||||||
defp ui_item(item) do
|
|
||||||
kind = Map.get(item, :kind, :page)
|
|
||||||
|
|
||||||
%{
|
|
||||||
item_id: Ecto.UUID.generate(),
|
|
||||||
kind: kind,
|
|
||||||
label: Map.get(item, :label, ""),
|
|
||||||
slug: Map.get(item, :slug),
|
|
||||||
children: Enum.map(Map.get(item, :children, []), &ui_item/1),
|
|
||||||
is_home: false
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp persisted_item(%{kind: :home}), do: %{kind: :home, label: "Home", slug: nil}
|
|
||||||
|
|
||||||
defp persisted_item(%{kind: :submenu} = item) do
|
|
||||||
%{kind: :submenu, label: item.label, slug: nil, children: Enum.map(item.children || [], &persisted_item/1)}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp persisted_item(item) do
|
|
||||||
%{kind: item.kind, label: item.label, slug: item.slug}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp insert_target(items, nil), do: {[], length(items)}
|
|
||||||
|
|
||||||
defp insert_target(items, selected_id) do
|
|
||||||
case find_path(items, selected_id) do
|
|
||||||
nil -> {[], length(items)}
|
|
||||||
[] -> {[], length(items)}
|
|
||||||
path ->
|
|
||||||
case item_at_path(items, path) do
|
|
||||||
%{kind: :submenu} -> {path, 0}
|
|
||||||
_other -> {Enum.drop(path, -1), List.last(path) + 1}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp first_item_id([item | _rest]), do: item.item_id
|
|
||||||
defp first_item_id([]), do: nil
|
|
||||||
|
|
||||||
defp blank_to_nil(nil), do: nil
|
|
||||||
defp blank_to_nil(value) do
|
|
||||||
trimmed = String.trim(to_string(value))
|
|
||||||
if trimmed == "", do: nil, else: trimmed
|
|
||||||
end
|
|
||||||
|
|
||||||
defp path_prefix?(prefix, path) when length(prefix) > length(path), do: false
|
|
||||||
defp path_prefix?(prefix, path), do: Enum.take(path, length(prefix)) == prefix
|
|
||||||
|
|
||||||
defp find_path(items, item_id, path \\ []) do
|
|
||||||
Enum.find_value(Enum.with_index(items), fn {item, index} ->
|
|
||||||
next_path = path ++ [index]
|
|
||||||
|
|
||||||
cond do
|
|
||||||
item.item_id == item_id ->
|
|
||||||
next_path
|
|
||||||
|
|
||||||
item.children != [] ->
|
|
||||||
find_path(item.children, item_id, next_path)
|
|
||||||
|
|
||||||
true ->
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp item_at_path(_items, []), do: nil
|
|
||||||
|
|
||||||
defp item_at_path(items, [index]) do
|
|
||||||
Enum.at(items, index)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp item_at_path(items, [index | rest]) do
|
|
||||||
case Enum.at(items, index) do
|
|
||||||
nil -> nil
|
|
||||||
item -> item_at_path(item.children || [], rest)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp items_at_path(items, []), do: items
|
|
||||||
|
|
||||||
defp items_at_path(items, [index | rest]) do
|
|
||||||
case Enum.at(items, index) do
|
|
||||||
nil -> []
|
|
||||||
item -> items_at_path(item.children || [], rest)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp replace_items_at_path(_items, [], replacement), do: replacement
|
|
||||||
|
|
||||||
defp replace_items_at_path(items, [index | rest], replacement) do
|
|
||||||
List.update_at(items, index, fn item ->
|
|
||||||
%{item | children: replace_items_at_path(item.children || [], rest, replacement)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp update_item(items, item_id, updater) do
|
|
||||||
Enum.map(items, fn item ->
|
|
||||||
cond do
|
|
||||||
item.item_id == item_id -> updater.(item)
|
|
||||||
item.children != [] -> %{item | children: update_item(item.children, item_id, updater)}
|
|
||||||
true -> item
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp insert_item(items, [], index, item) do
|
|
||||||
List.insert_at(items, index, item)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp insert_item(items, [head | tail], index, item) do
|
|
||||||
List.update_at(items, head, fn current ->
|
|
||||||
%{current | children: insert_item(current.children || [], tail, index, item)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp remove_item(items, item_id) do
|
|
||||||
remove_item_with_value(items, item_id) |> elem(0)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp remove_item_with_value(items, item_id) do
|
|
||||||
Enum.reduce_while(Enum.with_index(items), {items, nil}, fn {item, index}, _acc ->
|
|
||||||
cond do
|
|
||||||
item.item_id == item_id ->
|
|
||||||
{:halt, {List.delete_at(items, index), item}}
|
|
||||||
|
|
||||||
item.children != [] ->
|
|
||||||
{next_children, removed_item} = remove_item_with_value(item.children, item_id)
|
|
||||||
|
|
||||||
if removed_item do
|
|
||||||
{:halt, {List.replace_at(items, index, %{item | children: next_children}), removed_item}}
|
|
||||||
else
|
|
||||||
{:cont, {items, nil}}
|
|
||||||
end
|
|
||||||
|
|
||||||
true ->
|
|
||||||
{:cont, {items, nil}}
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp append_child(items, parent_item_id, child) do
|
|
||||||
update_item(items, parent_item_id, fn item ->
|
|
||||||
%{item | children: (item.children || []) ++ [child]}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
132
lib/bds/desktop/shell_live/menu_editor/draft_management.ex
Normal file
132
lib/bds/desktop/shell_live/menu_editor/draft_management.ex
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellData
|
||||||
|
alias BDS.Metadata
|
||||||
|
alias BDS.Desktop.ShellLive.MenuEditor.PageCategory
|
||||||
|
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
|
||||||
|
|
||||||
|
def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft)
|
||||||
|
|
||||||
|
def start_page_draft(state) do
|
||||||
|
item = %{
|
||||||
|
item_id: Ecto.UUID.generate(),
|
||||||
|
kind: :page,
|
||||||
|
label: translated("menuEditor.newPage"),
|
||||||
|
slug: nil,
|
||||||
|
children: [],
|
||||||
|
is_home: false
|
||||||
|
}
|
||||||
|
|
||||||
|
{parent_path, index} = TreeOps.insert_target(state.items, state.selected_id)
|
||||||
|
items = TreeOps.insert_item(state.items, parent_path, index, item)
|
||||||
|
|
||||||
|
%{
|
||||||
|
state
|
||||||
|
| items: items,
|
||||||
|
selected_id: item.item_id,
|
||||||
|
draft: %{item_id: item.item_id, type: :page, query: ""}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_category_draft(state) do
|
||||||
|
item = %{
|
||||||
|
item_id: Ecto.UUID.generate(),
|
||||||
|
kind: :category_archive,
|
||||||
|
label: "",
|
||||||
|
slug: nil,
|
||||||
|
children: [],
|
||||||
|
is_home: false
|
||||||
|
}
|
||||||
|
|
||||||
|
{parent_path, index} = TreeOps.insert_target(state.items, state.selected_id)
|
||||||
|
items = TreeOps.insert_item(state.items, parent_path, index, item)
|
||||||
|
|
||||||
|
%{
|
||||||
|
state
|
||||||
|
| items: items,
|
||||||
|
selected_id: item.item_id,
|
||||||
|
draft: %{item_id: item.item_id, type: :category, query: ""}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do
|
||||||
|
label =
|
||||||
|
if(String.trim(query) == "",
|
||||||
|
do: translated("menuEditor.newSubmenu"),
|
||||||
|
else: String.trim(query)
|
||||||
|
)
|
||||||
|
|
||||||
|
%{
|
||||||
|
state
|
||||||
|
| items:
|
||||||
|
TreeOps.update_item(state.items, item_id, fn item ->
|
||||||
|
%{item | kind: :submenu, label: label, slug: nil, children: item.children || []}
|
||||||
|
end),
|
||||||
|
draft: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def finalize_submenu_draft(state), do: state
|
||||||
|
|
||||||
|
def assign_page_to_draft(%{draft: %{item_id: item_id}} = state, post) do
|
||||||
|
%{
|
||||||
|
state
|
||||||
|
| items:
|
||||||
|
TreeOps.update_item(state.items, item_id, fn item ->
|
||||||
|
%{item | kind: :page, label: post.title, slug: PageCategory.blank_to_nil(post.slug), children: []}
|
||||||
|
end),
|
||||||
|
draft: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_page_to_draft(state, _post), do: state
|
||||||
|
|
||||||
|
def assign_category_to_draft(%{draft: %{item_id: item_id}} = state, category) do
|
||||||
|
label = PageCategory.blank_to_nil(category.title) || category.name
|
||||||
|
|
||||||
|
%{
|
||||||
|
state
|
||||||
|
| items:
|
||||||
|
TreeOps.update_item(state.items, item_id, fn item ->
|
||||||
|
%{item | kind: :category_archive, label: label, slug: category.name, children: []}
|
||||||
|
end),
|
||||||
|
draft: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_category_to_draft(state, _category), do: state
|
||||||
|
|
||||||
|
def cancel_draft(%{draft: %{item_id: item_id}} = state) do
|
||||||
|
items = TreeOps.remove_item(state.items, item_id)
|
||||||
|
%{state | items: items, selected_id: TreeOps.first_item_id(items), draft: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel_draft(state), do: state
|
||||||
|
|
||||||
|
def confirm_category_draft(socket, update_state_fun) do
|
||||||
|
project_id = socket.assigns.projects.active_project_id
|
||||||
|
draft = current_draft(socket.assigns)
|
||||||
|
normalized = String.trim(Map.get(draft || %{}, :query, ""))
|
||||||
|
|
||||||
|
category =
|
||||||
|
Enum.find(PageCategory.category_options(project_id), fn option ->
|
||||||
|
String.downcase(option.name) == String.downcase(normalized) or
|
||||||
|
String.downcase(option.title) == String.downcase(normalized)
|
||||||
|
end)
|
||||||
|
|
||||||
|
category =
|
||||||
|
cond do
|
||||||
|
category != nil -> category
|
||||||
|
normalized == "" -> %{name: "", title: ""}
|
||||||
|
true ->
|
||||||
|
{:ok, _metadata} = Metadata.add_category(project_id, normalized)
|
||||||
|
%{name: normalized, title: normalized}
|
||||||
|
end
|
||||||
|
|
||||||
|
update_state_fun.(socket, &assign_category_to_draft(&1, category))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translated(text, bindings \\ %{}),
|
||||||
|
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||||
|
end
|
||||||
58
lib/bds/desktop/shell_live/menu_editor/page_category.ex
Normal file
58
lib/bds/desktop/shell_live/menu_editor/page_category.ex
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.{Metadata, Repo}
|
||||||
|
alias BDS.Posts.Post
|
||||||
|
|
||||||
|
def page_posts(nil), do: []
|
||||||
|
|
||||||
|
def page_posts(project_id) do
|
||||||
|
Repo.all(from post in Post, where: post.project_id == ^project_id, order_by: [asc: post.title, asc: post.slug])
|
||||||
|
|> Enum.filter(&("page" in (&1.categories || [])))
|
||||||
|
end
|
||||||
|
|
||||||
|
def page_post(nil, _post_id), do: nil
|
||||||
|
|
||||||
|
def page_post(project_id, post_id) do
|
||||||
|
Enum.find(page_posts(project_id), &(&1.id == post_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_page_posts(posts, query) do
|
||||||
|
normalized = query |> to_string() |> String.trim() |> String.downcase()
|
||||||
|
|
||||||
|
Enum.filter(posts, fn post ->
|
||||||
|
normalized == "" or
|
||||||
|
String.contains?(String.downcase(post.title || ""), normalized) or
|
||||||
|
String.contains?(String.downcase(post.slug || ""), normalized)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def category_options(nil), do: []
|
||||||
|
|
||||||
|
def category_options(project_id) do
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
|
|
||||||
|
Enum.map(metadata.categories || [], fn name ->
|
||||||
|
title = get_in(metadata.category_settings || %{}, [name, "title"])
|
||||||
|
%{name: name, title: blank_to_nil(title) || name}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_categories(categories, query) do
|
||||||
|
normalized = query |> to_string() |> String.trim() |> String.downcase()
|
||||||
|
|
||||||
|
Enum.filter(categories, fn category ->
|
||||||
|
normalized == "" or
|
||||||
|
String.contains?(String.downcase(category.name), normalized) or
|
||||||
|
String.contains?(String.downcase(category.title), normalized)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def blank_to_nil(nil), do: nil
|
||||||
|
def blank_to_nil(value) do
|
||||||
|
trimmed = String.trim(to_string(value))
|
||||||
|
if trimmed == "", do: nil, else: trimmed
|
||||||
|
end
|
||||||
|
end
|
||||||
85
lib/bds/desktop/shell_live/menu_editor/state.ex
Normal file
85
lib/bds/desktop/shell_live/menu_editor/state.ex
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.MenuEditor.State do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellData
|
||||||
|
alias BDS.Menu
|
||||||
|
alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates}
|
||||||
|
|
||||||
|
def ensure_state(assigns) do
|
||||||
|
project_id = assigns.projects.active_project_id
|
||||||
|
|
||||||
|
case assigns[:menu_editor_state] do
|
||||||
|
%{project_id: ^project_id} = state -> state
|
||||||
|
_other -> load_state(project_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_state(socket, updater) do
|
||||||
|
state = ensure_state(socket.assigns)
|
||||||
|
assign(socket, :menu_editor_state, updater.(state))
|
||||||
|
end
|
||||||
|
|
||||||
|
def build(_assigns, state) do
|
||||||
|
categories = PageCategory.category_options(state.project_id)
|
||||||
|
draft = state.draft
|
||||||
|
draft_query = Map.get(draft || %{}, :query, "")
|
||||||
|
|
||||||
|
%{
|
||||||
|
title: translated("menuEditor.title"),
|
||||||
|
description: translated("menuEditor.description"),
|
||||||
|
items: state.items,
|
||||||
|
selected_id: state.selected_id,
|
||||||
|
draft: draft,
|
||||||
|
draft_query: draft_query,
|
||||||
|
filtered_pages:
|
||||||
|
if(match?(%{type: :page}, draft),
|
||||||
|
do: PageCategory.filter_page_posts(PageCategory.page_posts(state.project_id), draft_query),
|
||||||
|
else: []
|
||||||
|
),
|
||||||
|
filtered_categories:
|
||||||
|
if(match?(%{type: :category}, draft),
|
||||||
|
do: PageCategory.filter_categories(categories, draft_query),
|
||||||
|
else: []
|
||||||
|
),
|
||||||
|
category_titles: Map.new(categories, &{&1.name, &1.title}),
|
||||||
|
can_move_up?: TreePredicates.can_move_up?(state.items, state.selected_id),
|
||||||
|
can_move_down?: TreePredicates.can_move_down?(state.items, state.selected_id),
|
||||||
|
can_indent?: TreePredicates.can_indent?(state.items, state.selected_id),
|
||||||
|
can_unindent?: TreePredicates.can_unindent?(state.items, state.selected_id),
|
||||||
|
can_delete?: TreePredicates.can_delete?(state.selected_id),
|
||||||
|
has_items?: state.items != []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def save(socket, reload, append_output) do
|
||||||
|
state = socket.assigns.menu_editor_state
|
||||||
|
|
||||||
|
{:ok, _menu} =
|
||||||
|
Menu.update_menu(state.project_id, Enum.map(state.items, &TreeOps.persisted_item/1))
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> append_output.(translated("menuEditor.tabTitle"), translated("menuEditor.saved"), nil, "info")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_state(nil) do
|
||||||
|
%{project_id: nil, items: [TreeOps.home_item()], selected_id: TreeOps.home_item_id(), draft: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_state(project_id) do
|
||||||
|
{:ok, %{items: items}} = Menu.get_menu(project_id)
|
||||||
|
items = Enum.map(items, &TreeOps.ui_item/1)
|
||||||
|
|
||||||
|
%{
|
||||||
|
project_id: project_id,
|
||||||
|
items: items,
|
||||||
|
selected_id: TreeOps.first_item_id(items),
|
||||||
|
draft: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translated(text, bindings \\ %{}),
|
||||||
|
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||||
|
end
|
||||||
296
lib/bds/desktop/shell_live/menu_editor/tree_ops.ex
Normal file
296
lib/bds/desktop/shell_live/menu_editor/tree_ops.ex
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
@home_item_id "menu-home"
|
||||||
|
|
||||||
|
def home_item_id, do: @home_item_id
|
||||||
|
|
||||||
|
def home_item do
|
||||||
|
%{item_id: @home_item_id, kind: :home, label: "Home", slug: nil, children: [], is_home: true}
|
||||||
|
end
|
||||||
|
|
||||||
|
def ui_item(%{kind: :home}), do: home_item()
|
||||||
|
|
||||||
|
def ui_item(item) do
|
||||||
|
kind = Map.get(item, :kind, :page)
|
||||||
|
|
||||||
|
%{
|
||||||
|
item_id: Ecto.UUID.generate(),
|
||||||
|
kind: kind,
|
||||||
|
label: Map.get(item, :label, ""),
|
||||||
|
slug: Map.get(item, :slug),
|
||||||
|
children: Enum.map(Map.get(item, :children, []), &ui_item/1),
|
||||||
|
is_home: false
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def persisted_item(%{kind: :home}), do: %{kind: :home, label: "Home", slug: nil}
|
||||||
|
|
||||||
|
def persisted_item(%{kind: :submenu} = item) do
|
||||||
|
%{kind: :submenu, label: item.label, slug: nil, children: Enum.map(item.children || [], &persisted_item/1)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def persisted_item(item) do
|
||||||
|
%{kind: item.kind, label: item.label, slug: item.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
def first_item_id([item | _rest]), do: item.item_id
|
||||||
|
def first_item_id([]), do: nil
|
||||||
|
|
||||||
|
def insert_target(items, nil), do: {[], length(items)}
|
||||||
|
|
||||||
|
def insert_target(items, selected_id) do
|
||||||
|
case find_path(items, selected_id) do
|
||||||
|
nil -> {[], length(items)}
|
||||||
|
[] -> {[], length(items)}
|
||||||
|
path ->
|
||||||
|
case item_at_path(items, path) do
|
||||||
|
%{kind: :submenu} -> {path, 0}
|
||||||
|
_other -> {Enum.drop(path, -1), List.last(path) + 1}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def path_prefix?(prefix, path) when length(prefix) > length(path), do: false
|
||||||
|
def path_prefix?(prefix, path), do: Enum.take(path, length(prefix)) == prefix
|
||||||
|
|
||||||
|
def find_path(items, item_id, path \\ []) do
|
||||||
|
Enum.find_value(Enum.with_index(items), fn {item, index} ->
|
||||||
|
next_path = path ++ [index]
|
||||||
|
|
||||||
|
cond do
|
||||||
|
item.item_id == item_id ->
|
||||||
|
next_path
|
||||||
|
|
||||||
|
item.children != [] ->
|
||||||
|
find_path(item.children, item_id, next_path)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def item_at_path(_items, []), do: nil
|
||||||
|
|
||||||
|
def item_at_path(items, [index]) do
|
||||||
|
Enum.at(items, index)
|
||||||
|
end
|
||||||
|
|
||||||
|
def item_at_path(items, [index | rest]) do
|
||||||
|
case Enum.at(items, index) do
|
||||||
|
nil -> nil
|
||||||
|
item -> item_at_path(item.children || [], rest)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def items_at_path(items, []), do: items
|
||||||
|
|
||||||
|
def items_at_path(items, [index | rest]) do
|
||||||
|
case Enum.at(items, index) do
|
||||||
|
nil -> []
|
||||||
|
item -> items_at_path(item.children || [], rest)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def replace_items_at_path(_items, [], replacement), do: replacement
|
||||||
|
|
||||||
|
def replace_items_at_path(items, [index | rest], replacement) do
|
||||||
|
List.update_at(items, index, fn item ->
|
||||||
|
%{item | children: replace_items_at_path(item.children || [], rest, replacement)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_item(items, item_id, updater) do
|
||||||
|
Enum.map(items, fn item ->
|
||||||
|
cond do
|
||||||
|
item.item_id == item_id -> updater.(item)
|
||||||
|
item.children != [] -> %{item | children: update_item(item.children, item_id, updater)}
|
||||||
|
true -> item
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def insert_item(items, [], index, item) do
|
||||||
|
List.insert_at(items, index, item)
|
||||||
|
end
|
||||||
|
|
||||||
|
def insert_item(items, [head | tail], index, item) do
|
||||||
|
List.update_at(items, head, fn current ->
|
||||||
|
%{current | children: insert_item(current.children || [], tail, index, item)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_item(items, item_id) do
|
||||||
|
remove_item_with_value(items, item_id) |> elem(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_item_with_value(items, item_id) do
|
||||||
|
Enum.reduce_while(Enum.with_index(items), {items, nil}, fn {item, index}, _acc ->
|
||||||
|
cond do
|
||||||
|
item.item_id == item_id ->
|
||||||
|
{:halt, {List.delete_at(items, index), item}}
|
||||||
|
|
||||||
|
item.children != [] ->
|
||||||
|
{next_children, removed_item} = remove_item_with_value(item.children, item_id)
|
||||||
|
|
||||||
|
if removed_item do
|
||||||
|
{:halt, {List.replace_at(items, index, %{item | children: next_children}), removed_item}}
|
||||||
|
else
|
||||||
|
{:cont, {items, nil}}
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:cont, {items, nil}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def append_child(items, parent_item_id, child) do
|
||||||
|
update_item(items, parent_item_id, fn item ->
|
||||||
|
%{item | children: (item.children || []) ++ [child]}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def move_selected(%{selected_id: selected_id} = state, direction) when direction in [:up, :down] do
|
||||||
|
case find_path(state.items, selected_id) do
|
||||||
|
nil -> state
|
||||||
|
[] -> state
|
||||||
|
path ->
|
||||||
|
parent_path = Enum.drop(path, -1)
|
||||||
|
index = List.last(path)
|
||||||
|
siblings = items_at_path(state.items, parent_path)
|
||||||
|
delta = if(direction == :up, do: -1, else: 1)
|
||||||
|
target_index = index + delta
|
||||||
|
|
||||||
|
if target_index < 0 or target_index >= length(siblings) do
|
||||||
|
state
|
||||||
|
else
|
||||||
|
reordered =
|
||||||
|
List.pop_at(siblings, index)
|
||||||
|
|> then(fn {item, rest} -> List.insert_at(rest, target_index, item) end)
|
||||||
|
|
||||||
|
%{state | items: replace_items_at_path(state.items, parent_path, reordered)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def indent_selected(%{selected_id: selected_id} = state) do
|
||||||
|
case find_path(state.items, selected_id) do
|
||||||
|
nil -> state
|
||||||
|
[] -> state
|
||||||
|
path ->
|
||||||
|
parent_path = Enum.drop(path, -1)
|
||||||
|
index = List.last(path)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
index <= 0 ->
|
||||||
|
state
|
||||||
|
|
||||||
|
true ->
|
||||||
|
previous_sibling_path = parent_path ++ [index - 1]
|
||||||
|
|
||||||
|
case item_at_path(state.items, previous_sibling_path) do
|
||||||
|
%{kind: :submenu, item_id: sibling_id} ->
|
||||||
|
case remove_item_with_value(state.items, selected_id) do
|
||||||
|
{_next_items, nil} -> state
|
||||||
|
{next_items, removed_item} ->
|
||||||
|
%{
|
||||||
|
state
|
||||||
|
| items: append_child(next_items, sibling_id, removed_item)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def unindent_selected(%{selected_id: selected_id} = state) do
|
||||||
|
case find_path(state.items, selected_id) do
|
||||||
|
nil -> state
|
||||||
|
[] -> state
|
||||||
|
[_root_index] -> state
|
||||||
|
path ->
|
||||||
|
parent_path = Enum.drop(path, -1)
|
||||||
|
parent_index = List.last(parent_path)
|
||||||
|
grand_parent_path = Enum.drop(parent_path, -1)
|
||||||
|
|
||||||
|
case remove_item_with_value(state.items, selected_id) do
|
||||||
|
{_next_items, nil} -> state
|
||||||
|
{next_items, removed_item} ->
|
||||||
|
%{
|
||||||
|
state
|
||||||
|
| items: insert_item(next_items, grand_parent_path, parent_index + 1, removed_item)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_selected(%{selected_id: @home_item_id} = state), do: state
|
||||||
|
|
||||||
|
def delete_selected(%{selected_id: selected_id} = state) do
|
||||||
|
items = remove_item(state.items, selected_id)
|
||||||
|
%{state | items: items, selected_id: first_item_id(items), draft: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
def drop_selected(state, drag_item_id, target_item_id, _position)
|
||||||
|
when drag_item_id in [nil, ""] or target_item_id in [nil, ""] do
|
||||||
|
state
|
||||||
|
end
|
||||||
|
|
||||||
|
def drop_selected(state, drag_item_id, target_item_id, _position) when drag_item_id == target_item_id,
|
||||||
|
do: state
|
||||||
|
|
||||||
|
def drop_selected(state, drag_item_id, target_item_id, position) do
|
||||||
|
drag_path = find_path(state.items, drag_item_id)
|
||||||
|
target_path = find_path(state.items, target_item_id)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(drag_path) or is_nil(target_path) ->
|
||||||
|
state
|
||||||
|
|
||||||
|
path_prefix?(drag_path, target_path) ->
|
||||||
|
state
|
||||||
|
|
||||||
|
true ->
|
||||||
|
case remove_item_with_value(state.items, drag_item_id) do
|
||||||
|
{_next_items, nil} ->
|
||||||
|
state
|
||||||
|
|
||||||
|
{next_items, dragged_item} ->
|
||||||
|
case find_path(next_items, target_item_id) do
|
||||||
|
nil ->
|
||||||
|
state
|
||||||
|
|
||||||
|
next_target_path ->
|
||||||
|
insert_dropped_item(state, next_items, dragged_item, next_target_path, position)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp insert_dropped_item(state, next_items, dragged_item, target_path, "inside") do
|
||||||
|
case item_at_path(next_items, target_path) do
|
||||||
|
%{kind: :submenu} ->
|
||||||
|
%{state | items: insert_item(next_items, target_path, 0, dragged_item), selected_id: dragged_item.item_id}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp insert_dropped_item(state, next_items, dragged_item, target_path, "before") do
|
||||||
|
parent_path = Enum.drop(target_path, -1)
|
||||||
|
index = List.last(target_path)
|
||||||
|
%{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp insert_dropped_item(state, next_items, dragged_item, target_path, _position) do
|
||||||
|
parent_path = Enum.drop(target_path, -1)
|
||||||
|
index = List.last(target_path) + 1
|
||||||
|
%{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id}
|
||||||
|
end
|
||||||
|
end
|
||||||
55
lib/bds/desktop/shell_live/menu_editor/tree_predicates.ex
Normal file
55
lib/bds/desktop/shell_live/menu_editor/tree_predicates.ex
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
|
||||||
|
|
||||||
|
def can_move_up?(items, selected_id) do
|
||||||
|
case TreeOps.find_path(items, selected_id) do
|
||||||
|
[_parent, index] -> index > 0
|
||||||
|
[index] -> index > 0
|
||||||
|
path when is_list(path) -> List.last(path) > 0
|
||||||
|
_other -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_move_down?(items, selected_id) do
|
||||||
|
case TreeOps.find_path(items, selected_id) do
|
||||||
|
nil -> false
|
||||||
|
path ->
|
||||||
|
parent_path = Enum.drop(path, -1)
|
||||||
|
index = List.last(path)
|
||||||
|
index < length(TreeOps.items_at_path(items, parent_path)) - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_indent?(items, selected_id) do
|
||||||
|
case TreeOps.find_path(items, selected_id) do
|
||||||
|
nil -> false
|
||||||
|
[] -> false
|
||||||
|
[_index] = path ->
|
||||||
|
index = List.last(path)
|
||||||
|
index > 0 and match?(%{kind: :submenu}, TreeOps.item_at_path(items, [index - 1]))
|
||||||
|
|
||||||
|
path ->
|
||||||
|
index = List.last(path)
|
||||||
|
|
||||||
|
index > 0 and
|
||||||
|
match?(%{kind: :submenu}, TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1]))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_unindent?(items, selected_id) do
|
||||||
|
case TreeOps.find_path(items, selected_id) do
|
||||||
|
[_index] -> false
|
||||||
|
path when is_list(path) -> length(path) > 1
|
||||||
|
_other -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_delete?(selected_id),
|
||||||
|
do: is_binary(selected_id) and selected_id != TreeOps.home_item_id()
|
||||||
|
|
||||||
|
def draft_item?(menu_editor, item_id) do
|
||||||
|
match?(%{item_id: ^item_id}, menu_editor.draft)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,14 +3,77 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
|
|
||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
|
|
||||||
import Ecto.Query
|
alias BDS.{AI, Posts, Preview, Repo}
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
alias BDS.{AI, I18n, Metadata, PostLinks, Posts, Preview, Repo, Tags, Templates}
|
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
|
||||||
alias BDS.Media.Media
|
alias BDS.Posts.Post
|
||||||
alias BDS.Posts.{Post, PostMedia, Translation}
|
alias BDS.Tags
|
||||||
alias BDS.UI.Workbench
|
alias BDS.UI.Workbench
|
||||||
|
|
||||||
|
import DraftManagement,
|
||||||
|
only: [
|
||||||
|
current_draft: 4,
|
||||||
|
delete_nested_map: 3,
|
||||||
|
editing_canonical_language?: 3,
|
||||||
|
maybe_update_draft: 7,
|
||||||
|
normalize_language: 2,
|
||||||
|
normalize_mode: 1,
|
||||||
|
normalize_params: 3,
|
||||||
|
persisted_form: 3,
|
||||||
|
put_draft_field: 6,
|
||||||
|
put_nested_map: 4,
|
||||||
|
put_query_state: 4,
|
||||||
|
query_value: 3,
|
||||||
|
record_status: 1,
|
||||||
|
record_title: 2,
|
||||||
|
reload_with_assigned_workbench: 2,
|
||||||
|
save_state_for_action: 1,
|
||||||
|
toggled_sections: 3
|
||||||
|
]
|
||||||
|
|
||||||
|
import ListValues,
|
||||||
|
only: [
|
||||||
|
category_suggestions: 3,
|
||||||
|
category_values: 1,
|
||||||
|
csv_to_list: 1,
|
||||||
|
ensure_list_value: 3,
|
||||||
|
field_key: 1,
|
||||||
|
normalize_list_entry: 1,
|
||||||
|
query_addable?: 4,
|
||||||
|
tag_chips: 2,
|
||||||
|
tag_suggestions: 3,
|
||||||
|
tag_values: 1
|
||||||
|
]
|
||||||
|
|
||||||
|
import Persistence,
|
||||||
|
only: [
|
||||||
|
discard: 3,
|
||||||
|
discard_label: 1,
|
||||||
|
discard_title: 1,
|
||||||
|
has_published_version?: 1,
|
||||||
|
persist: 5
|
||||||
|
]
|
||||||
|
|
||||||
|
import PostMetadata,
|
||||||
|
only: [
|
||||||
|
blank?: 1,
|
||||||
|
blank_to_nil: 1,
|
||||||
|
canonical_language: 2,
|
||||||
|
display_title: 3,
|
||||||
|
footer: 4,
|
||||||
|
gallery_count: 1,
|
||||||
|
languages: 1,
|
||||||
|
linked_media: 1,
|
||||||
|
post_links: 1,
|
||||||
|
preview_url: 4,
|
||||||
|
project_metadata: 1,
|
||||||
|
template_options: 1,
|
||||||
|
translation_flags: 4,
|
||||||
|
translations: 1
|
||||||
|
]
|
||||||
|
|
||||||
|
defdelegate tag_chip_style(color), to: ListValues
|
||||||
|
|
||||||
embed_templates "post_editor_html/*"
|
embed_templates "post_editor_html/*"
|
||||||
|
|
||||||
def assign_socket(socket) do
|
def assign_socket(socket) do
|
||||||
@@ -363,12 +426,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
canonical_language = canonical_language(post, metadata)
|
canonical_language = canonical_language(post, metadata)
|
||||||
active_language = Map.get(assigns.post_editor_active_languages, post.id, canonical_language)
|
active_language = Map.get(assigns.post_editor_active_languages, post.id, canonical_language)
|
||||||
translations = translations(post.id)
|
translations = translations(post.id)
|
||||||
persisted_form = persisted_form(post, metadata, active_language, translations)
|
persisted = DraftManagement.persisted_form(post, metadata, active_language, translations)
|
||||||
|
|
||||||
form =
|
form =
|
||||||
assigns.post_editor_drafts
|
assigns.post_editor_drafts
|
||||||
|> Map.get(post.id, %{})
|
|> Map.get(post.id, %{})
|
||||||
|> Map.get(active_language, persisted_form)
|
|> Map.get(active_language, persisted)
|
||||||
|
|
||||||
expanded =
|
expanded =
|
||||||
Map.get(assigns.post_editor_expanded, post.id, %{
|
Map.get(assigns.post_editor_expanded, post.id, %{
|
||||||
@@ -425,91 +488,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
|
|
||||||
def build(_assigns), do: nil
|
def build(_assigns), do: nil
|
||||||
|
|
||||||
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode
|
|
||||||
def normalize_mode("visual"), do: :markdown
|
|
||||||
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_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(:dirty), do: translated("Unsaved")
|
||||||
@@ -521,443 +499,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
def post_editor_mode_label(:markdown), do: translated("Markdown")
|
def post_editor_mode_label(:markdown), do: translated("Markdown")
|
||||||
def post_editor_mode_label(:preview), do: translated("Preview")
|
def post_editor_mode_label(:preview), do: translated("Preview")
|
||||||
|
|
||||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
def translated(text, bindings \\ %{}),
|
||||||
|
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||||
def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted)
|
|
||||||
|
|
||||||
def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
|
|
||||||
|
|
||||||
def project_metadata(project_id) do
|
|
||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
|
||||||
metadata
|
|
||||||
rescue
|
|
||||||
_error -> %{main_language: "en", blog_languages: []}
|
|
||||||
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 assigned_project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{})
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
if active_language == canonical_language do
|
|
||||||
%{
|
|
||||||
"title" => post.title || "",
|
|
||||||
"excerpt" => post.excerpt || "",
|
|
||||||
"content" => Posts.editor_body(post),
|
|
||||||
"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" => if(translation, do: Posts.editor_body(translation), else: ""),
|
|
||||||
"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 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
|
|
||||||
rows =
|
|
||||||
Repo.all(
|
|
||||||
from pm in PostMedia,
|
|
||||||
where: pm.post_id == ^post_id,
|
|
||||||
order_by: [asc: pm.sort_order, asc: pm.media_id],
|
|
||||||
select: {pm.media_id, pm.sort_order}
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
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 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
|
|
||||||
query =
|
|
||||||
%{}
|
|
||||||
|> maybe_put_query("draft", "true")
|
|
||||||
|> maybe_put_query("post_id", post.id)
|
|
||||||
|> maybe_put_query("lang", active_language != canonical_language && active_language)
|
|
||||||
|
|
||||||
Preview.base_url() <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query)
|
|
||||||
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")),
|
|
||||||
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
|
|
||||||
|
|
||||||
defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) when current_language == next_language,
|
|
||||||
do: socket
|
|
||||||
|
|
||||||
defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do
|
|
||||||
assign(socket, :post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp toggled_sections(expanded_by_post, post_id, section) do
|
|
||||||
expanded_by_post
|
|
||||||
|> Map.get(post_id, %{metadata: false, excerpt: false})
|
|
||||||
|> Map.put_new(:metadata, false)
|
|
||||||
|> Map.put_new(:excerpt, false)
|
|
||||||
|> Map.update!(section, ¬ &1)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp put_nested_map(map, key, nested_key, value) do
|
|
||||||
Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp delete_nested_map(map, key, nested_key) do
|
|
||||||
case Map.get(map, key) do
|
|
||||||
nil -> map
|
|
||||||
nested ->
|
|
||||||
case Map.delete(nested, nested_key) do
|
|
||||||
emptied when map_size(emptied) == 0 -> Map.delete(map, key)
|
|
||||||
remaining -> Map.put(map, key, remaining)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
183
lib/bds/desktop/shell_live/post_editor/draft_management.ex
Normal file
183
lib/bds/desktop/shell_live/post_editor/draft_management.ex
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
|
||||||
|
alias BDS.Posts
|
||||||
|
alias BDS.Posts.{Post, Translation}
|
||||||
|
alias BDS.Desktop.ShellLive.PostEditor.PostMetadata
|
||||||
|
alias BDS.UI.Workbench
|
||||||
|
|
||||||
|
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode
|
||||||
|
def normalize_mode("visual"), do: :markdown
|
||||||
|
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, PostMetadata.translations(post.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
def persisted_form(post, metadata, active_language, translations) do
|
||||||
|
canonical_language = PostMetadata.canonical_language(post, metadata)
|
||||||
|
translation = Map.get(translations, active_language)
|
||||||
|
|
||||||
|
if active_language == canonical_language do
|
||||||
|
%{
|
||||||
|
"title" => post.title || "",
|
||||||
|
"excerpt" => post.excerpt || "",
|
||||||
|
"content" => Posts.editor_body(post),
|
||||||
|
"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" => if(translation, do: Posts.editor_body(translation), else: ""),
|
||||||
|
"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
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def put_draft_field(socket, post_id, post, active_language, field, value) do
|
||||||
|
metadata = PostMetadata.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
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def 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 maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) when current_language == next_language,
|
||||||
|
do: socket
|
||||||
|
|
||||||
|
defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do
|
||||||
|
assign(socket, :post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language))
|
||||||
|
end
|
||||||
|
|
||||||
|
def toggled_sections(expanded_by_post, post_id, section) do
|
||||||
|
expanded_by_post
|
||||||
|
|> Map.get(post_id, %{metadata: false, excerpt: false})
|
||||||
|
|> Map.put_new(:metadata, false)
|
||||||
|
|> Map.put_new(:excerpt, false)
|
||||||
|
|> Map.update!(section, ¬ &1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def put_nested_map(map, key, nested_key, value) do
|
||||||
|
Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value))
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_nested_map(map, key, nested_key) do
|
||||||
|
case Map.get(map, key) do
|
||||||
|
nil ->
|
||||||
|
map
|
||||||
|
|
||||||
|
nested ->
|
||||||
|
case Map.delete(nested, nested_key) do
|
||||||
|
emptied when map_size(emptied) == 0 -> Map.delete(map, key)
|
||||||
|
remaining -> Map.put(map, key, remaining)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
defp truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
|
||||||
|
defp truthy?(_value), do: false
|
||||||
|
|
||||||
|
defp blank_to_nil(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> case do
|
||||||
|
"" -> nil
|
||||||
|
trimmed -> trimmed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
125
lib/bds/desktop/shell_live/post_editor/list_values.ex
Normal file
125
lib/bds/desktop/shell_live/post_editor/list_values.ex
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.{Metadata, Tags}
|
||||||
|
|
||||||
|
def field_key(:tags), do: "tags"
|
||||||
|
def field_key(:categories), do: "categories"
|
||||||
|
|
||||||
|
def tag_values(form), do: csv_to_list(Map.get(form, "tags", ""))
|
||||||
|
def category_values(form), do: csv_to_list(Map.get(form, "categories", ""))
|
||||||
|
|
||||||
|
def tag_suggestions(form, options, query) do
|
||||||
|
selected = MapSet.new(tag_values(form))
|
||||||
|
filter_suggestions(options, query, fn option -> option.name end, selected)
|
||||||
|
end
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def normalize_list_entry(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.downcase()
|
||||||
|
end
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def csv_to_list(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.split(",")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
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 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"
|
||||||
|
|
||||||
|
def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted)
|
||||||
|
end
|
||||||
105
lib/bds/desktop/shell_live/post_editor/persistence.ex
Normal file
105
lib/bds/desktop/shell_live/post_editor/persistence.ex
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.Posts
|
||||||
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Desktop.ShellData
|
||||||
|
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata}
|
||||||
|
|
||||||
|
def persist(%Post{} = post, draft, active_language, metadata, action) do
|
||||||
|
canonical_language = PostMetadata.canonical_language(post, metadata)
|
||||||
|
translations = PostMetadata.translations(post.id)
|
||||||
|
|
||||||
|
if DraftManagement.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
|
||||||
|
end
|
||||||
|
|
||||||
|
def discard(%Post{} = post, active_language, metadata) do
|
||||||
|
canonical_language = PostMetadata.canonical_language(post, metadata)
|
||||||
|
current_translations = PostMetadata.translations(post.id)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
not DraftManagement.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 has_published_version?(%Post{} = post),
|
||||||
|
do: not is_nil(post.published_at) or post.file_path not in [nil, ""]
|
||||||
|
|
||||||
|
def discard_label(%Post{} = post) do
|
||||||
|
if has_published_version?(post),
|
||||||
|
do: translated("Discard Changes"),
|
||||||
|
else: translated("Discard Draft")
|
||||||
|
end
|
||||||
|
|
||||||
|
def 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 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
|
||||||
|
|
||||||
|
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 translated(text, bindings \\ %{}),
|
||||||
|
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||||
|
end
|
||||||
190
lib/bds/desktop/shell_live/post_editor/post_metadata.ex
Normal file
190
lib/bds/desktop/shell_live/post_editor/post_metadata.ex
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.{I18n, Metadata, PostLinks, Posts, Preview, Repo, Templates}
|
||||||
|
alias BDS.Desktop.ShellData
|
||||||
|
alias BDS.Media.Media
|
||||||
|
alias BDS.Posts.{Post, PostMedia}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def canonical_language(post, metadata) do
|
||||||
|
BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language(
|
||||||
|
post.language,
|
||||||
|
metadata.main_language || "en"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def translations(post_id) do
|
||||||
|
{:ok, translations} = Posts.list_post_translations(post_id)
|
||||||
|
Map.new(translations, fn translation -> {translation.language, translation} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def linked_media(post_id) do
|
||||||
|
rows =
|
||||||
|
Repo.all(
|
||||||
|
from pm in PostMedia,
|
||||||
|
where: pm.post_id == ^post_id,
|
||||||
|
order_by: [asc: pm.sort_order, asc: pm.media_id],
|
||||||
|
select: {pm.media_id, pm.sort_order}
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
rescue
|
||||||
|
_error -> []
|
||||||
|
end
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def display_title(title, slug, fallback_id) do
|
||||||
|
blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled")
|
||||||
|
end
|
||||||
|
|
||||||
|
def gallery_count(form) do
|
||||||
|
form
|
||||||
|
|> Map.get("content", "")
|
||||||
|
|> to_string()
|
||||||
|
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|
||||||
|
|> length()
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview, do: nil
|
||||||
|
|
||||||
|
def preview_url(%Post{} = post, active_language, canonical_language, :preview) do
|
||||||
|
query =
|
||||||
|
%{}
|
||||||
|
|> maybe_put_query("draft", "true")
|
||||||
|
|> maybe_put_query("post_id", post.id)
|
||||||
|
|> maybe_put_query("lang", active_language != canonical_language && active_language)
|
||||||
|
|
||||||
|
Preview.base_url() <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query)
|
||||||
|
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)
|
||||||
|
|
||||||
|
def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
|
||||||
|
def truthy?(_value), do: false
|
||||||
|
|
||||||
|
def blank?(value), do: blank_to_nil(value) == nil
|
||||||
|
|
||||||
|
def blank_to_nil(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> case do
|
||||||
|
"" -> nil
|
||||||
|
trimmed -> trimmed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translated(text, bindings \\ %{}),
|
||||||
|
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user