diff --git a/PLAN.md b/PLAN.md index c6b1e56..b6ddb54 100644 --- a/PLAN.md +++ b/PLAN.md @@ -4,10 +4,10 @@ This document tracks the current implementation state of bDS2 against the Allium ## Open Work Summary -- Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9. -- Open plan steps: 10, 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. -- Scheduled after the current parity pass: 11 desktop-side CLI mutation watching, 12 import execution/editor parity. +- Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10. +- Open plan steps: 11, 12. +- Next actionable step: 11. The remaining open parity backlog now starts with desktop-side CLI mutation watching. +- Scheduled after the current parity pass: 12 import execution/editor parity. ## Current State @@ -31,7 +31,6 @@ The rewrite already implements most of the backend and compatibility-critical su ### 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. -- 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. - 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. | | 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. | -| 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 -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. 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. 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. - 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. +10. Restore menu editor parity on the implemented data model. Completed 2026-04-29. + 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. 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. diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index bc8022a..ca86e8b 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -7,7 +7,7 @@ defmodule BDS.Desktop.ShellLive do alias BDS.AI 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.PostEditor 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)} 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 {:noreply, TagsEditor.toggle_selection(socket, tag_name, &reload_shell/2)} end @@ -1205,6 +1245,7 @@ defmodule BDS.Desktop.ShellLive do |> assign_post_editor() |> assign_media_editor() |> assign_settings_editor() + |> assign_menu_editor() |> assign_tags_editor() |> assign_code_entity_editor() |> assign_chat_editor() @@ -1444,6 +1485,10 @@ defmodule BDS.Desktop.ShellLive do SettingsEditor.assign_socket(socket) end + defp assign_menu_editor(socket) do + MenuEditor.assign_socket(socket) + end + defp assign_tags_editor(socket) do TagsEditor.assign_socket(socket) end diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index f52078a..2a96bc4 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -394,6 +394,9 @@ <% @current_tab.type == :style and @style_editor -> %> + <% @current_tab.type == :menu_editor and @menu_editor -> %> + + <% @current_tab.type == :tags and @tags_editor -> %> diff --git a/lib/bds/desktop/shell_live/menu_editor.ex b/lib/bds/desktop/shell_live/menu_editor.ex new file mode 100644 index 0000000..a506b67 --- /dev/null +++ b/lib/bds/desktop/shell_live/menu_editor.ex @@ -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 %> + + <% end %> + """ + end + + attr :kind, :atom, required: true + + def kind_icon(assigns) do + ~H""" + <%= case @kind do %> + <% :home -> %> + + <% :page -> %> + + <% :category_archive -> %> + + <% _other -> %> + + <% 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 diff --git a/lib/bds/desktop/shell_live/menu_editor_html/menu_editor.html.heex b/lib/bds/desktop/shell_live/menu_editor_html/menu_editor.html.heex new file mode 100644 index 0000000..db7232c --- /dev/null +++ b/lib/bds/desktop/shell_live/menu_editor_html/menu_editor.html.heex @@ -0,0 +1,56 @@ + \ No newline at end of file diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json index d66798e..68f951a 100644 --- a/priv/i18n/locales/de.json +++ b/priv/i18n/locales/de.json @@ -64,6 +64,35 @@ "translationValidation.revalidate": "Erneut validieren", "translationValidation.fix": "Probleme beheben", "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.setupTitle": "KI-Chat-Einrichtung", "chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich", diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json index 43b2e12..20b0258 100644 --- a/priv/i18n/locales/en.json +++ b/priv/i18n/locales/en.json @@ -64,6 +64,35 @@ "translationValidation.revalidate": "Revalidate", "translationValidation.fix": "Fix Issues", "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.setupTitle": "AI Chat Setup", "chat.apiKeyRequiredTitle": "API Key Required", diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json index c5000f3..213ee7e 100644 --- a/priv/i18n/locales/es.json +++ b/priv/i18n/locales/es.json @@ -64,6 +64,35 @@ "translationValidation.revalidate": "Revalidar", "translationValidation.fix": "Corregir problemas", "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.setupTitle": "Configuración de chat IA", "chat.apiKeyRequiredTitle": "Clave API requerida", diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json index b3eeb09..a4cfbab 100644 --- a/priv/i18n/locales/fr.json +++ b/priv/i18n/locales/fr.json @@ -64,6 +64,35 @@ "translationValidation.revalidate": "Revalider", "translationValidation.fix": "Corriger les problèmes", "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.welcomeTitle": "Bienvenue dans l’assistant IA", "chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :", diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json index d0a623f..c1740ac 100644 --- a/priv/i18n/locales/it.json +++ b/priv/i18n/locales/it.json @@ -64,6 +64,35 @@ "translationValidation.revalidate": "Rivalidare", "translationValidation.fix": "Correggi problemi", "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.setupTitle": "Configurazione chat IA", "chat.apiKeyRequiredTitle": "Chiave API richiesta", diff --git a/priv/ui/app.css b/priv/ui/app.css index b0954b1..85e8608 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -2550,6 +2550,295 @@ button svg * { 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"] { flex: 1; display: flex; diff --git a/priv/ui/live.js b/priv/ui/live.js index 0f7db45..d80ceb1 100644 --- a/priv/ui/live.js +++ b/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: { mounted() { this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 212a545..a3663d3 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLiveTest do alias BDS.Persistence alias BDS.AI + alias BDS.Menu alias BDS.Media alias BDS.Metadata alias BDS.Posts @@ -375,6 +376,114 @@ defmodule BDS.Desktop.ShellLiveTest do refute html =~ ~s(data-testid="window-titlebar-menu-dropdown") 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() + assert contents =~ ~s(