feat: step 10 done (claimed)

This commit is contained in:
2026-04-29 19:09:54 +02:00
parent dccb6a8786
commit 4ae6c55e83
13 changed files with 1662 additions and 10 deletions

17
PLAN.md
View File

@@ -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.

View File

@@ -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

View File

@@ -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} />

View 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

View File

@@ -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>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 lentré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 darchive",
"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 lassistant IA", "chat.welcomeTitle": "Bienvenue dans lassistant 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 :",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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");

View File

@@ -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)