feat: step 10 done (claimed)
This commit is contained in:
17
PLAN.md
17
PLAN.md
@@ -4,10 +4,10 @@ This document tracks the current implementation state of bDS2 against the Allium
|
|||||||
|
|
||||||
## Open Work Summary
|
## Open Work Summary
|
||||||
|
|
||||||
- Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9.
|
- Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10.
|
||||||
- Open plan steps: 10, 11, 12.
|
- Open plan steps: 11, 12.
|
||||||
- Next actionable step: 10. The batch-3 and batch-4 parity backlog is now closed, so the next implementation work is menu editor parity on the existing menu data model.
|
- Next actionable step: 11. The remaining open parity backlog now starts with desktop-side CLI mutation watching.
|
||||||
- Scheduled after the current parity pass: 11 desktop-side CLI mutation watching, 12 import execution/editor parity.
|
- Scheduled after the current parity pass: 12 import execution/editor parity.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ The rewrite already implements most of the backend and compatibility-critical su
|
|||||||
### Missing Or Materially Incomplete
|
### Missing Or Materially Incomplete
|
||||||
|
|
||||||
- Import remains definition-only: stored import definitions exist, but the old WXR analysis/execution pipeline and its dedicated editor surface are not present.
|
- Import remains definition-only: stored import definitions exist, but the old WXR analysis/execution pipeline and its dedicated editor surface are not present.
|
||||||
- Menu data exists, but the `menu_editor` route still lacks a dedicated editor surface comparable to the old app.
|
|
||||||
- CLI sync notification persistence exists, but old-app parity for desktop-side watching/broadcasting of external DB mutations is not yet proven.
|
- CLI sync notification persistence exists, but old-app parity for desktop-side watching/broadcasting of external DB mutations is not yet proven.
|
||||||
- The remaining parity write-up gap is now keeping the chat row and the final minimal backlog in sync with the current implementation.
|
- The remaining parity write-up gap is now keeping the chat row and the final minimal backlog in sync with the current implementation.
|
||||||
|
|
||||||
@@ -46,11 +45,11 @@ Ordered from base contracts upward:
|
|||||||
| Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. |
|
| Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. |
|
||||||
| Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and a LiveView-owned shell frame are in place. |
|
| Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and a LiveView-owned shell frame are in place. |
|
||||||
| Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. |
|
| Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. |
|
||||||
| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial | The existing editor routes now render dedicated surfaces and have focused tests; import/menu parity and full old-app behavior scoring are still outstanding. |
|
| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals`, `menu` | Partial | The existing editor routes now render dedicated surfaces and have focused tests; import parity and any later not-yet-implemented old-app behavior remain outstanding. |
|
||||||
|
|
||||||
## Batch 3 And 4 Focus
|
## Batch 3 And 4 Focus
|
||||||
|
|
||||||
Only these two audit tracks matter for the current pass. The follow-on missing-feature tracks now sit explicitly after this pass as steps 10, 11, and 12.
|
Only these two audit tracks matter for the current pass. The follow-on missing-feature tracks now sit explicitly after this pass as steps 11 and 12.
|
||||||
|
|
||||||
1. Lock compatibility contracts. Completed 2026-04-25.
|
1. Lock compatibility contracts. Completed 2026-04-25.
|
||||||
Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests.
|
Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests.
|
||||||
@@ -79,8 +78,8 @@ Only these two audit tracks matter for the current pass. The follow-on missing-f
|
|||||||
9. Fix any remaining batch 3 and batch 4 parity gaps that the audit proves. Completed 2026-04-29.
|
9. Fix any remaining batch 3 and batch 4 parity gaps that the audit proves. Completed 2026-04-29.
|
||||||
No further proven batch-3 or batch-4 gaps remained after the focused chat parity pass, so the minimal backlog now begins with the later menu, CLI-mutation-watching, and import tracks.
|
No further proven batch-3 or batch-4 gaps remained after the focused chat parity pass, so the minimal backlog now begins with the later menu, CLI-mutation-watching, and import tracks.
|
||||||
|
|
||||||
10. Restore menu editor parity on the implemented data model.
|
10. Restore menu editor parity on the implemented data model. Completed 2026-04-29.
|
||||||
Build the dedicated menu editor surface against the existing menu data using the old app as the blueprint for workflow, behavior, UI structure, and styling.
|
The shell now renders a dedicated menu editor with old-app-inspired structure, toolbar flow, inline page/category insertion, drag/drop plus move/indent controls, localized copy, and focused shell-live coverage on the existing menu persistence model.
|
||||||
|
|
||||||
11. Restore desktop-side CLI mutation watching parity.
|
11. Restore desktop-side CLI mutation watching parity.
|
||||||
Add the old desktop-side watching and invalidation behavior for external database mutations so CLI sync notifications propagate through the shell with the same timing and UX expectations as the old app.
|
Add the old desktop-side watching and invalidation behavior for external database mutations so CLI sync notifications propagate through the shell with the same timing and UX expectations as the old app.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
alias BDS.AI
|
alias BDS.AI
|
||||||
alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData}
|
alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData}
|
||||||
alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MiscEditor, SettingsEditor, TagsEditor}
|
alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor}
|
||||||
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
|
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
|
||||||
alias BDS.Desktop.ShellLive.PostEditor
|
alias BDS.Desktop.ShellLive.PostEditor
|
||||||
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
|
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
|
||||||
@@ -614,6 +614,46 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, SettingsEditor.apply_style_theme(socket, &reload_shell/2, &append_output_entry/5)}
|
{:noreply, SettingsEditor.apply_style_theme(socket, &reload_shell/2, &append_output_entry/5)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("menu_editor_select_item", %{"item_id" => item_id}, socket) do
|
||||||
|
{:noreply, MenuEditor.select_item(socket, item_id, &reload_shell/2)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("change_menu_editor_entry", %{"menu_editor_entry" => params}, socket) do
|
||||||
|
{:noreply, MenuEditor.change_entry(socket, params, &reload_shell/2)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("submit_menu_editor_entry", _params, socket) do
|
||||||
|
{:noreply, MenuEditor.submit_entry(socket, &reload_shell/2)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("cancel_menu_editor_entry", _params, socket) do
|
||||||
|
{:noreply, MenuEditor.cancel_entry(socket, &reload_shell/2)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("select_menu_editor_page", %{"post_id" => post_id}, socket) do
|
||||||
|
{:noreply, MenuEditor.select_page(socket, post_id, &reload_shell/2)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("select_menu_editor_category", %{"name" => name}, socket) do
|
||||||
|
{:noreply, MenuEditor.select_category(socket, name, &reload_shell/2)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("menu_editor_toolbar_action", %{"action" => action}, socket) do
|
||||||
|
{:noreply, MenuEditor.toolbar_action(socket, action, &reload_shell/2, &append_output_entry/5)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event(
|
||||||
|
"menu_editor_drop_item",
|
||||||
|
%{"drag_item_id" => drag_item_id, "target_item_id" => target_item_id, "position" => position},
|
||||||
|
socket
|
||||||
|
) do
|
||||||
|
{:noreply, MenuEditor.drop_item(socket, drag_item_id, target_item_id, position, &reload_shell/2)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("menu_editor_keydown", %{"key" => key}, socket) do
|
||||||
|
{:noreply, MenuEditor.handle_keydown(socket, key, &reload_shell/2)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("toggle_tag_selection", %{"name" => tag_name}, socket) do
|
def handle_event("toggle_tag_selection", %{"name" => tag_name}, socket) do
|
||||||
{:noreply, TagsEditor.toggle_selection(socket, tag_name, &reload_shell/2)}
|
{:noreply, TagsEditor.toggle_selection(socket, tag_name, &reload_shell/2)}
|
||||||
end
|
end
|
||||||
@@ -1205,6 +1245,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign_post_editor()
|
|> assign_post_editor()
|
||||||
|> assign_media_editor()
|
|> assign_media_editor()
|
||||||
|> assign_settings_editor()
|
|> assign_settings_editor()
|
||||||
|
|> assign_menu_editor()
|
||||||
|> assign_tags_editor()
|
|> assign_tags_editor()
|
||||||
|> assign_code_entity_editor()
|
|> assign_code_entity_editor()
|
||||||
|> assign_chat_editor()
|
|> assign_chat_editor()
|
||||||
@@ -1444,6 +1485,10 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
SettingsEditor.assign_socket(socket)
|
SettingsEditor.assign_socket(socket)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp assign_menu_editor(socket) do
|
||||||
|
MenuEditor.assign_socket(socket)
|
||||||
|
end
|
||||||
|
|
||||||
defp assign_tags_editor(socket) do
|
defp assign_tags_editor(socket) do
|
||||||
TagsEditor.assign_socket(socket)
|
TagsEditor.assign_socket(socket)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -394,6 +394,9 @@
|
|||||||
<% @current_tab.type == :style and @style_editor -> %>
|
<% @current_tab.type == :style and @style_editor -> %>
|
||||||
<SettingsEditor.style_editor style_editor={@style_editor} />
|
<SettingsEditor.style_editor style_editor={@style_editor} />
|
||||||
|
|
||||||
|
<% @current_tab.type == :menu_editor and @menu_editor -> %>
|
||||||
|
<MenuEditor.menu_editor menu_editor={@menu_editor} />
|
||||||
|
|
||||||
<% @current_tab.type == :tags and @tags_editor -> %>
|
<% @current_tab.type == :tags and @tags_editor -> %>
|
||||||
<TagsEditor.tags_editor tags_editor={@tags_editor} />
|
<TagsEditor.tags_editor tags_editor={@tags_editor} />
|
||||||
|
|
||||||
|
|||||||
871
lib/bds/desktop/shell_live/menu_editor.ex
Normal file
871
lib/bds/desktop/shell_live/menu_editor.ex
Normal file
@@ -0,0 +1,871 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.MenuEditor do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellData
|
||||||
|
alias BDS.{Menu, Metadata, Repo}
|
||||||
|
alias BDS.Posts.Post
|
||||||
|
|
||||||
|
embed_templates "menu_editor_html/*"
|
||||||
|
|
||||||
|
@home_item_id "menu-home"
|
||||||
|
|
||||||
|
def assign_socket(socket) do
|
||||||
|
case socket.assigns[:current_tab] do
|
||||||
|
%{type: :menu_editor, id: tab_id} ->
|
||||||
|
state = ensure_state(socket.assigns)
|
||||||
|
menu_editor = build(socket.assigns, state)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:menu_editor_state, state)
|
||||||
|
|> assign(:menu_editor, menu_editor)
|
||||||
|
|> assign(
|
||||||
|
:tab_meta,
|
||||||
|
Map.put(socket.assigns.tab_meta, {:menu_editor, tab_id}, %{
|
||||||
|
title: translated("menuEditor.tabTitle"),
|
||||||
|
subtitle: translated("menuEditor.description")
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
assign(socket, :menu_editor, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_item(socket, item_id, reload) do
|
||||||
|
socket
|
||||||
|
|> update_state(fn state -> %{state | selected_id: item_id} end)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def change_entry(socket, params, reload) do
|
||||||
|
query = Map.get(params, "query", "")
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> update_state(fn state -> put_in(state, [:draft, :query], query) end)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def submit_entry(socket, reload) do
|
||||||
|
case current_draft(socket.assigns) do
|
||||||
|
%{type: :page} ->
|
||||||
|
socket
|
||||||
|
|> update_state(&finalize_submenu_draft/1)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
%{type: :category} ->
|
||||||
|
socket
|
||||||
|
|> confirm_category_draft()
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel_entry(socket, reload) do
|
||||||
|
socket
|
||||||
|
|> update_state(&cancel_draft/1)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_page(socket, post_id, reload) do
|
||||||
|
case page_post(socket.assigns.projects.active_project_id, post_id) do
|
||||||
|
nil -> reload.(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
|
post ->
|
||||||
|
socket
|
||||||
|
|> update_state(&assign_page_to_draft(&1, post))
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_category(socket, name, reload) do
|
||||||
|
project_id = socket.assigns.projects.active_project_id
|
||||||
|
|
||||||
|
case Enum.find(category_options(project_id), &(&1.name == name)) do
|
||||||
|
nil -> reload.(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
|
category ->
|
||||||
|
socket
|
||||||
|
|> update_state(&assign_category_to_draft(&1, category))
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def toolbar_action(socket, action, reload, append_output) do
|
||||||
|
case action do
|
||||||
|
"add-entry" ->
|
||||||
|
socket
|
||||||
|
|> update_state(&start_page_draft/1)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
"add-category-archive" ->
|
||||||
|
socket
|
||||||
|
|> update_state(&start_category_draft/1)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
"save" ->
|
||||||
|
save(socket, reload, append_output)
|
||||||
|
|
||||||
|
"move-up" ->
|
||||||
|
socket
|
||||||
|
|> update_state(&move_selected(&1, :up))
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
"move-down" ->
|
||||||
|
socket
|
||||||
|
|> update_state(&move_selected(&1, :down))
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
"indent" ->
|
||||||
|
socket
|
||||||
|
|> update_state(&indent_selected/1)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
"unindent" ->
|
||||||
|
socket
|
||||||
|
|> update_state(&unindent_selected/1)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
"delete" ->
|
||||||
|
socket
|
||||||
|
|> update_state(&delete_selected/1)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def drop_item(socket, drag_item_id, target_item_id, position, reload) do
|
||||||
|
socket
|
||||||
|
|> update_state(&drop_selected(&1, drag_item_id, target_item_id, position))
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_keydown(socket, "Escape", reload) do
|
||||||
|
cancel_entry(socket, reload)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_keydown(socket, _key, reload) do
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :menu_editor, :map, required: true
|
||||||
|
|
||||||
|
def menu_editor(assigns)
|
||||||
|
|
||||||
|
attr :items, :list, required: true
|
||||||
|
attr :menu_editor, :map, required: true
|
||||||
|
attr :depth, :integer, required: true
|
||||||
|
|
||||||
|
def menu_tree_level(assigns) do
|
||||||
|
~H"""
|
||||||
|
<%= for item <- @items do %>
|
||||||
|
<li class="menu-editor-tree-item">
|
||||||
|
<% editing? = draft_item?(@menu_editor, item.item_id) %>
|
||||||
|
<% selected? = item.item_id == @menu_editor.selected_id %>
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"menu-editor-row",
|
||||||
|
if(selected?, do: "is-selected"),
|
||||||
|
if(editing?, do: "is-editing")
|
||||||
|
]}
|
||||||
|
data-testid="menu-editor-row"
|
||||||
|
data-menu-item-id={item.item_id}
|
||||||
|
data-menu-kind={item.kind}
|
||||||
|
data-menu-label={row_label(item, @menu_editor.category_titles)}
|
||||||
|
data-menu-can-drop-inside={to_string(item.kind == :submenu)}
|
||||||
|
data-selected={to_string(selected?)}
|
||||||
|
phx-click={unless(editing?, do: "menu_editor_select_item")}
|
||||||
|
phx-value-item_id={unless(editing?, do: item.item_id)}
|
||||||
|
style={"--menu-editor-depth: #{@depth};"}
|
||||||
|
>
|
||||||
|
<span class="menu-editor-row-handle" data-menu-drag-handle="true" title={translated("menuEditor.dragHandle")}>⋮⋮</span>
|
||||||
|
<span class="menu-editor-row-kind" title={kind_label(item.kind)} aria-label={kind_label(item.kind)}>
|
||||||
|
<.kind_icon kind={item.kind} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<%= if editing? do %>
|
||||||
|
<div class="menu-editor-row-title is-editing">
|
||||||
|
<form
|
||||||
|
class="menu-editor-entry-form"
|
||||||
|
data-testid="menu-editor-entry-form"
|
||||||
|
phx-change="change_menu_editor_entry"
|
||||||
|
phx-submit="submit_menu_editor_entry"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="menu-editor-inline-input"
|
||||||
|
type="text"
|
||||||
|
name="menu_editor_entry[query]"
|
||||||
|
value={@menu_editor.draft_query}
|
||||||
|
placeholder={editing_placeholder(@menu_editor)}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="menu-editor-inline-search">
|
||||||
|
<div class="menu-editor-inline-search-head">
|
||||||
|
<div>
|
||||||
|
<strong><%= editing_title(@menu_editor) %></strong>
|
||||||
|
<span><%= editing_hint(@menu_editor) %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-editor-inline-actions">
|
||||||
|
<%= if @menu_editor.draft.type == :page do %>
|
||||||
|
<button class="menu-editor-inline-action" data-testid="menu-editor-create-submenu" type="submit">
|
||||||
|
<%= translated("menuEditor.addSubmenu") %>
|
||||||
|
</button>
|
||||||
|
<% else %>
|
||||||
|
<button class="menu-editor-inline-action" type="submit">
|
||||||
|
<%= translated("menuEditor.addCategoryArchive") %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<button class="menu-editor-inline-action" type="button" phx-click="cancel_menu_editor_entry">
|
||||||
|
<%= translated("Cancel") %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @menu_editor.draft.type == :page do %>
|
||||||
|
<div class="menu-editor-picker-list">
|
||||||
|
<%= if @menu_editor.filtered_pages == [] do %>
|
||||||
|
<div class="menu-editor-picker-state"><%= translated("menuEditor.pagePicker.empty") %></div>
|
||||||
|
<% else %>
|
||||||
|
<%= for post <- @menu_editor.filtered_pages do %>
|
||||||
|
<button
|
||||||
|
class="menu-editor-picker-item"
|
||||||
|
type="button"
|
||||||
|
phx-click="select_menu_editor_page"
|
||||||
|
phx-value-post_id={post.id}
|
||||||
|
>
|
||||||
|
<span><%= post.title %></span>
|
||||||
|
<small><%= post.slug %></small>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="menu-editor-picker-list">
|
||||||
|
<%= if @menu_editor.filtered_categories == [] do %>
|
||||||
|
<div class="menu-editor-picker-state"><%= translated("menuEditor.categoryPicker.empty") %></div>
|
||||||
|
<% else %>
|
||||||
|
<%= for category <- @menu_editor.filtered_categories do %>
|
||||||
|
<button
|
||||||
|
class="menu-editor-picker-item"
|
||||||
|
type="button"
|
||||||
|
phx-click="select_menu_editor_category"
|
||||||
|
phx-value-name={category.name}
|
||||||
|
>
|
||||||
|
<span><%= category.title %></span>
|
||||||
|
<small><%= category.name %></small>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<span class="menu-editor-row-title"><%= row_label(item, @menu_editor.category_titles) %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if item.children != [] do %>
|
||||||
|
<ul class="menu-editor-tree-level">
|
||||||
|
<.menu_tree_level items={item.children} menu_editor={@menu_editor} depth={@depth + 1} />
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :kind, :atom, required: true
|
||||||
|
|
||||||
|
def kind_icon(assigns) do
|
||||||
|
~H"""
|
||||||
|
<%= case @kind do %>
|
||||||
|
<% :home -> %>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 2 2 7v7h4V9h4v5h4V7L8 2z" /></svg>
|
||||||
|
<% :page -> %>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M3 2h7l3 3v9H3V2zm7 1.5V6h2.5L10 3.5z" /></svg>
|
||||||
|
<% :category_archive -> %>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 3h12v3H2V3zm1 4h10v6H3V7zm2 1v1h6V8H5z" /></svg>
|
||||||
|
<% _other -> %>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 3h12v2H2V3zm0 4h12v2H2V7zm0 4h12v2H2v-2z" /></svg>
|
||||||
|
<% end %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||||
|
|
||||||
|
def row_label(item, category_titles) do
|
||||||
|
if item.kind == :category_archive do
|
||||||
|
Map.get(category_titles || %{}, item.slug, item.label)
|
||||||
|
else
|
||||||
|
item.label
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def kind_label(:home), do: translated("menuEditor.type.home")
|
||||||
|
def kind_label(:page), do: translated("menuEditor.type.page")
|
||||||
|
def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive")
|
||||||
|
def kind_label(:submenu), do: translated("menuEditor.type.submenu")
|
||||||
|
|
||||||
|
def draft_item?(menu_editor, item_id) do
|
||||||
|
match?(%{item_id: ^item_id}, menu_editor.draft)
|
||||||
|
end
|
||||||
|
|
||||||
|
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
|
||||||
|
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title")
|
||||||
|
|
||||||
|
def editing_hint(%{draft: %{type: :category}}), do: translated("menuEditor.categoryPicker.hint")
|
||||||
|
def editing_hint(_menu_editor), do: translated("menuEditor.createHint")
|
||||||
|
|
||||||
|
def editing_placeholder(%{draft: %{type: :category}}), do: translated("menuEditor.newCategoryPlaceholder")
|
||||||
|
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
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<div class="menu-editor-view" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")}>
|
||||||
|
<div class="menu-editor-header">
|
||||||
|
<div>
|
||||||
|
<h2><%= @menu_editor.title %></h2>
|
||||||
|
<p><%= @menu_editor.description %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-editor-main">
|
||||||
|
<div class="menu-editor-tree-wrap">
|
||||||
|
<div class="menu-editor-toolbar" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}>
|
||||||
|
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-entry" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-entry" title={translated("menuEditor.addEntry")}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="save" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="save" title={translated("menuEditor.save")}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-category-archive" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-category-archive" title={translated("menuEditor.addCategoryArchive")}>
|
||||||
|
<span aria-hidden="true"><%= translated("menuEditor.addCategoryArchiveShort") %></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-up" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-up" title={translated("menuEditor.moveUp")} disabled={not @menu_editor.can_move_up?}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-down" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-down" title={translated("menuEditor.moveDown")} disabled={not @menu_editor.can_move_down?}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="indent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="indent" title={translated("menuEditor.indent")} disabled={not @menu_editor.can_indent?}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm6-1 3 2-3 2V9z" /></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="unindent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="unindent" title={translated("menuEditor.unindent")} disabled={not @menu_editor.can_unindent?}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm3-1-3 2 3 2V9z" /></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="delete" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="delete" title={translated("menuEditor.delete")} disabled={not @menu_editor.can_delete?}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @menu_editor.items == [] do %>
|
||||||
|
<div class="menu-editor-empty"><%= translated("menuEditor.empty") %></div>
|
||||||
|
<% else %>
|
||||||
|
<div id="menu-editor-tree-shell" class="menu-editor-tree-shell" phx-hook="MenuEditorTree">
|
||||||
|
<ul class="menu-editor-tree-level">
|
||||||
|
<.menu_tree_level items={@menu_editor.items} menu_editor={@menu_editor} depth={0} />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -64,6 +64,35 @@
|
|||||||
"translationValidation.revalidate": "Erneut validieren",
|
"translationValidation.revalidate": "Erneut validieren",
|
||||||
"translationValidation.fix": "Probleme beheben",
|
"translationValidation.fix": "Probleme beheben",
|
||||||
"translationValidation.toast.fixSuccess": "%{dbRows} DB-Zeilen und %{files} Dateien gelöscht, %{flushed} Übersetzungen auf Datenträger geschrieben",
|
"translationValidation.toast.fixSuccess": "%{dbRows} DB-Zeilen und %{files} Dateien gelöscht, %{flushed} Übersetzungen auf Datenträger geschrieben",
|
||||||
|
"menuEditor.tabTitle": "Blog-Menü",
|
||||||
|
"menuEditor.title": "Blog-Menü-Editor",
|
||||||
|
"menuEditor.description": "Verwalte die zentrale Blog-Navigationsstruktur und speichere sie in meta/menu.opml.",
|
||||||
|
"menuEditor.save": "Menü speichern",
|
||||||
|
"menuEditor.saved": "Blog-Menü gespeichert",
|
||||||
|
"menuEditor.addEntry": "Eintrag hinzufügen",
|
||||||
|
"menuEditor.addCategoryArchive": "Kategorie-Archiv hinzufügen",
|
||||||
|
"menuEditor.addCategoryArchiveShort": "K+",
|
||||||
|
"menuEditor.addSubmenu": "Untermenü hinzufügen",
|
||||||
|
"menuEditor.moveUp": "Nach oben",
|
||||||
|
"menuEditor.moveDown": "Nach unten",
|
||||||
|
"menuEditor.indent": "Einrücken",
|
||||||
|
"menuEditor.unindent": "Ausrücken",
|
||||||
|
"menuEditor.delete": "Löschen",
|
||||||
|
"menuEditor.newEntryPlaceholder": "Seitentitel oder Untermenü-Bezeichnung eingeben",
|
||||||
|
"menuEditor.newCategoryPlaceholder": "Kategorienamen eingeben",
|
||||||
|
"menuEditor.createHint": "Wähle unten eine Seite aus oder drücke Enter, um ein Untermenü zu erstellen",
|
||||||
|
"menuEditor.dragHandle": "Menüeintrag ziehen",
|
||||||
|
"menuEditor.empty": "Noch keine Menüeinträge. Füge eine Seite oder ein Untermenü hinzu, um zu beginnen.",
|
||||||
|
"menuEditor.newPage": "Neue Seite",
|
||||||
|
"menuEditor.newSubmenu": "Neues Untermenü",
|
||||||
|
"menuEditor.pagePicker.title": "Seite auswählen",
|
||||||
|
"menuEditor.pagePicker.empty": "Keine passenden Seiten gefunden.",
|
||||||
|
"menuEditor.categoryPicker.hint": "Wähle eine vorhandene Kategorie oder drücke Enter, um einen neuen Archiv-Eintrag zu erstellen",
|
||||||
|
"menuEditor.categoryPicker.empty": "Keine passenden Kategorien gefunden.",
|
||||||
|
"menuEditor.type.page": "Seite",
|
||||||
|
"menuEditor.type.home": "Startseite",
|
||||||
|
"menuEditor.type.submenu": "Untermenü",
|
||||||
|
"menuEditor.type.categoryArchive": "Kategorie-Archiv",
|
||||||
"chat.newChat": "Neuer Chat",
|
"chat.newChat": "Neuer Chat",
|
||||||
"chat.setupTitle": "KI-Chat-Einrichtung",
|
"chat.setupTitle": "KI-Chat-Einrichtung",
|
||||||
"chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich",
|
"chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich",
|
||||||
|
|||||||
@@ -64,6 +64,35 @@
|
|||||||
"translationValidation.revalidate": "Revalidate",
|
"translationValidation.revalidate": "Revalidate",
|
||||||
"translationValidation.fix": "Fix Issues",
|
"translationValidation.fix": "Fix Issues",
|
||||||
"translationValidation.toast.fixSuccess": "Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk",
|
"translationValidation.toast.fixSuccess": "Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk",
|
||||||
|
"menuEditor.tabTitle": "Blog Menu",
|
||||||
|
"menuEditor.title": "Blog Menu Editor",
|
||||||
|
"menuEditor.description": "Manage the central blog navigation outline and save it to meta/menu.opml.",
|
||||||
|
"menuEditor.save": "Save Menu",
|
||||||
|
"menuEditor.saved": "Blog menu saved",
|
||||||
|
"menuEditor.addEntry": "Add Entry",
|
||||||
|
"menuEditor.addCategoryArchive": "Add Category Archive",
|
||||||
|
"menuEditor.addCategoryArchiveShort": "C+",
|
||||||
|
"menuEditor.addSubmenu": "Add Submenu",
|
||||||
|
"menuEditor.moveUp": "Move Up",
|
||||||
|
"menuEditor.moveDown": "Move Down",
|
||||||
|
"menuEditor.indent": "Indent",
|
||||||
|
"menuEditor.unindent": "Unindent",
|
||||||
|
"menuEditor.delete": "Delete",
|
||||||
|
"menuEditor.newEntryPlaceholder": "Type a page title or submenu label",
|
||||||
|
"menuEditor.newCategoryPlaceholder": "Type a category name",
|
||||||
|
"menuEditor.createHint": "Select a page below or press Enter to create a submenu",
|
||||||
|
"menuEditor.dragHandle": "Drag menu item",
|
||||||
|
"menuEditor.empty": "No menu entries yet. Add a page or submenu to start.",
|
||||||
|
"menuEditor.newPage": "New Page",
|
||||||
|
"menuEditor.newSubmenu": "New Submenu",
|
||||||
|
"menuEditor.pagePicker.title": "Select Page",
|
||||||
|
"menuEditor.pagePicker.empty": "No matching pages found.",
|
||||||
|
"menuEditor.categoryPicker.hint": "Select an existing category or press Enter to create a new archive entry",
|
||||||
|
"menuEditor.categoryPicker.empty": "No matching categories found.",
|
||||||
|
"menuEditor.type.page": "Page",
|
||||||
|
"menuEditor.type.home": "Home",
|
||||||
|
"menuEditor.type.submenu": "Submenu",
|
||||||
|
"menuEditor.type.categoryArchive": "Category Archive",
|
||||||
"chat.newChat": "New Chat",
|
"chat.newChat": "New Chat",
|
||||||
"chat.setupTitle": "AI Chat Setup",
|
"chat.setupTitle": "AI Chat Setup",
|
||||||
"chat.apiKeyRequiredTitle": "API Key Required",
|
"chat.apiKeyRequiredTitle": "API Key Required",
|
||||||
|
|||||||
@@ -64,6 +64,35 @@
|
|||||||
"translationValidation.revalidate": "Revalidar",
|
"translationValidation.revalidate": "Revalidar",
|
||||||
"translationValidation.fix": "Corregir problemas",
|
"translationValidation.fix": "Corregir problemas",
|
||||||
"translationValidation.toast.fixSuccess": "%{dbRows} filas de BD y %{files} archivos eliminados, %{flushed} traducciones escritas a disco",
|
"translationValidation.toast.fixSuccess": "%{dbRows} filas de BD y %{files} archivos eliminados, %{flushed} traducciones escritas a disco",
|
||||||
|
"menuEditor.tabTitle": "Menú del blog",
|
||||||
|
"menuEditor.title": "Editor del menú del blog",
|
||||||
|
"menuEditor.description": "Gestiona la estructura central de navegación del blog y guárdala en meta/menu.opml.",
|
||||||
|
"menuEditor.save": "Guardar menú",
|
||||||
|
"menuEditor.saved": "Menú del blog guardado",
|
||||||
|
"menuEditor.addEntry": "Agregar entrada",
|
||||||
|
"menuEditor.addCategoryArchive": "Agregar archivo de categoría",
|
||||||
|
"menuEditor.addCategoryArchiveShort": "C+",
|
||||||
|
"menuEditor.addSubmenu": "Agregar submenú",
|
||||||
|
"menuEditor.moveUp": "Subir",
|
||||||
|
"menuEditor.moveDown": "Bajar",
|
||||||
|
"menuEditor.indent": "Indentar",
|
||||||
|
"menuEditor.unindent": "Desindentar",
|
||||||
|
"menuEditor.delete": "Eliminar",
|
||||||
|
"menuEditor.newEntryPlaceholder": "Escribe un título de página o una etiqueta de submenú",
|
||||||
|
"menuEditor.newCategoryPlaceholder": "Escribe un nombre de categoría",
|
||||||
|
"menuEditor.createHint": "Selecciona una página abajo o pulsa Enter para crear un submenú",
|
||||||
|
"menuEditor.dragHandle": "Arrastrar elemento del menú",
|
||||||
|
"menuEditor.empty": "Todavía no hay entradas de menú. Agrega una página o un submenú para empezar.",
|
||||||
|
"menuEditor.newPage": "Nueva página",
|
||||||
|
"menuEditor.newSubmenu": "Nuevo submenú",
|
||||||
|
"menuEditor.pagePicker.title": "Seleccionar página",
|
||||||
|
"menuEditor.pagePicker.empty": "No se encontraron páginas coincidentes.",
|
||||||
|
"menuEditor.categoryPicker.hint": "Selecciona una categoría existente o pulsa Enter para crear una nueva entrada de archivo",
|
||||||
|
"menuEditor.categoryPicker.empty": "No se encontraron categorías coincidentes.",
|
||||||
|
"menuEditor.type.page": "Página",
|
||||||
|
"menuEditor.type.home": "Inicio",
|
||||||
|
"menuEditor.type.submenu": "Submenú",
|
||||||
|
"menuEditor.type.categoryArchive": "Archivo de categoría",
|
||||||
"chat.newChat": "Nuevo chat",
|
"chat.newChat": "Nuevo chat",
|
||||||
"chat.setupTitle": "Configuración de chat IA",
|
"chat.setupTitle": "Configuración de chat IA",
|
||||||
"chat.apiKeyRequiredTitle": "Clave API requerida",
|
"chat.apiKeyRequiredTitle": "Clave API requerida",
|
||||||
|
|||||||
@@ -64,6 +64,35 @@
|
|||||||
"translationValidation.revalidate": "Revalider",
|
"translationValidation.revalidate": "Revalider",
|
||||||
"translationValidation.fix": "Corriger les problèmes",
|
"translationValidation.fix": "Corriger les problèmes",
|
||||||
"translationValidation.toast.fixSuccess": "%{dbRows} lignes DB et %{files} fichiers supprimés, %{flushed} traductions écrites sur disque",
|
"translationValidation.toast.fixSuccess": "%{dbRows} lignes DB et %{files} fichiers supprimés, %{flushed} traductions écrites sur disque",
|
||||||
|
"menuEditor.tabTitle": "Menu du blog",
|
||||||
|
"menuEditor.title": "Éditeur du menu du blog",
|
||||||
|
"menuEditor.description": "Gérez la structure centrale de navigation du blog et enregistrez-la dans meta/menu.opml.",
|
||||||
|
"menuEditor.save": "Enregistrer le menu",
|
||||||
|
"menuEditor.saved": "Menu du blog enregistré",
|
||||||
|
"menuEditor.addEntry": "Ajouter une entrée",
|
||||||
|
"menuEditor.addCategoryArchive": "Ajouter une archive de catégorie",
|
||||||
|
"menuEditor.addCategoryArchiveShort": "C+",
|
||||||
|
"menuEditor.addSubmenu": "Ajouter un sous-menu",
|
||||||
|
"menuEditor.moveUp": "Monter",
|
||||||
|
"menuEditor.moveDown": "Descendre",
|
||||||
|
"menuEditor.indent": "Indenter",
|
||||||
|
"menuEditor.unindent": "Désindenter",
|
||||||
|
"menuEditor.delete": "Supprimer",
|
||||||
|
"menuEditor.newEntryPlaceholder": "Saisissez un titre de page ou un libellé de sous-menu",
|
||||||
|
"menuEditor.newCategoryPlaceholder": "Saisissez un nom de catégorie",
|
||||||
|
"menuEditor.createHint": "Sélectionnez une page ci-dessous ou appuyez sur Entrée pour créer un sous-menu",
|
||||||
|
"menuEditor.dragHandle": "Faire glisser l’entrée du menu",
|
||||||
|
"menuEditor.empty": "Aucune entrée de menu pour le moment. Ajoutez une page ou un sous-menu pour commencer.",
|
||||||
|
"menuEditor.newPage": "Nouvelle page",
|
||||||
|
"menuEditor.newSubmenu": "Nouveau sous-menu",
|
||||||
|
"menuEditor.pagePicker.title": "Sélectionner une page",
|
||||||
|
"menuEditor.pagePicker.empty": "Aucune page correspondante trouvée.",
|
||||||
|
"menuEditor.categoryPicker.hint": "Sélectionnez une catégorie existante ou appuyez sur Entrée pour créer une nouvelle entrée d’archive",
|
||||||
|
"menuEditor.categoryPicker.empty": "Aucune catégorie correspondante trouvée.",
|
||||||
|
"menuEditor.type.page": "Page",
|
||||||
|
"menuEditor.type.home": "Accueil",
|
||||||
|
"menuEditor.type.submenu": "Sous-menu",
|
||||||
|
"menuEditor.type.categoryArchive": "Archive de catégorie",
|
||||||
"chat.newChat": "Nouveau chat",
|
"chat.newChat": "Nouveau chat",
|
||||||
"chat.welcomeTitle": "Bienvenue dans l’assistant IA",
|
"chat.welcomeTitle": "Bienvenue dans l’assistant IA",
|
||||||
"chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :",
|
"chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :",
|
||||||
|
|||||||
@@ -64,6 +64,35 @@
|
|||||||
"translationValidation.revalidate": "Rivalidare",
|
"translationValidation.revalidate": "Rivalidare",
|
||||||
"translationValidation.fix": "Correggi problemi",
|
"translationValidation.fix": "Correggi problemi",
|
||||||
"translationValidation.toast.fixSuccess": "%{dbRows} righe DB e %{files} file eliminati, %{flushed} traduzioni scritte su disco",
|
"translationValidation.toast.fixSuccess": "%{dbRows} righe DB e %{files} file eliminati, %{flushed} traduzioni scritte su disco",
|
||||||
|
"menuEditor.tabTitle": "Menu del blog",
|
||||||
|
"menuEditor.title": "Editor del menu del blog",
|
||||||
|
"menuEditor.description": "Gestisci la struttura centrale di navigazione del blog e salvala in meta/menu.opml.",
|
||||||
|
"menuEditor.save": "Salva menu",
|
||||||
|
"menuEditor.saved": "Menu del blog salvato",
|
||||||
|
"menuEditor.addEntry": "Aggiungi voce",
|
||||||
|
"menuEditor.addCategoryArchive": "Aggiungi archivio categoria",
|
||||||
|
"menuEditor.addCategoryArchiveShort": "C+",
|
||||||
|
"menuEditor.addSubmenu": "Aggiungi sottomenu",
|
||||||
|
"menuEditor.moveUp": "Sposta su",
|
||||||
|
"menuEditor.moveDown": "Sposta giù",
|
||||||
|
"menuEditor.indent": "Rientra",
|
||||||
|
"menuEditor.unindent": "Riduci rientro",
|
||||||
|
"menuEditor.delete": "Elimina",
|
||||||
|
"menuEditor.newEntryPlaceholder": "Digita un titolo pagina o un'etichetta del sottomenu",
|
||||||
|
"menuEditor.newCategoryPlaceholder": "Digita un nome categoria",
|
||||||
|
"menuEditor.createHint": "Seleziona una pagina qui sotto o premi Invio per creare un sottomenu",
|
||||||
|
"menuEditor.dragHandle": "Trascina voce di menu",
|
||||||
|
"menuEditor.empty": "Nessuna voce di menu ancora. Aggiungi una pagina o un sottomenu per iniziare.",
|
||||||
|
"menuEditor.newPage": "Nuova pagina",
|
||||||
|
"menuEditor.newSubmenu": "Nuovo sottomenu",
|
||||||
|
"menuEditor.pagePicker.title": "Seleziona pagina",
|
||||||
|
"menuEditor.pagePicker.empty": "Nessuna pagina corrispondente trovata.",
|
||||||
|
"menuEditor.categoryPicker.hint": "Seleziona una categoria esistente o premi Invio per creare una nuova voce di archivio",
|
||||||
|
"menuEditor.categoryPicker.empty": "Nessuna categoria corrispondente trovata.",
|
||||||
|
"menuEditor.type.page": "Pagina",
|
||||||
|
"menuEditor.type.home": "Home",
|
||||||
|
"menuEditor.type.submenu": "Sottomenu",
|
||||||
|
"menuEditor.type.categoryArchive": "Archivio categoria",
|
||||||
"chat.newChat": "Nuova chat",
|
"chat.newChat": "Nuova chat",
|
||||||
"chat.setupTitle": "Configurazione chat IA",
|
"chat.setupTitle": "Configurazione chat IA",
|
||||||
"chat.apiKeyRequiredTitle": "Chiave API richiesta",
|
"chat.apiKeyRequiredTitle": "Chiave API richiesta",
|
||||||
|
|||||||
289
priv/ui/app.css
289
priv/ui/app.css
@@ -2550,6 +2550,295 @@ button svg * {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-editor-view {
|
||||||
|
padding: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-header p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tree-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tool {
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tool:hover:not(:disabled) {
|
||||||
|
background: var(--vscode-toolbar-hoverBackground);
|
||||||
|
border-color: var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tool:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tree-shell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tree-level {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tree-item {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row {
|
||||||
|
--menu-editor-indent: calc(var(--menu-editor-depth) * 1rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.3rem 0.45rem 0.3rem calc(0.4rem + var(--menu-editor-indent));
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-selected {
|
||||||
|
background: var(--vscode-list-activeSelectionBackground);
|
||||||
|
color: var(--vscode-list-activeSelectionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-drop-before::before,
|
||||||
|
.menu-editor-row.is-drop-after::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: calc(0.4rem + var(--menu-editor-indent));
|
||||||
|
right: 0.45rem;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-drop-before::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-drop-after::after {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-drop-inside {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--vscode-focusBorder);
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
min-width: 1rem;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-kind {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
min-width: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-title.is-editing {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-entry-form {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--vscode-focusBorder);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
padding: 0.25rem 0.45rem;
|
||||||
|
min-height: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-search {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
max-height: 18rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-search-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-search-head strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-search-head span {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-action {
|
||||||
|
border: 1px solid var(--vscode-button-border, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-action:hover {
|
||||||
|
background: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item:hover {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item small,
|
||||||
|
.menu-editor-picker-state {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-empty {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.menu-editor-inline-search-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-testid="media-editor"] {
|
[data-testid="media-editor"] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
135
priv/ui/live.js
135
priv/ui/live.js
@@ -790,6 +790,141 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
MenuEditorTree: {
|
||||||
|
mounted() {
|
||||||
|
this.dragItemId = null;
|
||||||
|
this.dragSourceEl = null;
|
||||||
|
this.dropTargetEl = null;
|
||||||
|
this.dropPosition = null;
|
||||||
|
|
||||||
|
this.clearDropTarget = () => {
|
||||||
|
if (this.dropTargetEl) {
|
||||||
|
this.dropTargetEl.classList.remove("is-drop-before", "is-drop-after", "is-drop-inside");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dropTargetEl = null;
|
||||||
|
this.dropPosition = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setDropTarget = (row, position) => {
|
||||||
|
if (this.dropTargetEl === row && this.dropPosition === position) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearDropTarget();
|
||||||
|
this.dropTargetEl = row;
|
||||||
|
this.dropPosition = position;
|
||||||
|
row.classList.add(`is-drop-${position}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDragStart = (event) => {
|
||||||
|
const handle = event.target.closest("[data-menu-drag-handle='true']");
|
||||||
|
const row = event.target.closest("[data-menu-item-id]");
|
||||||
|
|
||||||
|
if (!handle || !row || !this.el.contains(row)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dragItemId = row.dataset.menuItemId || null;
|
||||||
|
this.dragSourceEl = row;
|
||||||
|
row.classList.add("is-dragging");
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", this.dragItemId || "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDragOver = (event) => {
|
||||||
|
const row = event.target.closest("[data-menu-item-id]");
|
||||||
|
|
||||||
|
if (!this.dragItemId || !row || !this.el.contains(row)) {
|
||||||
|
this.clearDropTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetItemId = row.dataset.menuItemId || "";
|
||||||
|
|
||||||
|
if (!targetItemId || targetItemId === this.dragItemId) {
|
||||||
|
this.clearDropTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const offsetY = event.clientY - rect.top;
|
||||||
|
const allowInside = row.dataset.menuCanDropInside === "true";
|
||||||
|
const insideBandTop = rect.height * 0.3;
|
||||||
|
const insideBandBottom = rect.height * 0.7;
|
||||||
|
|
||||||
|
const position =
|
||||||
|
allowInside && offsetY >= insideBandTop && offsetY <= insideBandBottom
|
||||||
|
? "inside"
|
||||||
|
: offsetY < rect.height / 2
|
||||||
|
? "before"
|
||||||
|
: "after";
|
||||||
|
|
||||||
|
this.setDropTarget(row, position);
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDrop = (event) => {
|
||||||
|
const row = event.target.closest("[data-menu-item-id]");
|
||||||
|
|
||||||
|
if (!this.dragItemId || !row || !this.el.contains(row) || !this.dropPosition) {
|
||||||
|
this.clearDropTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
this.pushEvent("menu_editor_drop_item", {
|
||||||
|
drag_item_id: this.dragItemId,
|
||||||
|
target_item_id: row.dataset.menuItemId,
|
||||||
|
position: this.dropPosition
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clearDropTarget();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDragLeave = (event) => {
|
||||||
|
const related = event.relatedTarget;
|
||||||
|
|
||||||
|
if (this.dropTargetEl && (!related || !this.dropTargetEl.contains(related))) {
|
||||||
|
this.clearDropTarget();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDragEnd = () => {
|
||||||
|
if (this.dragSourceEl) {
|
||||||
|
this.dragSourceEl.classList.remove("is-dragging");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dragItemId = null;
|
||||||
|
this.dragSourceEl = null;
|
||||||
|
this.clearDropTarget();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.el.addEventListener("dragstart", this.handleDragStart);
|
||||||
|
this.el.addEventListener("dragover", this.handleDragOver);
|
||||||
|
this.el.addEventListener("drop", this.handleDrop);
|
||||||
|
this.el.addEventListener("dragleave", this.handleDragLeave);
|
||||||
|
this.el.addEventListener("dragend", this.handleDragEnd);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
this.el.removeEventListener("dragstart", this.handleDragStart);
|
||||||
|
this.el.removeEventListener("dragover", this.handleDragOver);
|
||||||
|
this.el.removeEventListener("drop", this.handleDrop);
|
||||||
|
this.el.removeEventListener("dragleave", this.handleDragLeave);
|
||||||
|
this.el.removeEventListener("dragend", this.handleDragEnd);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
MonacoEditor: {
|
MonacoEditor: {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
|
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
alias BDS.AI
|
alias BDS.AI
|
||||||
|
alias BDS.Menu
|
||||||
alias BDS.Media
|
alias BDS.Media
|
||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
alias BDS.Posts
|
alias BDS.Posts
|
||||||
@@ -375,6 +376,114 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
|
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "native edit menu action opens the dedicated menu editor surface", %{project: project} do
|
||||||
|
assert {:ok, _menu} =
|
||||||
|
Menu.update_menu(project.id, [
|
||||||
|
%{kind: :page, label: "About", slug: "about"},
|
||||||
|
%{
|
||||||
|
kind: :submenu,
|
||||||
|
label: "Sections",
|
||||||
|
children: [
|
||||||
|
%{kind: :page, label: "Contact", slug: "contact"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
html = render_hook(view, "native_menu_action", %{"action" => "edit_menu"})
|
||||||
|
|
||||||
|
assert html =~ ~s(data-testid="menu-editor")
|
||||||
|
assert html =~ "Blog Menu Editor"
|
||||||
|
assert html =~ "meta/menu.opml"
|
||||||
|
assert html =~ ~s(data-testid="menu-editor-toolbar")
|
||||||
|
assert html =~ ~s(data-testid="menu-editor-toolbar-button")
|
||||||
|
assert html =~ ~s(data-action="add-entry")
|
||||||
|
assert html =~ ~s(data-action="save")
|
||||||
|
assert html =~ ~s(data-action="indent")
|
||||||
|
assert html =~ ~s(data-action="unindent")
|
||||||
|
assert html =~ ~s(data-testid="menu-editor-row")
|
||||||
|
assert html =~ ~s(data-menu-label="Home")
|
||||||
|
assert html =~ ~s(data-menu-label="About")
|
||||||
|
assert html =~ ~s(data-menu-label="Sections")
|
||||||
|
refute html =~ "Desktop workbench content routed through the Elixir shell."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "menu editor adds a submenu, nests an entry, and saves the opml", %{
|
||||||
|
project: project,
|
||||||
|
temp_dir: temp_dir
|
||||||
|
} do
|
||||||
|
assert {:ok, _menu} =
|
||||||
|
Menu.update_menu(project.id, [
|
||||||
|
%{kind: :page, label: "Contact", slug: "contact"}
|
||||||
|
])
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
_html = render_hook(view, "native_menu_action", %{"action" => "edit_menu"})
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='menu-editor-toolbar-button'][data-action='add-entry']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ ~s(data-testid="menu-editor-entry-form")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("[data-testid='menu-editor-entry-form']", %{
|
||||||
|
menu_editor_entry: %{"query" => "Sections"}
|
||||||
|
})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
assert html =~ ~s(value="Sections")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("[data-testid='menu-editor-entry-form']", %{
|
||||||
|
menu_editor_entry: %{"query" => "Sections"}
|
||||||
|
})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert html =~ ~s(data-menu-label="Sections")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='menu-editor-row'][data-menu-label='Contact']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ ~s(data-selected="true")
|
||||||
|
|
||||||
|
_html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='menu-editor-toolbar-button'][data-action='indent']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
_html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='menu-editor-toolbar-button'][data-action='save']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert {:ok, menu} = Menu.get_menu(project.id)
|
||||||
|
|
||||||
|
assert menu.items == [
|
||||||
|
%{kind: :home, label: "Home", slug: nil},
|
||||||
|
%{
|
||||||
|
kind: :submenu,
|
||||||
|
label: "Sections",
|
||||||
|
slug: nil,
|
||||||
|
children: [
|
||||||
|
%{kind: :page, label: "Contact", slug: "contact"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
opml_path = Path.join([temp_dir, "meta", "menu.opml"])
|
||||||
|
contents = File.read!(opml_path)
|
||||||
|
|
||||||
|
assert contents =~ ~s(<outline text="Sections" type="submenu">)
|
||||||
|
assert contents =~ ~s(<outline text="Contact" type="page" pageSlug="contact")
|
||||||
|
end
|
||||||
|
|
||||||
test "workbench session restore reopens permanent and transient tabs and selected activity" do
|
test "workbench session restore reopens permanent and transient tabs and selected activity" do
|
||||||
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user