From 92a942d52f18ddecdd51ca1ef6a2fbf8feda8345 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 19 Jul 2026 19:37:00 +0200 Subject: [PATCH] Implement the OPML menu editor --- README.md | 1 + RUST_PLAN_CORE.md | 2 +- RUST_PLAN_EXTENSION.md | 9 +- crates/bds-core/src/engine/menu.rs | 194 ++- crates/bds-core/src/render/site.rs | 47 +- crates/bds-ui/assets/icons/menu-category.svg | 3 + crates/bds-ui/assets/icons/menu-home.svg | 3 + crates/bds-ui/assets/icons/menu-page.svg | 3 + crates/bds-ui/assets/icons/menu-submenu.svg | 3 + crates/bds-ui/src/app.rs | 312 ++++- crates/bds-ui/src/views/menu_editor.rs | 1234 ++++++++++++++++++ crates/bds-ui/src/views/mod.rs | 1 + crates/bds-ui/src/views/workspace.rs | 36 +- locales/ui/de.ftl | 30 + locales/ui/en.ftl | 30 + locales/ui/es.ftl | 30 + locales/ui/fr.ftl | 30 + locales/ui/it.ftl | 30 + 18 files changed, 1955 insertions(+), 43 deletions(-) create mode 100644 crates/bds-ui/assets/icons/menu-category.svg create mode 100644 crates/bds-ui/assets/icons/menu-home.svg create mode 100644 crates/bds-ui/assets/icons/menu-page.svg create mode 100644 crates/bds-ui/assets/icons/menu-submenu.svg create mode 100644 crates/bds-ui/src/views/menu_editor.rs diff --git a/README.md b/README.md index e727914..2a5ac54 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The project is under active development. Core blogging workflows are broadly ava - SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search. - Optional on-device multilingual semantic search and tag suggestions backed by a persistent USearch index, with duplicate-post review and dismissal in the desktop workspace. - Read-only in-app browsers for the bundled global `DOCUMENTATION.md` and the generated Lua API reference, public types, and runnable examples, with safe GFM rendering and confirmed external links. +- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence. - Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings. - Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`. - Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration. diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index 8588441..b36ab3d 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -88,7 +88,7 @@ Available: - User-authored Lua macro invocation during rendering. - Localhost-only Axum preview server, draft routes, embedded Wry preview, and external-browser preview. - Complete site generation with pages, archives, feeds, sitemap, static assets, changed-file tracking, parallel page rendering, and Pagefind output; full generation and validation apply run as grouped section tasks followed by search indexing. -- OPML/menu document loading and normalized Home-first menu output. +- Canonical bDS2-compatible OPML/menu document loading, recursive Home-first normalization, and renderer consumption of the saved tree. Open: diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index 353096e..ac7f0ad 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -60,18 +60,15 @@ Done: - Safe native GFM rendering for headings, links, code blocks, lists, and tables, including in-document anchors and confirmed external links without HTML, CSS, or JavaScript execution. - Project switches preserve the singleton global documentation tabs and their loaded content. -### Menu editor and deep links — Partly done +### Menu editor and deep links — Done Done: - Menu file parsing/rendering and Home-first normalization. +- Localized OPML tree editor with page/submenu/category drafts, metadata-backed new categories, sibling moves, indent/unindent, protected Home, validated drag/drop, delayed submenu expansion, save/reload, and accessible equivalent controls. +- Canonical bDS2 OPML attributes and recursive normalization feed the same renderer used by preview and generation. - macOS URL plumbing and the sole bDS2-compatible Blogmark action at `ruds://new-post`; RuDS neither registers nor accepts bDS2's `bds2://` scheme. -Open: - -- OPML/menu editor UI. -- Replace the Menu Editor placeholder. - ### CLI, domain events, and MCP — Done Done: diff --git a/crates/bds-core/src/engine/menu.rs b/crates/bds-core/src/engine/menu.rs index 85132a9..042934d 100644 --- a/crates/bds-core/src/engine/menu.rs +++ b/crates/bds-core/src/engine/menu.rs @@ -31,7 +31,7 @@ impl MenuItemKind { Self::Home => "home", Self::Page => "page", Self::Submenu => "submenu", - Self::CategoryArchive => "category_archive", + Self::CategoryArchive => "category-archive", } } @@ -39,7 +39,7 @@ impl MenuItemKind { match s { "home" => Self::Home, "submenu" => Self::Submenu, - "category_archive" => Self::CategoryArchive, + "category-archive" | "category_archive" => Self::CategoryArchive, _ => Self::Page, } } @@ -83,11 +83,7 @@ pub fn default_menu_opml() -> String { /// Per menu.allium HomeAlwaysPresent: ensure Home is always first. fn normalize_menu(items: &[MenuItem]) -> Vec { - let without_home: Vec = items - .iter() - .filter(|i| i.kind != MenuItemKind::Home) - .cloned() - .collect(); + let without_home: Vec<_> = items.iter().filter_map(normalize_non_home).collect(); let mut result = vec![MenuItem { kind: MenuItemKind::Home, label: "Home".to_string(), @@ -98,29 +94,70 @@ fn normalize_menu(items: &[MenuItem]) -> Vec { result } +fn normalize_non_home(item: &MenuItem) -> Option { + if item.kind == MenuItemKind::Home { + return None; + } + Some(MenuItem { + kind: item.kind.clone(), + label: item.label.clone(), + slug: match item.kind { + MenuItemKind::Page | MenuItemKind::CategoryArchive => item.slug.clone(), + MenuItemKind::Home | MenuItemKind::Submenu => None, + }, + children: if item.kind == MenuItemKind::Submenu { + item.children + .iter() + .filter_map(normalize_non_home) + .collect() + } else { + Vec::new() + }, + }) +} + /// Parse OPML 2.0 XML into menu items. fn parse_opml(content: &str) -> EngineResult> { let mut reader = Reader::from_str(content); reader.config_mut().trim_text(true); let mut items = Vec::new(); - let mut parents = Vec::new(); - let mut in_body = false; + let mut outlines: Vec> = Vec::new(); + let mut elements: Vec> = Vec::new(); loop { match reader.read_event() { - Ok(Event::Start(event)) if event.name().as_ref() == b"body" => in_body = true, - Ok(Event::End(event)) if event.name().as_ref() == b"body" => in_body = false, - Ok(Event::Start(event)) if in_body && event.name().as_ref() == b"outline" => { - parents.push(parse_outline(&event)?); + Ok(Event::Start(event)) => { + let name = event.name().as_ref().to_vec(); + if name == b"outline" { + let collect = collect_outline(&elements, &outlines); + outlines.push(collect.then(|| parse_outline(&event)).transpose()?); + } + elements.push(name); } - Ok(Event::Empty(event)) if in_body && event.name().as_ref() == b"outline" => { - attach_outline(parse_outline(&event)?, &mut parents, &mut items); + Ok(Event::Empty(event)) if event.name().as_ref() == b"outline" => { + if collect_outline(&elements, &outlines) { + attach_outline(parse_outline(&event)?, &mut outlines, &mut items); + } } - Ok(Event::End(event)) if event.name().as_ref() == b"outline" => { - let item = parents + Ok(Event::End(event)) => { + let name = event.name().as_ref().to_vec(); + let open = elements .pop() - .ok_or_else(|| EngineError::Parse("unexpected ".to_string()))?; - attach_outline(item, &mut parents, &mut items); + .ok_or_else(|| EngineError::Parse("unexpected closing element".to_string()))?; + if open != name { + return Err(EngineError::Parse("mismatched closing element".to_string())); + } + if name == b"outline" { + let item = outlines + .pop() + .ok_or_else(|| EngineError::Parse("unexpected ".to_string()))?; + if let Some(mut item) = item { + if item.kind != MenuItemKind::Submenu { + item.children.clear(); + } + attach_outline(item, &mut outlines, &mut items); + } + } } Ok(Event::Eof) => break, Ok(_) => {} @@ -128,16 +165,26 @@ fn parse_opml(content: &str) -> EngineResult> { } } - if !parents.is_empty() { + if !outlines.is_empty() || !elements.is_empty() { return Err(EngineError::Parse("unclosed ".to_string())); } Ok(normalize_menu(&items)) } +fn collect_outline(elements: &[Vec], outlines: &[Option]) -> bool { + elements == [b"opml".as_slice(), b"body".as_slice()] + || (elements.last().is_some_and(|name| name == b"outline") + && outlines.last().is_some_and(Option::is_some)) +} + fn parse_outline(event: &BytesStart<'_>) -> EngineResult { - let mut label = "Untitled".to_string(); - let mut kind = MenuItemKind::Page; - let mut slug = None; + let mut label = String::new(); + let mut kind_value = None; + let mut legacy_kind = None; + let mut page_slug = None; + let mut category_name = None; + let mut legacy_slug = None; + let mut html_url = None; for attribute in event.attributes() { let attribute = attribute.map_err(|error| EngineError::Parse(error.to_string()))?; @@ -147,12 +194,28 @@ fn parse_outline(event: &BytesStart<'_>) -> EngineResult { .into_owned(); match attribute.key.as_ref() { b"text" => label = value, - b"type" => kind = MenuItemKind::from_str(&value), - b"htmlUrl" => slug = Some(value), + b"type" => kind_value = Some(value), + b"kind" => legacy_kind = Some(value), + b"pageSlug" => page_slug = Some(value), + b"categoryName" => category_name = Some(value), + b"slug" => legacy_slug = Some(value), + b"htmlUrl" => html_url = Some(value), _ => {} } } + let kind = kind_value + .or(legacy_kind) + .as_deref() + .map(MenuItemKind::from_str) + .unwrap_or(MenuItemKind::Page); + let slug = match kind { + MenuItemKind::Home | MenuItemKind::Submenu => None, + MenuItemKind::Page => page_slug.or(legacy_slug).or(html_url), + MenuItemKind::CategoryArchive => category_name.or(legacy_slug).or(html_url), + } + .filter(|value| !value.is_empty()); + Ok(MenuItem { kind, label, @@ -161,8 +224,8 @@ fn parse_outline(event: &BytesStart<'_>) -> EngineResult { }) } -fn attach_outline(item: MenuItem, parents: &mut [MenuItem], items: &mut Vec) { - if let Some(parent) = parents.last_mut() { +fn attach_outline(item: MenuItem, parents: &mut [Option], items: &mut Vec) { + if let Some(Some(parent)) = parents.last_mut() { parent.children.push(item); } else { items.push(item); @@ -212,12 +275,22 @@ fn serialize_opml(items: &[MenuItem]) -> EngineResult { fn write_outline(writer: &mut Writer>, item: &MenuItem) -> quick_xml::Result<()> { let label = escape(&item.label); - let slug = item.slug.as_deref().map(escape); let mut outline = BytesStart::new("outline"); outline.push_attribute(("text", label.as_ref())); outline.push_attribute(("type", item.kind.as_str())); - if let Some(slug) = &slug { - outline.push_attribute(("htmlUrl", slug.as_ref())); + match item.kind { + MenuItemKind::Home => outline.push_attribute(("pageSlug", "home")), + MenuItemKind::Page => { + if let Some(slug) = item.slug.as_deref().map(escape) { + outline.push_attribute(("pageSlug", slug.as_ref())); + } + } + MenuItemKind::CategoryArchive => { + if let Some(slug) = item.slug.as_deref().map(escape) { + outline.push_attribute(("categoryName", slug.as_ref())); + } + } + MenuItemKind::Submenu => {} } if item.children.is_empty() { writer.write_event(Event::Empty(outline))?; @@ -330,6 +403,67 @@ mod tests { assert_eq!(parsed[1].slug.as_deref(), Some("/about")); } + #[test] + fn canonical_bds2_attributes_round_trip_without_legacy_output() { + let parsed = parse_opml( + "", + ) + .unwrap(); + + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[1].children[0].slug.as_deref(), Some("about")); + assert_eq!(parsed[1].children[1].kind, MenuItemKind::CategoryArchive); + assert_eq!(parsed[1].children[1].slug.as_deref(), Some("notes")); + + let serialized = serialize_opml(&parsed).unwrap(); + assert!(serialized.contains("type=\"home\" pageSlug=\"home\"")); + assert!(serialized.contains("type=\"page\" pageSlug=\"about\"")); + assert!(serialized.contains("type=\"category-archive\" categoryName=\"notes\"")); + assert!(!serialized.contains("htmlUrl")); + assert!(!serialized.contains("category_archive")); + } + + #[test] + fn parser_ignores_foreign_outlines_and_drops_children_of_non_submenus() { + let parsed = parse_opml( + "
", + ) + .unwrap(); + + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[1].label, "Page"); + assert!(parsed[1].children.is_empty()); + assert_eq!(parsed[2].label, "Kept"); + assert_eq!(parsed[2].children[0].label, "Child"); + } + + #[test] + fn normalization_removes_home_entries_at_every_depth() { + let normalized = normalize_menu(&[ + MenuItem { + kind: MenuItemKind::Home, + label: "Duplicate".into(), + slug: None, + children: Vec::new(), + }, + MenuItem { + kind: MenuItemKind::Submenu, + label: "Sections".into(), + slug: None, + children: vec![MenuItem { + kind: MenuItemKind::Home, + label: "Nested Home".into(), + slug: None, + children: Vec::new(), + }], + }, + ]); + + assert_eq!(normalized[0].kind, MenuItemKind::Home); + assert_eq!(normalized[0].label, "Home"); + assert!(normalized[1].children.is_empty()); + } + #[test] fn read_menu_rejects_malformed_xml() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index 763bb3e..a69b281 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -1008,7 +1008,7 @@ fn menu_item_href(item: &menu::MenuItem, language: &str, main_language: &str) -> MenuItemKind::CategoryArchive => item .slug .as_deref() - .map(|slug| format!("{}/category/{}/", prefix_or_root(&prefix), slugify(slug))) + .map(|slug| format!("{prefix}/category/{}/", slugify(slug))) .unwrap_or_else(|| "#".to_string()), } } @@ -1625,3 +1625,48 @@ fn queries_category_settings( ) -> Result, Box> { Ok(crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default()) } + +#[cfg(test)] +mod menu_tests { + use super::*; + use crate::engine::menu::{MenuItem, MenuItemKind}; + + #[test] + fn renderer_consumes_the_saved_opml_tree() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("meta")).unwrap(); + crate::engine::menu::write_menu( + dir.path(), + &[ + MenuItem { + kind: MenuItemKind::Page, + label: "About".into(), + slug: Some("about".into()), + children: Vec::new(), + }, + MenuItem { + kind: MenuItemKind::Submenu, + label: "Topics".into(), + slug: None, + children: vec![MenuItem { + kind: MenuItemKind::CategoryArchive, + label: "Long Form".into(), + slug: Some("Long Form".into()), + children: Vec::new(), + }], + }, + ], + ) + .unwrap(); + + let rendered = build_menu_items(dir.path(), "en", "en").unwrap(); + assert_eq!(rendered[0]["href"], "/"); + assert_eq!(rendered[1]["href"], "/about/"); + assert_eq!(rendered[2]["children"][0]["href"], "/category/long-form/"); + let translated = build_menu_items(dir.path(), "de", "en").unwrap(); + assert_eq!( + translated[2]["children"][0]["href"], + "/de/category/long-form/" + ); + } +} diff --git a/crates/bds-ui/assets/icons/menu-category.svg b/crates/bds-ui/assets/icons/menu-category.svg new file mode 100644 index 0000000..379404b --- /dev/null +++ b/crates/bds-ui/assets/icons/menu-category.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/bds-ui/assets/icons/menu-home.svg b/crates/bds-ui/assets/icons/menu-home.svg new file mode 100644 index 0000000..d46f43b --- /dev/null +++ b/crates/bds-ui/assets/icons/menu-home.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/bds-ui/assets/icons/menu-page.svg b/crates/bds-ui/assets/icons/menu-page.svg new file mode 100644 index 0000000..4232773 --- /dev/null +++ b/crates/bds-ui/assets/icons/menu-page.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/bds-ui/assets/icons/menu-submenu.svg b/crates/bds-ui/assets/icons/menu-submenu.svg new file mode 100644 index 0000000..16cf0b4 --- /dev/null +++ b/crates/bds-ui/assets/icons/menu-submenu.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index e7bd15b..8e9fd6b 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -40,6 +40,7 @@ use crate::views::{ ImportAnalysisEvent, ImportEditorMsg, ImportEditorState, ImportExecutionEvent, }, media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState}, + menu_editor::{MenuEditorMsg, MenuEditorState, MenuEditorStatus}, metadata_diff::MetadataDiffState, modal, post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink}, @@ -318,6 +319,7 @@ pub enum Message { Tags(TagsMsg), Settings(SettingsMsg), ImportEditor(ImportEditorMsg), + MenuEditor(MenuEditorMsg), // Editor data loading PostLoaded(Result), @@ -953,6 +955,7 @@ pub struct BdsApp { guide_documentation: DocumentationState, api_documentation: DocumentationState, metadata_diff_state: MetadataDiffState, + menu_editor_state: MenuEditorState, translation_validation_state: crate::views::translation_validation::TranslationValidationState, git_state: GitUiState, git_diffs: HashMap, @@ -1120,6 +1123,7 @@ impl BdsApp { guide_documentation: DocumentationState::new(DocumentationKind::Guide), api_documentation: DocumentationState::new(DocumentationKind::Api), metadata_diff_state: MetadataDiffState::default(), + menu_editor_state: MenuEditorState::default(), translation_validation_state: Default::default(), git_state: GitUiState::default(), git_diffs: HashMap::new(), @@ -1201,6 +1205,7 @@ impl BdsApp { guide_documentation: DocumentationState::new(DocumentationKind::Guide), api_documentation: DocumentationState::new(DocumentationKind::Api), metadata_diff_state: MetadataDiffState::default(), + menu_editor_state: MenuEditorState::default(), translation_validation_state: Default::default(), git_state: GitUiState::default(), git_diffs: HashMap::new(), @@ -1612,11 +1617,22 @@ impl BdsApp { } let sidebar_task = self.refresh_counts(); let documentation_task = self.reload_changed_documentation(); + let menu_editor_task = if self + .tabs + .iter() + .any(|tab| tab.tab_type == TabType::MenuEditor) + { + Task::done(Message::MenuEditor(MenuEditorMsg::Reload)) + } else { + self.menu_editor_state = MenuEditorState::default(); + Task::none() + }; self.sync_menu_state(); Task::batch([ sidebar_task, Task::done(Message::EmbeddingBackfill), documentation_task, + menu_editor_task, ]) } Message::SwitchProject(project_id) => { @@ -2755,6 +2771,7 @@ impl BdsApp { Message::Tags(msg) => self.handle_tags_msg(msg), Message::Settings(msg) => self.handle_settings_msg(msg), Message::ImportEditor(msg) => self.handle_import_editor_msg(msg), + Message::MenuEditor(msg) => self.handle_menu_editor_msg(msg), // ── Editor data loading ── Message::PostLoaded(result) => { @@ -3141,6 +3158,7 @@ impl BdsApp { &self.guide_documentation, &self.api_documentation, &self.metadata_diff_state, + &self.menu_editor_state, &self.translation_validation_state, &self.git_state, &self.git_diffs, @@ -3180,7 +3198,44 @@ impl BdsApp { Subscription::none() }; - Subscription::batch([menu_sub, task_tick, domain_event_tick, toast_tick, drag_sub]) + let menu_interaction_sub = if self.menu_editor_state.draft.is_some() { + iced::event::listen_with(|event, _status, _id| match event { + iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + .. + }) => Some(Message::MenuEditor(MenuEditorMsg::CancelDraft)), + _ => None, + }) + } else if self.menu_editor_state.dragging_id.is_some() { + iced::event::listen_with(|event, _status, _id| match event { + iced::Event::Mouse(iced::mouse::Event::ButtonReleased( + iced::mouse::Button::Left, + )) => Some(Message::MenuEditor(MenuEditorMsg::Drop)), + iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + .. + }) => Some(Message::MenuEditor(MenuEditorMsg::DragCancel)), + _ => None, + }) + } else { + Subscription::none() + }; + let menu_expand_tick = if self.menu_editor_state.hover_expand.is_some() { + iced::time::every(std::time::Duration::from_millis(50)) + .map(|_| Message::MenuEditor(MenuEditorMsg::ExpandTick)) + } else { + Subscription::none() + }; + + Subscription::batch([ + menu_sub, + task_tick, + domain_event_tick, + toast_tick, + drag_sub, + menu_interaction_sub, + menu_expand_tick, + ]) } // ── Private helpers ── @@ -3784,6 +3839,7 @@ impl BdsApp { Task::done(Message::TemplateEditor(TemplateEditorMsg::Save)) } Some(TabType::Scripts) => Task::done(Message::ScriptEditor(ScriptEditorMsg::Save)), + Some(TabType::MenuEditor) => Task::done(Message::MenuEditor(MenuEditorMsg::Save)), _ => Task::none(), }, MenuAction::OpenInBrowser => self.preview_active_post(), @@ -3827,7 +3883,7 @@ impl BdsApp { MenuAction::PreviewPost => self.preview_active_post(), MenuAction::EditMenu => { self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor"); - Task::none() + Task::done(Message::MenuEditor(MenuEditorMsg::Reload)) } MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase), MenuAction::ReindexText => Task::done(Message::ReindexText), @@ -4275,6 +4331,184 @@ impl BdsApp { } } + fn reload_menu_editor(&mut self) { + let result = match (&self.db, &self.active_project, &self.data_dir) { + (Some(db), Some(project), Some(data_dir)) => { + crate::views::menu_editor::load(db, &project.id, data_dir) + } + _ => Err(t(self.ui_locale, "menuEditor.noProject")), + }; + match result { + Ok(state) => self.menu_editor_state = state, + Err(error) => { + self.menu_editor_state.status = MenuEditorStatus::LoadFailed; + self.menu_editor_state.error = Some(error.clone()); + self.notify( + ToastLevel::Error, + &tw( + self.ui_locale, + "menuEditor.loadFailedDetail", + &[("error", &error)], + ), + ); + } + } + } + + fn handle_menu_editor_msg(&mut self, message: MenuEditorMsg) -> Task { + match message { + MenuEditorMsg::Reload => self.reload_menu_editor(), + MenuEditorMsg::Select(id) => self.menu_editor_state.selected_id = Some(id), + MenuEditorMsg::StartDraft(kind) => { + self.menu_editor_state.start_draft(kind); + } + MenuEditorMsg::DraftChanged(value) => self.menu_editor_state.draft_changed(value), + MenuEditorMsg::ChoosePage(id) => { + let _ = self.menu_editor_state.choose_page(&id); + } + MenuEditorMsg::ChooseCategory(name) => { + let _ = self.menu_editor_state.choose_category(&name); + } + MenuEditorMsg::SubmitDraft => { + let kind = self + .menu_editor_state + .draft + .as_ref() + .map(|draft| draft.kind); + match kind { + Some(crate::views::menu_editor::DraftKind::Page) => { + let label = t(self.ui_locale, "menuEditor.newSubmenu"); + let _ = self.menu_editor_state.submit_submenu(&label); + } + Some(crate::views::menu_editor::DraftKind::Category) => { + let previous = self.menu_editor_state.clone(); + if let Ok((name, is_new)) = self.menu_editor_state.submit_category() + && is_new + && let Some(data_dir) = &self.data_dir + && let Err(error) = engine::meta::add_category(data_dir, &name) + { + self.menu_editor_state = previous; + self.notify( + ToastLevel::Error, + &tw( + self.ui_locale, + "menuEditor.categoryCreateFailed", + &[("error", &error.to_string())], + ), + ); + } + } + None => {} + } + } + MenuEditorMsg::CancelDraft => { + self.menu_editor_state.cancel_draft(); + } + MenuEditorMsg::Move(direction) => { + self.menu_editor_state.move_selected(direction); + } + MenuEditorMsg::Indent => { + self.menu_editor_state.indent_selected(); + } + MenuEditorMsg::Unindent => { + self.menu_editor_state.unindent_selected(); + } + MenuEditorMsg::Delete => { + self.menu_editor_state.delete_selected(); + } + MenuEditorMsg::Save => { + if self.menu_editor_state.draft.is_some() { + self.notify( + ToastLevel::Warning, + &t(self.ui_locale, "menuEditor.finishDraft"), + ); + return Task::none(); + } + let Some(data_dir) = self.data_dir.clone() else { + return Task::none(); + }; + self.menu_editor_state.status = MenuEditorStatus::Saving; + let result = + engine::menu::write_menu(&data_dir, &self.menu_editor_state.persisted_items()) + .and_then(|()| engine::menu::read_menu(&data_dir)); + match result { + Ok(items) => { + let project_id = self + .menu_editor_state + .project_id + .clone() + .unwrap_or_default(); + let pages = self.menu_editor_state.pages.clone(); + let categories = self.menu_editor_state.categories.clone(); + self.menu_editor_state = + MenuEditorState::from_persisted(project_id, items, pages, categories); + self.notify(ToastLevel::Success, &t(self.ui_locale, "menuEditor.saved")); + } + Err(error) => { + self.menu_editor_state.status = MenuEditorStatus::Ready; + self.menu_editor_state.error = Some(error.to_string()); + self.notify( + ToastLevel::Error, + &tw( + self.ui_locale, + "menuEditor.saveFailed", + &[("error", &error.to_string())], + ), + ); + } + } + } + MenuEditorMsg::ToggleExpanded(id) => { + if !self.menu_editor_state.collapsed.insert(id.clone()) { + self.menu_editor_state.collapsed.remove(&id); + } + } + MenuEditorMsg::DragStart(id) => { + if id != crate::views::menu_editor::HOME_ID { + self.menu_editor_state.selected_id = Some(id.clone()); + self.menu_editor_state.dragging_id = Some(id); + } + } + MenuEditorMsg::DragOver(id, position) => { + self.menu_editor_state + .drag_over(id, position, std::time::Instant::now()); + } + MenuEditorMsg::DragLeave(id) => { + if self + .menu_editor_state + .drop_target + .as_ref() + .is_some_and(|(target, _)| target == &id) + { + self.menu_editor_state.drop_target = None; + self.menu_editor_state.hover_expand = None; + } + } + MenuEditorMsg::Drop => { + if let (Some(dragged), Some((target, position))) = ( + self.menu_editor_state.dragging_id.clone(), + self.menu_editor_state.drop_target.clone(), + ) { + self.menu_editor_state + .drop_item(&dragged, &target, position); + } + self.menu_editor_state.dragging_id = None; + self.menu_editor_state.drop_target = None; + self.menu_editor_state.hover_expand = None; + } + MenuEditorMsg::DragCancel => { + self.menu_editor_state.dragging_id = None; + self.menu_editor_state.drop_target = None; + self.menu_editor_state.hover_expand = None; + } + MenuEditorMsg::ExpandTick => { + self.menu_editor_state + .expand_hovered(std::time::Instant::now()); + } + } + Task::none() + } + fn update_import_paths( &mut self, definition_id: &str, @@ -8434,6 +8668,7 @@ mod tests { use crate::views::chat_view::ChatEditorState; use crate::views::documentation::{DocumentLoad, DocumentationKind}; use crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; + use crate::views::menu_editor::{MenuEditorMsg, MenuEditorState, MenuEditorStatus}; use crate::views::modal; use crate::views::post_editor::{PostEditorMsg, PostEditorState}; use crate::views::script_editor::{ScriptEditorMsg, ScriptEditorState}; @@ -8444,7 +8679,7 @@ mod tests { use bds_core::db::queries::project::insert_project; use bds_core::engine::generation::GenerationReport; use bds_core::engine::task::{TaskStatus, TaskStatus::*}; - use bds_core::engine::{ai, chat, media, post, script, template, wordpress_import}; + use bds_core::engine::{ai, chat, media, menu, meta, post, script, template, wordpress_import}; use bds_core::i18n::UiLocale; use bds_core::model::{ChatRole, Project, ScriptKind, TemplateKind}; use chrono::{Datelike, TimeZone}; @@ -8587,6 +8822,77 @@ mod tests { assert_eq!(app.guide_documentation.signature, 42); } + #[test] + fn menu_editor_persists_pages_new_categories_and_reload_roundtrip() { + let (db, project, temp) = setup(); + let page = post::create_post( + db.conn(), + temp.path(), + &project.id, + "About", + Some("About body"), + Vec::new(), + vec!["page".to_string()], + None, + None, + None, + ) + .unwrap(); + let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf()); + + let _ = app.update(Message::MenuEditor(MenuEditorMsg::Reload)); + assert_eq!(app.menu_editor_state.status, MenuEditorStatus::Ready); + assert!( + app.menu_editor_state + .pages + .iter() + .any(|item| item.id == page.id) + ); + + let _ = app.update(Message::MenuEditor(MenuEditorMsg::StartDraft( + crate::views::menu_editor::DraftKind::Page, + ))); + let _ = app.update(Message::MenuEditor(MenuEditorMsg::ChoosePage(page.id))); + let _ = app.update(Message::MenuEditor(MenuEditorMsg::StartDraft( + crate::views::menu_editor::DraftKind::Category, + ))); + let _ = app.update(Message::MenuEditor(MenuEditorMsg::DraftChanged( + "Long Form".to_string(), + ))); + let _ = app.update(Message::MenuEditor(MenuEditorMsg::SubmitDraft)); + assert!( + meta::read_categories_json(temp.path()) + .unwrap() + .contains(&"Long Form".to_string()) + ); + assert!( + meta::read_category_meta_json(temp.path()) + .unwrap() + .contains_key("Long Form") + ); + + let _ = app.update(Message::MenuEditor(MenuEditorMsg::Save)); + let saved = menu::read_menu(temp.path()).unwrap(); + assert_eq!(saved[0].kind, menu::MenuItemKind::Home); + assert!( + saved + .iter() + .any(|item| item.kind == menu::MenuItemKind::Page + && item.slug.as_deref() == Some("about")) + ); + assert!( + saved + .iter() + .any(|item| item.kind == menu::MenuItemKind::CategoryArchive + && item.slug.as_deref() == Some("Long Form")) + ); + assert!(!app.menu_editor_state.dirty); + + app.menu_editor_state = MenuEditorState::default(); + let _ = app.update(Message::MenuEditor(MenuEditorMsg::Reload)); + assert_eq!(app.menu_editor_state.items.len(), 3); + } + #[test] fn stale_documentation_load_cannot_overwrite_a_newer_guide_read() { let (db, project, temp) = setup(); diff --git a/crates/bds-ui/src/views/menu_editor.rs b/crates/bds-ui/src/views/menu_editor.rs new file mode 100644 index 0000000..1b4f3a5 --- /dev/null +++ b/crates/bds-ui/src/views/menu_editor.rs @@ -0,0 +1,1234 @@ +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +use bds_core::engine::menu::{MenuItem, MenuItemKind}; +use bds_core::i18n::UiLocale; +use iced::widget::{ + Space, button, column, container, mouse_area, row, scrollable, svg, text, text_input, tooltip, +}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Point, Theme}; +use uuid::Uuid; + +use crate::app::Message; +use crate::components::inputs::{ + self, danger_button, field_style, primary_button, secondary_button, +}; +use crate::i18n::t; + +pub const HOME_ID: &str = "menu-home"; +pub const DRAG_EXPAND_DELAY: Duration = Duration::from_millis(450); +const HOME_ICON: &[u8] = include_bytes!("../../assets/icons/menu-home.svg"); +const PAGE_ICON: &[u8] = include_bytes!("../../assets/icons/menu-page.svg"); +const SUBMENU_ICON: &[u8] = include_bytes!("../../assets/icons/menu-submenu.svg"); +const CATEGORY_ICON: &[u8] = include_bytes!("../../assets/icons/menu-category.svg"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DraftKind { + Page, + Category, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MoveDirection { + Up, + Down, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DropPosition { + Before, + Inside, + After, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MenuEditError { + MissingDraft, + MissingChoice, + CategoryRequired, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageOption { + pub id: String, + pub title: String, + pub slug: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MenuNode { + pub id: String, + pub kind: MenuItemKind, + pub label: String, + pub slug: Option, + pub children: Vec, +} + +impl MenuNode { + fn from_item(item: MenuItem) -> Self { + let id = if item.kind == MenuItemKind::Home { + HOME_ID.to_string() + } else { + Uuid::new_v4().to_string() + }; + Self { + id, + kind: item.kind, + label: item.label, + slug: item.slug, + children: item.children.into_iter().map(Self::from_item).collect(), + } + } + + fn to_item(&self) -> MenuItem { + MenuItem { + kind: self.kind.clone(), + label: self.label.clone(), + slug: self.slug.clone(), + children: self.children.iter().map(Self::to_item).collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MenuDraft { + pub item_id: String, + pub kind: DraftKind, + pub query: String, + pub validation_failed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MenuEditorStatus { + NotLoaded, + Ready, + Saving, + LoadFailed, +} + +#[derive(Debug, Clone)] +pub struct MenuEditorState { + pub project_id: Option, + pub items: Vec, + pub selected_id: Option, + pub draft: Option, + pub pages: Vec, + pub categories: Vec, + pub dirty: bool, + pub status: MenuEditorStatus, + pub error: Option, + pub collapsed: HashSet, + pub dragging_id: Option, + pub drop_target: Option<(String, DropPosition)>, + pub hover_expand: Option<(String, Instant)>, +} + +pub fn load( + db: &bds_core::db::Database, + project_id: &str, + data_dir: &std::path::Path, +) -> Result { + let items = bds_core::engine::menu::read_menu(data_dir).map_err(|error| error.to_string())?; + let mut pages = bds_core::db::queries::post::list_posts_by_project(db.conn(), project_id) + .map_err(|error| error.to_string())? + .into_iter() + .filter(|post| { + post.categories + .iter() + .any(|category| category.eq_ignore_ascii_case("page")) + }) + .map(|post| PageOption { + id: post.id, + title: post.title, + slug: post.slug, + }) + .collect::>(); + pages.sort_by(|left, right| { + left.title + .to_lowercase() + .cmp(&right.title.to_lowercase()) + .then_with(|| left.slug.cmp(&right.slug)) + }); + let mut categories = bds_core::engine::meta::read_categories_json(data_dir) + .map_err(|error| error.to_string())?; + categories.sort_by_key(|name| name.to_lowercase()); + Ok(MenuEditorState::from_persisted( + project_id.to_string(), + items, + pages, + categories, + )) +} + +impl Default for MenuEditorState { + fn default() -> Self { + Self { + project_id: None, + items: Vec::new(), + selected_id: None, + draft: None, + pages: Vec::new(), + categories: Vec::new(), + dirty: false, + status: MenuEditorStatus::NotLoaded, + error: None, + collapsed: HashSet::new(), + dragging_id: None, + drop_target: None, + hover_expand: None, + } + } +} + +impl MenuEditorState { + pub fn from_persisted( + project_id: String, + items: Vec, + pages: Vec, + categories: Vec, + ) -> Self { + Self::ready( + project_id, + items.into_iter().map(MenuNode::from_item).collect(), + pages, + categories, + ) + } + + pub fn ready( + project_id: String, + items: Vec, + pages: Vec, + categories: Vec, + ) -> Self { + let selected_id = items.first().map(|item| item.id.clone()); + Self { + project_id: Some(project_id), + items, + selected_id, + pages, + categories, + status: MenuEditorStatus::Ready, + ..Self::default() + } + } + + pub fn persisted_items(&self) -> Vec { + self.items.iter().map(MenuNode::to_item).collect() + } + + pub fn start_draft(&mut self, kind: DraftKind) -> bool { + if self.draft.is_some() || self.status != MenuEditorStatus::Ready { + return false; + } + let id = Uuid::new_v4().to_string(); + let node = MenuNode { + id: id.clone(), + kind: match kind { + DraftKind::Page => MenuItemKind::Page, + DraftKind::Category => MenuItemKind::CategoryArchive, + }, + label: String::new(), + slug: None, + children: Vec::new(), + }; + let (parent, index) = insertion_target(&self.items, self.selected_id.as_deref()); + insert_at(&mut self.items, &parent, index, node); + if let Some(selected) = self.selected_id.as_deref() { + self.collapsed.remove(selected); + } + self.selected_id = Some(id.clone()); + self.draft = Some(MenuDraft { + item_id: id, + kind, + query: String::new(), + validation_failed: false, + }); + true + } + + pub fn draft_changed(&mut self, query: String) { + if let Some(draft) = &mut self.draft { + draft.query = query; + draft.validation_failed = false; + } + } + + pub fn choose_page(&mut self, page_id: &str) -> Result<(), MenuEditError> { + let page = self + .pages + .iter() + .find(|page| page.id == page_id) + .cloned() + .ok_or(MenuEditError::MissingChoice)?; + let draft = self + .draft + .as_ref() + .filter(|draft| draft.kind == DraftKind::Page) + .ok_or(MenuEditError::MissingDraft)?; + let path = find_path(&self.items, &draft.item_id).ok_or(MenuEditError::MissingDraft)?; + let item = item_at_mut(&mut self.items, &path).ok_or(MenuEditError::MissingDraft)?; + item.kind = MenuItemKind::Page; + item.label = page.title; + item.slug = Some(page.slug); + self.draft = None; + self.dirty = true; + Ok(()) + } + + pub fn submit_submenu(&mut self, default_label: &str) -> Result<(), MenuEditError> { + let draft = self + .draft + .as_ref() + .filter(|draft| draft.kind == DraftKind::Page) + .ok_or(MenuEditError::MissingDraft)?; + let label = if draft.query.trim().is_empty() { + default_label.to_string() + } else { + draft.query.trim().to_string() + }; + let path = find_path(&self.items, &draft.item_id).ok_or(MenuEditError::MissingDraft)?; + let item = item_at_mut(&mut self.items, &path).ok_or(MenuEditError::MissingDraft)?; + item.kind = MenuItemKind::Submenu; + item.label = label; + item.slug = None; + self.draft = None; + self.dirty = true; + Ok(()) + } + + /// Resolve an existing or new category and finalize its draft item. + /// Returns the canonical category name and whether metadata must be created. + pub fn submit_category(&mut self) -> Result<(String, bool), MenuEditError> { + let Some(draft) = self + .draft + .as_mut() + .filter(|draft| draft.kind == DraftKind::Category) + else { + return Err(MenuEditError::MissingDraft); + }; + let query = draft.query.trim(); + if query.is_empty() { + draft.validation_failed = true; + return Err(MenuEditError::CategoryRequired); + } + let existing = self + .categories + .iter() + .find(|name| name.eq_ignore_ascii_case(query)) + .cloned(); + let name = existing.clone().unwrap_or_else(|| query.to_string()); + let is_new = existing.is_none(); + let item_id = draft.item_id.clone(); + let path = find_path(&self.items, &item_id).ok_or(MenuEditError::MissingDraft)?; + let item = item_at_mut(&mut self.items, &path).ok_or(MenuEditError::MissingDraft)?; + item.kind = MenuItemKind::CategoryArchive; + item.label = name.clone(); + item.slug = Some(name.clone()); + if is_new { + self.categories.push(name.clone()); + self.categories.sort_by_key(|name| name.to_lowercase()); + } + self.draft = None; + self.dirty = true; + Ok((name, is_new)) + } + + pub fn choose_category(&mut self, name: &str) -> Result<(), MenuEditError> { + if !self.categories.iter().any(|category| category == name) { + return Err(MenuEditError::MissingChoice); + } + self.draft_changed(name.to_string()); + self.submit_category().map(|_| ()) + } + + pub fn cancel_draft(&mut self) -> bool { + let Some(draft) = self.draft.take() else { + return false; + }; + let removed = remove_by_id(&mut self.items, &draft.item_id).is_some(); + self.selected_id = self.items.first().map(|item| item.id.clone()); + removed + } + + pub fn move_selected(&mut self, direction: MoveDirection) -> bool { + let Some(id) = self.selected_id.clone() else { + return false; + }; + if id == HOME_ID || self.draft.is_some() { + return false; + } + let Some(path) = find_path(&self.items, &id) else { + return false; + }; + let Some((&index, parent_path)) = path.split_last() else { + return false; + }; + let siblings = children_at_mut(&mut self.items, parent_path); + let Some(siblings) = siblings else { + return false; + }; + let target = match direction { + MoveDirection::Up => { + let minimum = usize::from(parent_path.is_empty()); + if index <= minimum { + return false; + } + index - 1 + } + MoveDirection::Down => { + if index + 1 >= siblings.len() { + return false; + } + index + 1 + } + }; + siblings.swap(index, target); + self.dirty = true; + true + } + + pub fn indent_selected(&mut self) -> bool { + let Some(id) = self.selected_id.clone() else { + return false; + }; + if id == HOME_ID || self.draft.is_some() { + return false; + } + let Some(path) = find_path(&self.items, &id) else { + return false; + }; + let Some((&index, parent_path)) = path.split_last() else { + return false; + }; + if index == 0 { + return false; + } + let mut preceding = parent_path.to_vec(); + preceding.push(index - 1); + if item_at(&self.items, &preceding).is_none_or(|item| item.kind != MenuItemKind::Submenu) { + return false; + } + let Some(item) = remove_at(&mut self.items, &path) else { + return false; + }; + let Some(parent) = item_at_mut(&mut self.items, &preceding) else { + return false; + }; + parent.children.push(item); + self.collapsed.remove(&parent.id); + self.dirty = true; + true + } + + pub fn unindent_selected(&mut self) -> bool { + let Some(id) = self.selected_id.clone() else { + return false; + }; + if id == HOME_ID || self.draft.is_some() { + return false; + } + let Some(path) = find_path(&self.items, &id) else { + return false; + }; + if path.len() < 2 { + return false; + } + let parent_path = &path[..path.len() - 1]; + let parent_id = item_at(&self.items, parent_path).map(|item| item.id.clone()); + if parent_id.as_deref() == Some(HOME_ID) { + return false; + } + let Some(item) = remove_at(&mut self.items, &path) else { + return false; + }; + let grandparent = &parent_path[..parent_path.len() - 1]; + let parent_index = parent_path[parent_path.len() - 1]; + insert_at(&mut self.items, grandparent, parent_index + 1, item); + self.dirty = true; + true + } + + pub fn delete_selected(&mut self) -> bool { + let Some(id) = self.selected_id.clone() else { + return false; + }; + if id == HOME_ID { + return false; + } + let removed = remove_by_id(&mut self.items, &id).is_some(); + if removed { + self.draft = None; + self.selected_id = self.items.first().map(|item| item.id.clone()); + self.dirty = true; + } + removed + } + + pub fn drop_item(&mut self, dragged_id: &str, target_id: &str, position: DropPosition) -> bool { + if dragged_id.is_empty() + || target_id.is_empty() + || dragged_id == target_id + || dragged_id == HOME_ID + || self.draft.is_some() + { + return false; + } + let Some(drag_path) = find_path(&self.items, dragged_id) else { + return false; + }; + let Some(target_path) = find_path(&self.items, target_id) else { + return false; + }; + if target_path.starts_with(&drag_path) { + return false; + } + if position == DropPosition::Inside + && item_at(&self.items, &target_path) + .is_none_or(|item| item.kind != MenuItemKind::Submenu) + { + return false; + } + if position == DropPosition::Before && target_id == HOME_ID { + return false; + } + let Some(item) = remove_at(&mut self.items, &drag_path) else { + return false; + }; + let Some(next_target) = find_path(&self.items, target_id) else { + return false; + }; + match position { + DropPosition::Inside => insert_at(&mut self.items, &next_target, 0, item), + DropPosition::Before | DropPosition::After => { + let Some((&index, parent)) = next_target.split_last() else { + return false; + }; + let index = index + usize::from(position == DropPosition::After); + insert_at(&mut self.items, parent, index, item); + } + } + self.selected_id = Some(dragged_id.to_string()); + self.dirty = true; + true + } + + pub fn drag_over(&mut self, target: String, position: DropPosition, now: Instant) { + if self.dragging_id.is_none() { + return; + } + self.drop_target = Some((target.clone(), position)); + let should_expand = position == DropPosition::Inside + && self.collapsed.contains(&target) + && find_path(&self.items, &target) + .and_then(|path| item_at(&self.items, &path)) + .is_some_and(|item| item.kind == MenuItemKind::Submenu); + if should_expand { + if self + .hover_expand + .as_ref() + .is_none_or(|(id, _)| id != &target) + { + self.hover_expand = Some((target, now)); + } + } else { + self.hover_expand = None; + } + } + + pub fn expand_hovered(&mut self, now: Instant) -> bool { + let Some((id, started)) = self.hover_expand.clone() else { + return false; + }; + if now.duration_since(started) < DRAG_EXPAND_DELAY { + return false; + } + self.collapsed.remove(&id); + self.hover_expand = None; + true + } +} + +#[derive(Debug, Clone)] +pub enum MenuEditorMsg { + Reload, + Select(String), + StartDraft(DraftKind), + DraftChanged(String), + ChoosePage(String), + ChooseCategory(String), + SubmitDraft, + CancelDraft, + Move(MoveDirection), + Indent, + Unindent, + Delete, + Save, + ToggleExpanded(String), + DragStart(String), + DragOver(String, DropPosition), + DragLeave(String), + Drop, + DragCancel, + ExpandTick, +} + +fn insertion_target(items: &[MenuNode], selected_id: Option<&str>) -> (Vec, usize) { + let Some(id) = selected_id else { + return (Vec::new(), items.len()); + }; + let Some(path) = find_path(items, id) else { + return (Vec::new(), items.len()); + }; + if item_at(items, &path).is_some_and(|item| item.kind == MenuItemKind::Submenu) { + return (path, 0); + } + let Some((&index, parent)) = path.split_last() else { + return (Vec::new(), items.len()); + }; + (parent.to_vec(), index + 1) +} + +fn find_path(items: &[MenuNode], id: &str) -> Option> { + fn search(items: &[MenuNode], id: &str, path: &mut Vec) -> bool { + for (index, item) in items.iter().enumerate() { + path.push(index); + if item.id == id || search(&item.children, id, path) { + return true; + } + path.pop(); + } + false + } + let mut path = Vec::new(); + search(items, id, &mut path).then_some(path) +} + +fn item_at<'a>(items: &'a [MenuNode], path: &[usize]) -> Option<&'a MenuNode> { + let (&first, rest) = path.split_first()?; + let item = items.get(first)?; + if rest.is_empty() { + Some(item) + } else { + item_at(&item.children, rest) + } +} + +fn item_at_mut<'a>(items: &'a mut [MenuNode], path: &[usize]) -> Option<&'a mut MenuNode> { + let (&first, rest) = path.split_first()?; + let item = items.get_mut(first)?; + if rest.is_empty() { + Some(item) + } else { + item_at_mut(&mut item.children, rest) + } +} + +fn children_at_mut<'a>( + items: &'a mut Vec, + path: &[usize], +) -> Option<&'a mut Vec> { + if path.is_empty() { + return Some(items); + } + item_at_mut(items, path).map(|item| &mut item.children) +} + +fn insert_at(items: &mut Vec, parent: &[usize], index: usize, item: MenuNode) { + if let Some(children) = children_at_mut(items, parent) { + children.insert(index.min(children.len()), item); + } +} + +fn remove_at(items: &mut Vec, path: &[usize]) -> Option { + let (&index, parent) = path.split_last()?; + let children = children_at_mut(items, parent)?; + (index < children.len()).then(|| children.remove(index)) +} + +fn remove_by_id(items: &mut Vec, id: &str) -> Option { + let path = find_path(items, id)?; + remove_at(items, &path) +} + +fn kind_label(locale: UiLocale, kind: &MenuItemKind) -> String { + t( + locale, + match kind { + MenuItemKind::Home => "menuEditor.kindHome", + MenuItemKind::Page => "menuEditor.kindPage", + MenuItemKind::Submenu => "menuEditor.kindSubmenu", + MenuItemKind::CategoryArchive => "menuEditor.kindCategory", + }, + ) +} + +fn kind_icon(kind: &MenuItemKind) -> iced::widget::Svg<'static> { + let bytes = match kind { + MenuItemKind::Home => HOME_ICON, + MenuItemKind::Page => PAGE_ICON, + MenuItemKind::Submenu => SUBMENU_ICON, + MenuItemKind::CategoryArchive => CATEGORY_ICON, + }; + svg(svg::Handle::from_memory(bytes)) + .width(Length::Fixed(18.0)) + .height(Length::Fixed(18.0)) +} + +pub fn view(state: &MenuEditorState, locale: UiLocale) -> Element<'_, Message> { + let title = text(t(locale, "menuEditor.title")).size(24); + let description = text(t(locale, "menuEditor.description")) + .size(13) + .color(Color::from_rgb8(0xA8, 0xA8, 0xA8)); + let header = inputs::card(column![title, description].spacing(6)); + let add_disabled = state.draft.is_some() || state.status != MenuEditorStatus::Ready; + let selected_is_home = state.selected_id.as_deref() == Some(HOME_ID); + let actions_disabled = selected_is_home + || state.selected_id.is_none() + || state.draft.is_some() + || state.status != MenuEditorStatus::Ready; + let toolbar = inputs::toolbar( + vec![ + toolbar_icon( + locale, + "menuEditor.addEntry", + "+", + MenuEditorMsg::StartDraft(DraftKind::Page), + !add_disabled, + false, + ), + toolbar_icon( + locale, + "menuEditor.addCategory", + "▣", + MenuEditorMsg::StartDraft(DraftKind::Category), + !add_disabled, + false, + ), + toolbar_icon( + locale, + "menuEditor.moveUp", + "↑", + MenuEditorMsg::Move(MoveDirection::Up), + !actions_disabled, + false, + ), + toolbar_icon( + locale, + "menuEditor.moveDown", + "↓", + MenuEditorMsg::Move(MoveDirection::Down), + !actions_disabled, + false, + ), + toolbar_icon( + locale, + "menuEditor.indent", + "→", + MenuEditorMsg::Indent, + !actions_disabled, + false, + ), + toolbar_icon( + locale, + "menuEditor.unindent", + "←", + MenuEditorMsg::Unindent, + !actions_disabled, + false, + ), + toolbar_icon( + locale, + "common.delete", + "×", + MenuEditorMsg::Delete, + !actions_disabled, + true, + ), + ], + vec![toolbar_icon( + locale, + "common.save", + "◆", + MenuEditorMsg::Save, + state.dirty && state.draft.is_none() && state.status == MenuEditorStatus::Ready, + false, + )], + ); + + let content: Element<'_, Message> = match state.status { + MenuEditorStatus::NotLoaded => centered_status(locale, "menuEditor.loading"), + MenuEditorStatus::LoadFailed => { + let detail = state.error.clone().unwrap_or_default(); + container( + column![ + text(t(locale, "menuEditor.loadFailed")), + text(detail).size(12) + ] + .spacing(8) + .align_x(Alignment::Center), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } + MenuEditorStatus::Ready | MenuEditorStatus::Saving => { + if state.items.is_empty() { + centered_status(locale, "menuEditor.empty") + } else { + scrollable(tree_view(state, locale)) + .height(Length::Fill) + .into() + } + } + }; + + let content = inputs::card(content).height(Length::Fill); + container(column![header, toolbar, content].spacing(12)) + .padding(16) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn toolbar_icon( + locale: UiLocale, + key: &str, + glyph: &'static str, + message: MenuEditorMsg, + enabled: bool, + destructive: bool, +) -> Element<'static, Message> { + let style = if !enabled { + secondary_button + } else if destructive { + danger_button + } else if key == "common.save" { + primary_button + } else { + secondary_button + }; + let mut control = button(text(glyph).size(18)) + .width(Length::Fixed(38.0)) + .height(Length::Fixed(34.0)) + .style(style); + if enabled { + control = control.on_press(Message::MenuEditor(message)); + } + tooltip( + control, + text(t(locale, key)).size(12), + tooltip::Position::Bottom, + ) + .gap(4) + .into() +} + +fn centered_status(locale: UiLocale, key: &str) -> Element<'_, Message> { + container( + text(t(locale, key)) + .size(14) + .color(Color::from_rgb8(0xA8, 0xA8, 0xA8)), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + +fn tree_view<'a>(state: &'a MenuEditorState, locale: UiLocale) -> Element<'a, Message> { + let mut rows = column![].spacing(4).width(Length::Fill); + for item in &state.items { + rows = rows.push(tree_item(state, item, 0, locale)); + } + rows.into() +} + +fn tree_item<'a>( + state: &'a MenuEditorState, + item: &'a MenuNode, + depth: usize, + locale: UiLocale, +) -> Element<'a, Message> { + let selected = state.selected_id.as_deref() == Some(&item.id); + let is_submenu = item.kind == MenuItemKind::Submenu; + let collapsed = state.collapsed.contains(&item.id); + let id = item.id.clone(); + let toggle: Element<'_, Message> = if is_submenu { + button(text(if collapsed { "▸" } else { "▾" }).size(14)) + .on_press(Message::MenuEditor(MenuEditorMsg::ToggleExpanded( + id.clone(), + ))) + .padding(4) + .style(secondary_button) + .into() + } else { + Space::new(Length::Fixed(28.0), 0).into() + }; + let drag: Element<'_, Message> = if item.id == HOME_ID { + Space::new(Length::Fixed(24.0), 0).into() + } else { + tooltip( + mouse_area(text("⠿").width(Length::Fixed(24.0))) + .on_press(Message::MenuEditor(MenuEditorMsg::DragStart(id.clone()))) + .interaction(iced::mouse::Interaction::Grab), + text(t(locale, "menuEditor.drag")).size(12), + tooltip::Position::Top, + ) + .gap(4) + .into() + }; + let label = if item.label.is_empty() { + t(locale, "menuEditor.draft") + } else { + item.label.clone() + }; + let label_button = button( + row![ + kind_icon(&item.kind), + text(label).size(14), + Space::new(Length::Fill, 0), + text(kind_label(locale, &item.kind)) + .size(11) + .color(Color::from_rgb8(0x9D, 0xA5, 0xB4)) + ] + .align_y(Alignment::Center), + ) + .on_press(Message::MenuEditor(MenuEditorMsg::Select(id.clone()))) + .padding([8, 10]) + .width(Length::Fill) + .style(move |_theme: &Theme, _status| button::Style { + background: selected.then_some(Background::Color(Color::from_rgb8(0x26, 0x4F, 0x78))), + text_color: Color::from_rgb8(0xE4, 0xE4, 0xE4), + border: Border { + radius: 5.0.into(), + ..Border::default() + }, + ..button::Style::default() + }); + let row_content = row![ + Space::new(Length::Fixed(depth as f32 * 22.0), 0), + toggle, + drag, + label_button + ] + .spacing(4) + .align_y(Alignment::Center) + .height(Length::Fixed(46.0)); + let target = state + .drop_target + .as_ref() + .filter(|(target, _)| target == &item.id) + .map(|(_, position)| *position); + let target_id = id.clone(); + let drop_row = mouse_area( + container(row_content) + .padding(Padding::from([1, 4])) + .width(Length::Fill) + .style(move |_theme| { + let color = Color::from_rgb8(0x00, 0x7F, 0xD4); + let mut border = Border { + radius: 5.0.into(), + ..Border::default() + }; + if target == Some(DropPosition::Inside) { + border.color = color; + border.width = 1.0; + } + container::Style { + border, + ..container::Style::default() + } + }), + ) + .on_move(move |point: Point| { + let position = if point.y < 14.0 { + DropPosition::Before + } else if point.y > 32.0 { + DropPosition::After + } else { + DropPosition::Inside + }; + Message::MenuEditor(MenuEditorMsg::DragOver(target_id.clone(), position)) + }) + .on_exit(Message::MenuEditor(MenuEditorMsg::DragLeave(id.clone()))) + .on_release(Message::MenuEditor(MenuEditorMsg::Drop)); + + let mut result = column![].spacing(3).width(Length::Fill); + if target == Some(DropPosition::Before) { + result = result.push(drop_indicator(depth)); + } + result = result.push(drop_row); + if state + .draft + .as_ref() + .is_some_and(|draft| draft.item_id == item.id) + { + result = result.push(draft_editor(state, depth + 1, locale)); + } + if is_submenu && !collapsed { + for child in &item.children { + result = result.push(tree_item(state, child, depth + 1, locale)); + } + } + if target == Some(DropPosition::After) { + result = result.push(drop_indicator(depth)); + } + result.into() +} + +fn drop_indicator(depth: usize) -> Element<'static, Message> { + row![ + Space::new(Length::Fixed(depth as f32 * 22.0 + 60.0), 0), + container(Space::new(Length::Fill, Length::Fixed(2.0))).style(|_theme| { + container::Style { + background: Some(Background::Color(Color::from_rgb8(0x00, 0x7F, 0xD4))), + ..container::Style::default() + } + }) + ] + .into() +} + +fn draft_editor<'a>( + state: &'a MenuEditorState, + depth: usize, + locale: UiLocale, +) -> Element<'a, Message> { + let draft = state.draft.as_ref().expect("draft editor requires a draft"); + let placeholder = match draft.kind { + DraftKind::Page => "menuEditor.pagePlaceholder", + DraftKind::Category => "menuEditor.categoryPlaceholder", + }; + let input = text_input(&t(locale, placeholder), &draft.query) + .on_input(|value| Message::MenuEditor(MenuEditorMsg::DraftChanged(value))) + .on_submit(Message::MenuEditor(MenuEditorMsg::SubmitDraft)) + .padding([8, 10]) + .style(field_style) + .width(Length::Fill); + let mut choices = column![].spacing(4); + match draft.kind { + DraftKind::Page => { + for page in state + .pages + .iter() + .filter(|page| page_matches_query(page, &draft.query)) + { + choices = choices.push( + button(text(page.title.clone())) + .on_press(Message::MenuEditor(MenuEditorMsg::ChoosePage( + page.id.clone(), + ))) + .style(secondary_button) + .width(Length::Fill), + ); + } + } + DraftKind::Category => { + for category in state + .categories + .iter() + .filter(|name| category_matches_query(name, &draft.query)) + { + choices = choices.push( + button(text(category.clone())) + .on_press(Message::MenuEditor(MenuEditorMsg::ChooseCategory( + category.clone(), + ))) + .style(secondary_button) + .width(Length::Fill), + ); + } + } + } + let submit_label = match draft.kind { + DraftKind::Page => "menuEditor.createSubmenu", + DraftKind::Category => "menuEditor.useCategory", + }; + let actions = row![ + button(text(t(locale, submit_label))) + .on_press(Message::MenuEditor(MenuEditorMsg::SubmitDraft)) + .style(primary_button), + button(text(t(locale, "common.cancel"))) + .on_press(Message::MenuEditor(MenuEditorMsg::CancelDraft)) + .style(secondary_button), + ] + .spacing(8); + let mut editor = column![input, choices, actions].spacing(8); + if draft.validation_failed { + editor = editor.push( + text(t(locale, "menuEditor.categoryRequired")) + .size(12) + .color(Color::from_rgb8(0xF4, 0x87, 0x71)), + ); + } + container(row![ + Space::new(Length::Fixed(depth as f32 * 22.0 + 60.0), 0), + editor.width(Length::Fill) + ]) + .padding([8, 8]) + .into() +} + +fn page_matches_query(page: &PageOption, query: &str) -> bool { + let query = query.trim().to_lowercase(); + query.is_empty() + || page.title.to_lowercase().contains(&query) + || page.slug.to_lowercase().contains(&query) +} + +fn category_matches_query(category: &str, query: &str) -> bool { + let query = query.trim().to_lowercase(); + query.is_empty() || category.to_lowercase().contains(&query) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inserts_after_a_leaf_and_as_first_child_of_a_submenu() { + let mut state = fixture(); + state.selected_id = Some("page-one".into()); + state.start_draft(DraftKind::Page); + state.draft_changed("Nested".into()); + state.submit_submenu("New submenu").unwrap(); + assert_eq!( + labels(&state.items), + vec!["Home", "One", "Nested", "Section"] + ); + + state.selected_id = Some("section".into()); + state.start_draft(DraftKind::Page); + state.choose_page("page-two").unwrap(); + assert_eq!(labels(&state.items[3].children), vec!["Two"]); + } + + #[test] + fn cancelling_a_draft_removes_it() { + let mut state = fixture(); + state.selected_id = Some("page-one".into()); + state.start_draft(DraftKind::Category); + assert!(state.draft.is_some()); + state.cancel_draft(); + assert_eq!(labels(&state.items), vec!["Home", "One", "Section"]); + } + + #[test] + fn moves_indents_and_unindents_with_home_protected() { + let mut state = fixture(); + state.selected_id = Some("section".into()); + assert!(state.move_selected(MoveDirection::Up)); + assert_eq!(labels(&state.items), vec!["Home", "Section", "One"]); + assert!(!state.move_selected(MoveDirection::Up)); + assert!(state.move_selected(MoveDirection::Down)); + assert_eq!(labels(&state.items), vec!["Home", "One", "Section"]); + assert!(state.move_selected(MoveDirection::Up)); + + state.selected_id = Some("page-one".into()); + assert!(state.indent_selected()); + assert_eq!(labels(&state.items[1].children), vec!["One"]); + assert!(state.unindent_selected()); + assert_eq!(labels(&state.items), vec!["Home", "Section", "One"]); + + state.selected_id = Some(HOME_ID.into()); + assert!(!state.move_selected(MoveDirection::Down)); + assert!(!state.delete_selected()); + } + + #[test] + fn drag_drop_supports_positions_and_rejects_invalid_targets() { + let mut state = fixture(); + assert!(state.drop_item("page-one", "section", DropPosition::Inside)); + let section = find_path(&state.items, "section").unwrap(); + assert_eq!( + labels(&item_at(&state.items, §ion).unwrap().children), + vec!["One"] + ); + assert!(state.drop_item("page-one", "section", DropPosition::Before)); + assert_eq!(labels(&state.items), vec!["Home", "One", "Section"]); + assert!(state.drop_item("page-one", "section", DropPosition::After)); + assert_eq!(labels(&state.items), vec!["Home", "Section", "One"]); + assert!(state.drop_item("page-one", "section", DropPosition::Before)); + assert!(!state.drop_item("section", "page-one", DropPosition::Inside)); + assert!(!state.drop_item("section", "section", DropPosition::After)); + assert!(!state.drop_item("section", HOME_ID, DropPosition::Before)); + assert!(!state.drop_item(HOME_ID, "section", DropPosition::After)); + + state.items[2] + .children + .push(node("child", MenuItemKind::Submenu, "Child", None)); + assert!(!state.drop_item("section", "child", DropPosition::Inside)); + } + + #[test] + fn category_drafts_validate_and_normalize_existing_names() { + let mut state = fixture(); + state.start_draft(DraftKind::Category); + assert!(state.submit_category().is_err()); + assert!(state.draft.as_ref().unwrap().validation_failed); + state.draft_changed(" ARTICLES ".into()); + assert_eq!(state.submit_category().unwrap(), ("articles".into(), false)); + + state.start_draft(DraftKind::Category); + state.draft_changed(" Long Form ".into()); + assert_eq!(state.submit_category().unwrap(), ("Long Form".into(), true)); + assert_eq!(state.categories, vec!["articles", "Long Form"]); + } + + #[test] + fn collapsed_submenus_expand_only_after_the_drag_delay() { + let mut state = fixture(); + state.collapsed.insert("section".into()); + state.dragging_id = Some("page-one".into()); + let started = Instant::now(); + state.drag_over("section".into(), DropPosition::Inside, started); + assert!(!state.expand_hovered(started + DRAG_EXPAND_DELAY - Duration::from_millis(1))); + assert!(state.collapsed.contains("section")); + assert!(state.expand_hovered(started + DRAG_EXPAND_DELAY)); + assert!(!state.collapsed.contains("section")); + } + + #[test] + fn draft_search_matches_bds2_title_slug_and_trimmed_category_rules() { + let page = PageOption { + id: "page".into(), + title: "About Us".into(), + slug: "company-profile".into(), + }; + assert!(page_matches_query(&page, " ABOUT ")); + assert!(page_matches_query(&page, "profile")); + assert!(!page_matches_query(&page, "contact")); + assert!(category_matches_query("Long Form", " long ")); + } + + fn fixture() -> MenuEditorState { + MenuEditorState::ready( + "project".into(), + vec![ + node(HOME_ID, MenuItemKind::Home, "Home", None), + node("page-one", MenuItemKind::Page, "One", Some("one")), + node("section", MenuItemKind::Submenu, "Section", None), + ], + vec![PageOption { + id: "page-two".into(), + title: "Two".into(), + slug: "two".into(), + }], + vec!["articles".into()], + ) + } + + fn node(id: &str, kind: MenuItemKind, label: &str, slug: Option<&str>) -> MenuNode { + MenuNode { + id: id.into(), + kind, + label: label.into(), + slug: slug.map(str::to_string), + children: Vec::new(), + } + } + + fn labels(items: &[MenuNode]) -> Vec<&str> { + items.iter().map(|item| item.label.as_str()).collect() + } +} diff --git a/crates/bds-ui/src/views/mod.rs b/crates/bds-ui/src/views/mod.rs index 145f31a..0ad67e0 100644 --- a/crates/bds-ui/src/views/mod.rs +++ b/crates/bds-ui/src/views/mod.rs @@ -7,6 +7,7 @@ pub mod duplicates; pub mod git; pub mod import_editor; pub mod media_editor; +pub mod menu_editor; pub mod metadata_diff; pub mod modal; pub mod panel; diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 100d875..661c9e2 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -22,6 +22,7 @@ use crate::views::{ duplicates::{self, DuplicatesState}, git::{self, GitDiffState, GitUiState}, media_editor::{self, MediaEditorState}, + menu_editor::{self, MenuEditorState}, metadata_diff::{self, MetadataDiffState}, modal, panel, post_editor::{self, PostEditorState}, @@ -134,6 +135,7 @@ pub fn view<'a>( guide_documentation: &'a DocumentationState, api_documentation: &'a DocumentationState, metadata_diff_state: &'a MetadataDiffState, + menu_editor_state: &'a MenuEditorState, translation_validation_state: &'a TranslationValidationState, git_state: &'a GitUiState, git_diffs: &'a HashMap, @@ -167,6 +169,7 @@ pub fn view<'a>( guide_documentation, api_documentation, metadata_diff_state, + menu_editor_state, translation_validation_state, git_diffs, ); @@ -412,6 +415,7 @@ fn route_content_area<'a>( guide_documentation: &'a DocumentationState, api_documentation: &'a DocumentationState, metadata_diff_state: &'a MetadataDiffState, + menu_editor_state: &'a MenuEditorState, translation_validation_state: &'a TranslationValidationState, git_diffs: &'a HashMap, ) -> Element<'a, Message> { @@ -504,6 +508,7 @@ fn route_content_area<'a>( ContentRoute::Documentation => documentation::view(guide_documentation, locale), ContentRoute::ApiDocumentation => documentation::view(api_documentation, locale), ContentRoute::MetadataDiff => metadata_diff::view(metadata_diff_state, locale), + ContentRoute::MenuEditor => menu_editor::view(menu_editor_state, locale), ContentRoute::TranslationValidation => { translation_validation::view(translation_validation_state, locale) } @@ -542,6 +547,7 @@ enum ContentRoute<'a> { Documentation, ApiDocumentation, MetadataDiff, + MenuEditor, TranslationValidation, GitDiff(&'a str), Placeholder(&'a str), @@ -632,7 +638,8 @@ fn route_kind<'a>( TabType::FindDuplicates => ContentRoute::FindDuplicates, TabType::Documentation => ContentRoute::Documentation, TabType::ApiDocumentation => ContentRoute::ApiDocumentation, - TabType::Style | TabType::MenuEditor => ContentRoute::Placeholder(&tab.title), + TabType::MenuEditor => ContentRoute::MenuEditor, + TabType::Style => ContentRoute::Placeholder(&tab.title), TabType::TranslationValidation => ContentRoute::TranslationValidation, } } @@ -689,7 +696,7 @@ mod tests { let empty_scripts = HashMap::new(); let empty_imports = HashMap::new(); let site_validation_state = SiteValidationState::default(); - let unsupported = [TabType::Style, TabType::MenuEditor]; + let unsupported = [TabType::Style]; for tab_type in unsupported { let tabs = vec![tab("tool", tab_type.clone(), "Tool")]; @@ -713,6 +720,31 @@ mod tests { } } + #[test] + fn menu_editor_tab_routes_to_real_editor() { + let empty_posts = HashMap::new(); + let empty_media = HashMap::new(); + let empty_templates = HashMap::new(); + let empty_scripts = HashMap::new(); + let empty_imports = HashMap::new(); + let site_validation = SiteValidationState::default(); + let tabs = vec![tab("menu", TabType::MenuEditor, "Menu")]; + let route = route_kind( + &tabs, + Some("menu"), + &empty_posts, + &empty_media, + &empty_templates, + &empty_scripts, + &empty_imports, + None, + None, + None, + &site_validation, + ); + assert!(matches!(route, ContentRoute::MenuEditor)); + } + #[test] fn documentation_tabs_route_to_real_read_only_views() { let empty_posts = HashMap::new(); diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 85dde56..2ca1a4c 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -78,6 +78,36 @@ common-save = Speichern common-open = Öffnen common-cancel = Abbrechen common-delete = Löschen +menuEditor-title = Menü-Editor +menuEditor-description = Erstellen Sie die in meta/menu.opml gespeicherte Seitennavigation. Ziehen Sie Einträge oder verwenden Sie die zugänglichen Verschiebesteuerungen. +menuEditor-addEntry = Seite oder Untermenü hinzufügen +menuEditor-addCategory = Kategoriearchiv hinzufügen +menuEditor-reload = Neu laden +menuEditor-moveUp = Nach oben +menuEditor-drag = Menüeintrag ziehen +menuEditor-moveDown = Nach unten +menuEditor-indent = Einrücken +menuEditor-unindent = Ausrücken +menuEditor-loading = Menü wird geladen… +menuEditor-loadFailed = Das Menü konnte nicht geladen werden. +menuEditor-loadFailedDetail = Das Menü konnte nicht geladen werden: { $error } +menuEditor-saveFailed = Das Menü konnte nicht gespeichert werden: { $error } +menuEditor-saved = Menü gespeichert. +menuEditor-empty = Das Menü ist leer. +menuEditor-noProject = Kein aktives Projekt verfügbar. +menuEditor-finishDraft = Schließen Sie den neuen Eintrag ab oder brechen Sie ihn vor dem Speichern ab. +menuEditor-draft = Neuer Eintrag +menuEditor-pagePlaceholder = Seiten suchen oder Untermenütitel eingeben +menuEditor-categoryPlaceholder = Kategorie suchen oder eingeben +menuEditor-createSubmenu = Untermenü erstellen +menuEditor-useCategory = Kategorie verwenden +menuEditor-categoryRequired = Geben Sie einen Kategorienamen ein. +menuEditor-categoryCreateFailed = Die Kategorie konnte nicht erstellt werden: { $error } +menuEditor-newSubmenu = Neues Untermenü +menuEditor-kindHome = Startseite +menuEditor-kindPage = Seite +menuEditor-kindSubmenu = Untermenü +menuEditor-kindCategory = Kategoriearchiv chat-sidebar-empty = Noch keine Unterhaltungen chat-new = Neue Unterhaltung chat-newWithModel = Unterhaltung mit { $model } diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 587a1a1..e046be4 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -78,6 +78,36 @@ common-save = Save common-open = Open common-cancel = Cancel common-delete = Delete +menuEditor-title = Menu Editor +menuEditor-description = Build the site navigation stored in meta/menu.opml. Drag entries or use the accessible move controls. +menuEditor-addEntry = Add page or submenu +menuEditor-addCategory = Add category archive +menuEditor-reload = Reload +menuEditor-moveUp = Move up +menuEditor-drag = Drag menu entry +menuEditor-moveDown = Move down +menuEditor-indent = Indent +menuEditor-unindent = Unindent +menuEditor-loading = Loading menu… +menuEditor-loadFailed = The menu could not be loaded. +menuEditor-loadFailedDetail = The menu could not be loaded: { $error } +menuEditor-saveFailed = The menu could not be saved: { $error } +menuEditor-saved = Menu saved. +menuEditor-empty = The menu is empty. +menuEditor-noProject = No active project is available. +menuEditor-finishDraft = Finish or cancel the new entry before saving. +menuEditor-draft = New entry +menuEditor-pagePlaceholder = Search pages or enter a submenu title +menuEditor-categoryPlaceholder = Search or enter a category +menuEditor-createSubmenu = Create submenu +menuEditor-useCategory = Use category +menuEditor-categoryRequired = Enter a category name. +menuEditor-categoryCreateFailed = The category could not be created: { $error } +menuEditor-newSubmenu = New Submenu +menuEditor-kindHome = Home +menuEditor-kindPage = Page +menuEditor-kindSubmenu = Submenu +menuEditor-kindCategory = Category archive chat-sidebar-empty = No conversations yet chat-new = New Chat chat-newWithModel = Chat with { $model } diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 14d67f0..b790026 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -78,6 +78,36 @@ common-save = Guardar common-open = Abrir common-cancel = Cancelar common-delete = Eliminar +menuEditor-title = Editor de menú +menuEditor-description = Cree la navegación del sitio guardada en meta/menu.opml. Arrastre las entradas o use los controles de movimiento accesibles. +menuEditor-addEntry = Añadir página o submenú +menuEditor-addCategory = Añadir archivo de categoría +menuEditor-reload = Recargar +menuEditor-moveUp = Subir +menuEditor-drag = Arrastrar entrada del menú +menuEditor-moveDown = Bajar +menuEditor-indent = Aumentar sangría +menuEditor-unindent = Reducir sangría +menuEditor-loading = Cargando menú… +menuEditor-loadFailed = No se pudo cargar el menú. +menuEditor-loadFailedDetail = No se pudo cargar el menú: { $error } +menuEditor-saveFailed = No se pudo guardar el menú: { $error } +menuEditor-saved = Menú guardado. +menuEditor-empty = El menú está vacío. +menuEditor-noProject = No hay ningún proyecto activo disponible. +menuEditor-finishDraft = Termine o cancele la nueva entrada antes de guardar. +menuEditor-draft = Nueva entrada +menuEditor-pagePlaceholder = Buscar páginas o introducir el título de un submenú +menuEditor-categoryPlaceholder = Buscar o introducir una categoría +menuEditor-createSubmenu = Crear submenú +menuEditor-useCategory = Usar categoría +menuEditor-categoryRequired = Introduzca un nombre de categoría. +menuEditor-categoryCreateFailed = No se pudo crear la categoría: { $error } +menuEditor-newSubmenu = Nuevo submenú +menuEditor-kindHome = Inicio +menuEditor-kindPage = Página +menuEditor-kindSubmenu = Submenú +menuEditor-kindCategory = Archivo de categoría chat-sidebar-empty = No hay conversaciones chat-new = Nueva conversación chat-newWithModel = Conversación con { $model } diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 934427e..897f6a4 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -78,6 +78,36 @@ common-save = Enregistrer common-open = Ouvrir common-cancel = Annuler common-delete = Supprimer +menuEditor-title = Éditeur de menu +menuEditor-description = Construisez la navigation du site enregistrée dans meta/menu.opml. Faites glisser les entrées ou utilisez les commandes de déplacement accessibles. +menuEditor-addEntry = Ajouter une page ou un sous-menu +menuEditor-addCategory = Ajouter une archive de catégorie +menuEditor-reload = Recharger +menuEditor-moveUp = Monter +menuEditor-drag = Faire glisser l’entrée du menu +menuEditor-moveDown = Descendre +menuEditor-indent = Indenter +menuEditor-unindent = Désindenter +menuEditor-loading = Chargement du menu… +menuEditor-loadFailed = Impossible de charger le menu. +menuEditor-loadFailedDetail = Impossible de charger le menu : { $error } +menuEditor-saveFailed = Impossible d’enregistrer le menu : { $error } +menuEditor-saved = Menu enregistré. +menuEditor-empty = Le menu est vide. +menuEditor-noProject = Aucun projet actif n’est disponible. +menuEditor-finishDraft = Terminez ou annulez la nouvelle entrée avant d’enregistrer. +menuEditor-draft = Nouvelle entrée +menuEditor-pagePlaceholder = Rechercher des pages ou saisir le titre d’un sous-menu +menuEditor-categoryPlaceholder = Rechercher ou saisir une catégorie +menuEditor-createSubmenu = Créer le sous-menu +menuEditor-useCategory = Utiliser la catégorie +menuEditor-categoryRequired = Saisissez un nom de catégorie. +menuEditor-categoryCreateFailed = Impossible de créer la catégorie : { $error } +menuEditor-newSubmenu = Nouveau sous-menu +menuEditor-kindHome = Accueil +menuEditor-kindPage = Page +menuEditor-kindSubmenu = Sous-menu +menuEditor-kindCategory = Archive de catégorie chat-sidebar-empty = Aucune conversation chat-new = Nouvelle conversation chat-newWithModel = Conversation avec { $model } diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 4de66ce..b89fbc1 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -78,6 +78,36 @@ common-save = Salva common-open = Apri common-cancel = Annulla common-delete = Elimina +menuEditor-title = Editor del menu +menuEditor-description = Crea la navigazione del sito salvata in meta/menu.opml. Trascina le voci o usa i comandi di spostamento accessibili. +menuEditor-addEntry = Aggiungi pagina o sottomenu +menuEditor-addCategory = Aggiungi archivio categoria +menuEditor-reload = Ricarica +menuEditor-moveUp = Sposta su +menuEditor-drag = Trascina la voce del menu +menuEditor-moveDown = Sposta giù +menuEditor-indent = Aumenta rientro +menuEditor-unindent = Riduci rientro +menuEditor-loading = Caricamento menu… +menuEditor-loadFailed = Impossibile caricare il menu. +menuEditor-loadFailedDetail = Impossibile caricare il menu: { $error } +menuEditor-saveFailed = Impossibile salvare il menu: { $error } +menuEditor-saved = Menu salvato. +menuEditor-empty = Il menu è vuoto. +menuEditor-noProject = Nessun progetto attivo disponibile. +menuEditor-finishDraft = Completa o annulla la nuova voce prima di salvare. +menuEditor-draft = Nuova voce +menuEditor-pagePlaceholder = Cerca pagine o inserisci il titolo di un sottomenu +menuEditor-categoryPlaceholder = Cerca o inserisci una categoria +menuEditor-createSubmenu = Crea sottomenu +menuEditor-useCategory = Usa categoria +menuEditor-categoryRequired = Inserisci un nome di categoria. +menuEditor-categoryCreateFailed = Impossibile creare la categoria: { $error } +menuEditor-newSubmenu = Nuovo sottomenu +menuEditor-kindHome = Home +menuEditor-kindPage = Pagina +menuEditor-kindSubmenu = Sottomenu +menuEditor-kindCategory = Archivio categoria chat-sidebar-empty = Nessuna conversazione chat-new = Nuova conversazione chat-newWithModel = Conversazione con { $model }