From 7a3c5683e74bf2565d0d6f09f7babc1d88c6e355 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 24 Jul 2026 09:00:12 +0200 Subject: [PATCH] feat: reworked the category editing --- README.md | 1 + crates/bds-core/src/engine/meta.rs | 8 + crates/bds-core/src/engine/metadata_diff.rs | 1 + crates/bds-core/src/engine/preview.rs | 2 + crates/bds-core/src/model/metadata.rs | 36 +++ crates/bds-core/src/render/site.rs | 4 +- crates/bds-core/tests/m4_generation_engine.rs | 25 +- crates/bds-ui/src/app.rs | 70 +++++- crates/bds-ui/src/components/inputs.rs | 2 +- crates/bds-ui/src/views/settings_view.rs | 150 +----------- crates/bds-ui/src/views/sidebar.rs | 31 ++- crates/bds-ui/src/views/tags_view.rs | 227 +++++++++++++++++- crates/bds-ui/src/views/workspace.rs | 6 +- locales/ui/de.ftl | 4 + locales/ui/en.ftl | 4 + locales/ui/es.ftl | 4 + locales/ui/fr.ftl | 4 + locales/ui/it.ftl | 4 + specs/editor_settings.allium | 16 -- specs/frontmatter.allium | 2 + specs/metadata.allium | 5 +- specs/rendering.allium | 7 + specs/sidebar_views.allium | 51 +++- 23 files changed, 471 insertions(+), 193 deletions(-) diff --git a/README.md b/README.md index 60e66d3..8e84ca3 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The project is under active development. Core blogging workflows are broadly ava - SQLite and filesystem persistence with byte-canonical bDS2 frontmatter, media sidecars, metadata JSON, and OPML menus; rebuild; bidirectional metadata diff/repair including project categories and publishing preferences; stale post-path cleanup on republish; bDS2-compatible checksums and NFD slug generation; and FTS5 search. - Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace. - Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links. +- A localized Tags workspace manages tags and category settings; its category table includes the main language and every configured translation, whose titles are used by the matching category archives. - 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, effective shared settings with secret-presence redaction, projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`. diff --git a/crates/bds-core/src/engine/meta.rs b/crates/bds-core/src/engine/meta.rs index c55c6e9..611ae87 100644 --- a/crates/bds-core/src/engine/meta.rs +++ b/crates/bds-core/src/engine/meta.rs @@ -446,6 +446,7 @@ pub fn add_category( category.to_string(), CategorySettings { title: None, + titles: BTreeMap::new(), render_in_lists: true, show_title: true, post_template_slug: None, @@ -642,6 +643,7 @@ mod tests { "article".to_string(), CategorySettings { title: None, + titles: BTreeMap::new(), render_in_lists: true, show_title: true, post_template_slug: None, @@ -662,6 +664,7 @@ mod tests { "news".to_string(), CategorySettings { title: Some("News Archive".to_string()), + titles: BTreeMap::from([("de".into(), "Nachrichten".into())]), render_in_lists: false, show_title: true, post_template_slug: Some("article".to_string()), @@ -674,11 +677,13 @@ mod tests { let content = std::fs::read_to_string(dir.path().join("meta/category-meta.json")).unwrap(); let json: serde_json::Value = serde_json::from_str(&content).unwrap(); assert_eq!(json["news"]["title"], "News Archive"); + assert_eq!(json["news"]["titles"]["de"], "Nachrichten"); assert_eq!(json["news"]["renderInLists"], false); assert_eq!(json["news"]["showTitle"], true); let read = read_category_meta_json(dir.path()).unwrap(); assert_eq!(read["news"].title.as_deref(), Some("News Archive")); + assert_eq!(read["news"].title_for("de", "en"), Some("Nachrichten")); } #[test] @@ -689,6 +694,7 @@ mod tests { "zebra".to_string(), CategorySettings { title: Some("Zebra".into()), + titles: BTreeMap::new(), render_in_lists: true, show_title: false, post_template_slug: Some("post".into()), @@ -699,6 +705,7 @@ mod tests { "alpha".to_string(), CategorySettings { title: None, + titles: BTreeMap::new(), render_in_lists: false, show_title: true, post_template_slug: None, @@ -903,6 +910,7 @@ mod tests { "article".to_string(), CategorySettings { title: None, + titles: BTreeMap::new(), render_in_lists: true, show_title: true, post_template_slug: None, diff --git a/crates/bds-core/src/engine/metadata_diff.rs b/crates/bds-core/src/engine/metadata_diff.rs index 5976e18..2a06939 100644 --- a/crates/bds-core/src/engine/metadata_diff.rs +++ b/crates/bds-core/src/engine/metadata_diff.rs @@ -1427,6 +1427,7 @@ mod tests { "news".to_string(), crate::model::metadata::CategorySettings { title: Some("News".into()), + titles: std::collections::BTreeMap::new(), render_in_lists: false, show_title: true, post_template_slug: Some("article".into()), diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs index f02f67b..9eb2f66 100644 --- a/crates/bds-core/src/engine/preview.rs +++ b/crates/bds-core/src/engine/preview.rs @@ -1546,6 +1546,7 @@ mod tests { "hidden".to_string(), crate::model::CategorySettings { title: None, + titles: std::collections::BTreeMap::new(), render_in_lists: false, show_title: true, post_template_slug: None, @@ -1556,6 +1557,7 @@ mod tests { "featured".to_string(), crate::model::CategorySettings { title: None, + titles: std::collections::BTreeMap::new(), render_in_lists: true, show_title: false, post_template_slug: None, diff --git a/crates/bds-core/src/model/metadata.rs b/crates/bds-core/src/model/metadata.rs index ea8874d..a9174e6 100644 --- a/crates/bds-core/src/model/metadata.rs +++ b/crates/bds-core/src/model/metadata.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; pub const SUPPORTED_PICO_THEMES: [&str; 20] = [ @@ -107,6 +109,23 @@ pub struct CategorySettings { pub show_title: bool, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub titles: BTreeMap, +} + +impl CategorySettings { + pub fn title_for(&self, language: &str, main_language: &str) -> Option<&str> { + if language.eq_ignore_ascii_case(main_language) { + self.title.as_deref() + } else { + self.titles + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(language)) + .map(|(_, title)| title.as_str()) + } + .map(str::trim) + .filter(|title| !title.is_empty()) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -177,6 +196,7 @@ mod tests { fn category_settings_camel_case() { let settings = CategorySettings { title: Some("Article".into()), + titles: BTreeMap::new(), render_in_lists: false, show_title: true, post_template_slug: Some("article-tpl".into()), @@ -189,6 +209,22 @@ mod tests { assert!(json.contains("postTemplateSlug")); } + #[test] + fn category_settings_select_title_for_render_language() { + let settings = CategorySettings { + title: Some("Artikel".into()), + titles: std::collections::BTreeMap::from([("en".into(), "Articles".into())]), + render_in_lists: true, + show_title: true, + post_template_slug: None, + list_template_slug: None, + }; + + assert_eq!(settings.title_for("de", "de"), Some("Artikel")); + assert_eq!(settings.title_for("en", "de"), Some("Articles")); + assert_eq!(settings.title_for("fr", "de"), None); + } + #[test] fn tag_entry_roundtrip() { let tag = TagEntry { diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index b89d519..b0af4eb 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -921,9 +921,7 @@ fn build_language_routes( let slug = slugify(&category); let display_name = category_settings .get(&category) - .and_then(|settings| settings.title.as_deref()) - .map(str::trim) - .filter(|title| !title.is_empty()) + .and_then(|settings| settings.title_for(language, main_language(metadata))) .unwrap_or(&category); routes.extend(paginated_route_specs( &records, diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs index 984d03f..e94d693 100644 --- a/crates/bds-core/tests/m4_generation_engine.rs +++ b/crates/bds-core/tests/m4_generation_engine.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use bds_core::db::Database; use bds_core::db::queries::project::insert_project; @@ -254,6 +254,21 @@ fn mixed_source_languages_use_the_main_language_at_canonical_urls() { metadata.main_language = Some("de".into()); metadata.blog_languages = vec!["de".into(), "en".into()]; bds_core::engine::meta::write_project_json(dir.path(), &metadata).unwrap(); + write_category_meta_json( + dir.path(), + &HashMap::from([( + "article".into(), + CategorySettings { + title: Some("Artikel".into()), + titles: BTreeMap::from([("en".into(), "Articles".into())]), + render_in_lists: true, + show_title: true, + post_template_slug: None, + list_template_slug: None, + }, + )]), + ) + .unwrap(); insert_template( db.conn(), &Template { @@ -391,6 +406,12 @@ fn mixed_source_languages_use_the_main_language_at_canonical_urls() { let english_index = std::fs::read_to_string(output.join("en/index.html")).unwrap(); assert!(english_index.contains("English translation body")); assert!(!english_index.contains(">English translation")); + let german_category = + std::fs::read_to_string(output.join("category/article/index.html")).unwrap(); + let english_category = + std::fs::read_to_string(output.join("en/category/article/index.html")).unwrap(); + assert!(german_category.contains("

Artikel

")); + assert!(english_category.contains("

Articles

")); assert!( output .join("2024/03/12/german-private/index.html") @@ -725,6 +746,7 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() { "hidden".to_string(), CategorySettings { title: None, + titles: BTreeMap::new(), render_in_lists: false, show_title: true, post_template_slug: None, @@ -735,6 +757,7 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() { "featured".to_string(), CategorySettings { title: Some("Featured Archive".to_string()), + titles: BTreeMap::from([("de".into(), "Ausgewählt".into())]), render_in_lists: true, show_title: false, post_template_slug: None, diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index d23c686..1a54d11 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -89,6 +89,7 @@ pub enum Message { ToggleSidebar, TogglePanel, OpenSettingsSection(crate::views::settings_view::SettingsSection), + OpenTagsSection(TagsSection), // Sidebar resize SidebarResizeStart, @@ -1678,6 +1679,22 @@ impl BdsApp { self.sync_menu_state(); Task::none() } + Message::OpenTagsSection(section) => { + self.sidebar_view = SidebarView::Tags; + self.sidebar_visible = true; + self.open_singleton_tab(TabType::Tags, "tabBar.tags"); + if self.settings_state.is_none() { + self.settings_state = Some(self.hydrate_settings_state()); + } + if self.tags_view_state.is_none() { + self.reload_tags_state(); + } + if let Some(state) = self.tags_view_state.as_mut() { + state.section = section; + } + self.sync_menu_state(); + Task::none() + } Message::SidebarResizeStart => { self.sidebar_dragging = true; Task::none() @@ -7582,6 +7599,9 @@ impl BdsApp { title: meta .and_then(|value| value.title.clone()) .unwrap_or_else(|| name.clone()), + translated_titles: meta + .map(|value| value.titles.clone()) + .unwrap_or_default(), render_in_lists: meta.map(|value| value.render_in_lists).unwrap_or(true), show_title: meta.map(|value| value.show_title).unwrap_or(true), post_template_slug: meta @@ -8225,9 +8245,13 @@ impl BdsApp { } } } - SettingsMsg::CategoryTitleChanged(name, value) => { + SettingsMsg::CategoryTitleChanged(name, language, value) => { if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) { - row.title = value; + if language.eq_ignore_ascii_case(&state.main_language) { + row.title = value; + } else { + row.translated_titles.insert(language, value); + } } } SettingsMsg::CategoryRenderInListsChanged(name, value) => { @@ -8261,6 +8285,15 @@ impl BdsApp { row.name.clone(), bds_core::model::metadata::CategorySettings { title: Some(row.title.clone()).filter(|title| !title.trim().is_empty()), + titles: row + .translated_titles + .iter() + .filter(|(language, title)| { + !language.eq_ignore_ascii_case(&state.main_language) + && !title.trim().is_empty() + }) + .map(|(language, title)| (language.clone(), title.clone())) + .collect(), render_in_lists: row.render_in_lists, show_title: row.show_title, post_template_slug: (!row.post_template_slug.is_empty()) @@ -8313,6 +8346,7 @@ impl BdsApp { bds_core::model::metadata::CategorySettings { title: Some(row.title.clone()) .filter(|title| !title.trim().is_empty()), + titles: std::collections::BTreeMap::new(), render_in_lists: row.render_in_lists, show_title: row.show_title, post_template_slug: None, @@ -9047,6 +9081,9 @@ impl BdsApp { } } TabType::Tags => { + if self.settings_state.is_none() { + self.settings_state = Some(self.hydrate_settings_state()); + } if self.tags_view_state.is_none() { let project_id = self .active_project @@ -13048,6 +13085,35 @@ mod tests { assert!(meta.semantic_similarity_enabled); } + #[test] + fn save_category_persists_main_and_translated_titles() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + let mut state = app.hydrate_settings_state(); + state.main_language = "de".into(); + state.blog_languages = vec!["de".into(), "en".into()]; + app.settings_state = Some(state); + + let _ = app.handle_settings_msg(SettingsMsg::CategoryTitleChanged( + "article".into(), + "de".into(), + "Artikel".into(), + )); + let _ = app.handle_settings_msg(SettingsMsg::CategoryTitleChanged( + "article".into(), + "en".into(), + "Articles".into(), + )); + let _ = app.handle_settings_msg(SettingsMsg::SaveCategory("article".into())); + + let categories = bds_core::engine::meta::read_category_meta_json(tmp.path()).unwrap(); + assert_eq!(categories["article"].title.as_deref(), Some("Artikel")); + assert_eq!( + categories["article"].title_for("en", "de"), + Some("Articles") + ); + } + #[test] fn add_category_and_reset_defaults_updates_metadata_files() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/components/inputs.rs b/crates/bds-ui/src/components/inputs.rs index b41cae9..a89b68e 100644 --- a/crates/bds-ui/src/components/inputs.rs +++ b/crates/bds-ui/src/components/inputs.rs @@ -159,7 +159,7 @@ pub fn text_editor_style(_theme: &Theme, status: text_editor::Status) -> text_ed } } -fn select_style(_theme: &Theme, status: pick_list::Status) -> pick_list::Style { +pub(crate) fn select_style(_theme: &Theme, status: pick_list::Status) -> pick_list::Style { let border_color = match status { pick_list::Status::Hovered | pick_list::Status::Opened => FOCUS_COLOR, pick_list::Status::Active => BORDER_COLOR, diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index 90a0a8a..71c3df6 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -1,13 +1,14 @@ use iced::widget::text::Shaping; use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input}; use iced::{Alignment, Color, Element, Length}; +use std::collections::BTreeMap; use bds_core::engine::ai::AiEndpointKind; use bds_core::i18n::UiLocale; use crate::app::Message; use crate::components::inputs; -use crate::i18n::{t, tw}; +use crate::i18n::t; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AiModelOption { @@ -41,7 +42,6 @@ pub struct AiModeViewState { pub enum SettingsSection { Project, Editor, - Content, AI, Technology, Publishing, @@ -53,6 +53,7 @@ pub enum SettingsSection { pub struct SettingsCategoryRow { pub name: String, pub title: String, + pub translated_titles: BTreeMap, pub render_in_lists: bool, pub show_title: bool, pub post_template_slug: String, @@ -82,6 +83,7 @@ pub fn default_category_rows() -> Vec { .map(|name| SettingsCategoryRow { name: (*name).to_string(), title: (*name).to_string(), + translated_titles: BTreeMap::new(), render_in_lists: true, show_title: true, post_template_slug: String::new(), @@ -96,7 +98,6 @@ impl SettingsSection { &[ Self::Project, Self::Editor, - Self::Content, Self::AI, Self::Technology, Self::Publishing, @@ -109,7 +110,6 @@ impl SettingsSection { match self { Self::Project => "settings.nav.project", Self::Editor => "settings.nav.editor", - Self::Content => "settings.nav.content", Self::AI => "settings.nav.ai", Self::Technology => "settings.nav.technology", Self::Publishing => "settings.nav.publishing", @@ -141,7 +141,7 @@ pub struct SettingsViewState { pub diff_view_style: String, pub wrap_long_lines: bool, pub hide_unchanged_regions: bool, - // Content + // Categories (Tags workspace) pub categories: Vec, pub new_category_name: String, pub template_options: Vec, @@ -312,7 +312,7 @@ pub enum SettingsMsg { // Content AddCategoryNameChanged(String), AddCategory, - CategoryTitleChanged(String, String), + CategoryTitleChanged(String, String, String), CategoryRenderInListsChanged(String, bool), CategoryShowTitleChanged(String, bool), CategoryPostTemplateChanged(String, String), @@ -445,7 +445,6 @@ fn render_section<'a>( let content: Element<'a, Message> = match section { SettingsSection::Project => section_project(state, locale), SettingsSection::Editor => section_editor(state, locale), - SettingsSection::Content => section_content(state, locale), SettingsSection::AI => section_ai(state, locale), SettingsSection::Technology => section_technology(state, locale), SettingsSection::Publishing => section_publishing(state, locale), @@ -631,143 +630,6 @@ fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element .into() } -fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { - let template_options = std::iter::once(String::new()) - .chain(state.template_options.iter().cloned()) - .collect::>(); - - let category_rows = state - .categories - .iter() - .fold(column![].spacing(8), |column, category| { - let title = inputs::labeled_input( - &tw( - locale, - "settings.categoryTitle", - &[("category", &category.name)], - ), - "", - &category.title, - { - let name = category.name.clone(); - move |value| { - Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value)) - } - }, - ); - let post_template = inputs::labeled_select( - &t(locale, "settings.categoryPostTemplate"), - &template_options, - Some(&category.post_template_slug), - { - let name = category.name.clone(); - move |value| { - Message::Settings(SettingsMsg::CategoryPostTemplateChanged( - name.clone(), - value, - )) - } - }, - ); - let list_template = inputs::labeled_select( - &t(locale, "settings.categoryListTemplate"), - &template_options, - Some(&category.list_template_slug), - { - let name = category.name.clone(); - move |value| { - Message::Settings(SettingsMsg::CategoryListTemplateChanged( - name.clone(), - value, - )) - } - }, - ); - let toggles = column![ - inputs::labeled_checkbox( - &t(locale, "settings.categoryRenderInLists"), - category.render_in_lists, - { - let name = category.name.clone(); - move |value| { - Message::Settings(SettingsMsg::CategoryRenderInListsChanged( - name.clone(), - value, - )) - } - }, - ), - inputs::labeled_checkbox( - &t(locale, "settings.categoryShowTitles"), - category.show_title, - { - let name = category.name.clone(); - move |value| { - Message::Settings(SettingsMsg::CategoryShowTitleChanged( - name.clone(), - value, - )) - } - }, - ), - ] - .spacing(6); - let actions = row![ - button(text(t(locale, "common.save")).size(13)) - .on_press(Message::Settings(SettingsMsg::SaveCategory( - category.name.clone() - ))) - .style(inputs::primary_button) - .padding([6, 12]), - button(text(t(locale, "common.remove")).size(13)) - .on_press_maybe((!category.is_protected).then(|| Message::Settings( - SettingsMsg::RemoveCategory(category.name.clone()) - ))) - .style(inputs::danger_button) - .padding([6, 12]), - ] - .spacing(8); - - column.push( - inputs::card( - column![ - text(category.name.clone()).size(15).color(Color::WHITE), - title, - toggles, - row![post_template, list_template].spacing(12), - actions, - ] - .spacing(8), - ) - .padding(12), - ) - }); - - let add_row = row![ - inputs::labeled_input( - &t(locale, "settings.addCategory"), - "news", - &state.new_category_name, - |value| Message::Settings(SettingsMsg::AddCategoryNameChanged(value)), - ), - button(text(t(locale, "common.add")).size(13)) - .on_press(Message::Settings(SettingsMsg::AddCategory)) - .style(inputs::primary_button) - .padding([6, 12]), - button(text(t(locale, "settings.resetCategories")).size(13)) - .on_press(Message::Settings(SettingsMsg::ResetCategoriesToDefaults)) - .style(inputs::secondary_button) - .padding([6, 12]), - ] - .spacing(8) - .align_y(Alignment::End); - - column![category_rows, add_row] - .spacing(12) - .padding([0, 16]) - .into() -} - fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { let online = ai_mode_block( &state.online_ai, diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index 5a11494..8036bb6 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -1234,7 +1234,6 @@ pub fn view( let sections: &[(&str, Option)] = &[ ("settings.nav.project", Some(SettingsSection::Project)), ("settings.nav.editor", Some(SettingsSection::Editor)), - ("settings.nav.content", Some(SettingsSection::Content)), ("settings.nav.ai", Some(SettingsSection::AI)), ("settings.nav.technology", Some(SettingsSection::Technology)), ("settings.nav.publishing", Some(SettingsSection::Publishing)), @@ -1269,21 +1268,31 @@ pub fn view( iced::widget::Column::with_children(items).spacing(1).into() } SidebarView::Tags => { - // Per sidebar_views.allium TagsNav: 3 fixed-order sections - let sections = ["tags.nav.cloud", "tags.nav.manage", "tags.nav.merge"]; + let sections = [ + ( + "tags.nav.cloud", + crate::views::tags_view::TagsSection::Cloud, + ), + ( + "tags.nav.manage", + crate::views::tags_view::TagsSection::Manage, + ), + ( + "tags.nav.merge", + crate::views::tags_view::TagsSection::Merge, + ), + ( + "tags.nav.categories", + crate::views::tags_view::TagsSection::Categories, + ), + ]; let items: Vec> = sections .iter() - .map(|key| { + .map(|(key, section)| { let label = t(locale, key); let label_text = text(label).size(12).shaping(Shaping::Advanced); button(container(label_text).width(Length::Fill)) - .on_press(Message::OpenTab(Tab { - id: "tags".to_string(), - tab_type: TabType::Tags, - title: t(locale, "tabBar.tags"), - is_transient: false, - is_dirty: false, - })) + .on_press(Message::OpenTagsSection(*section)) .padding([5, 8]) .width(Length::Fill) .style(item_style) diff --git a/crates/bds-ui/src/views/tags_view.rs b/crates/bds-ui/src/views/tags_view.rs index c77f662..12dc5d0 100644 --- a/crates/bds-ui/src/views/tags_view.rs +++ b/crates/bds-ui/src/views/tags_view.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; -use iced::widget::{Space, button, column, container, row, scrollable, text, text_input}; +use iced::widget::{ + Space, button, checkbox, column, container, pick_list, row, scrollable, text, text_input, +}; use iced::{Alignment, Background, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -9,6 +11,7 @@ use bds_core::model::Tag; use crate::app::Message; use crate::components::inputs; use crate::i18n::{t, tw}; +use crate::views::settings_view::{SettingsMsg, SettingsViewState}; const DEFAULT_TAG_COLOR: &str = "#6495ed"; const COLOR_PRESETS: [&str; 17] = [ @@ -24,6 +27,7 @@ pub enum TagsSection { Manage, Merge, Discover, + Categories, } /// State for the tags view. @@ -116,7 +120,11 @@ pub enum TagsMsg { } /// Render the tags management view. -pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> { +pub fn view<'a>( + state: &'a TagsViewState, + settings: &'a SettingsViewState, + locale: UiLocale, +) -> Element<'a, Message> { let section_nav = inputs::card( row![ section_tab( @@ -139,6 +147,11 @@ pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Messa state.section == TagsSection::Discover, TagsSection::Discover ), + section_tab( + &t(locale, "tags.nav.categories"), + state.section == TagsSection::Categories, + TagsSection::Categories + ), ] .spacing(6), ) @@ -149,6 +162,7 @@ pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Messa TagsSection::Manage => view_manage(state, locale), TagsSection::Merge => view_merge(state, locale), TagsSection::Discover => view_discover(state, locale), + TagsSection::Categories => view_categories(settings, locale), }; column![section_nav, content] @@ -158,6 +172,215 @@ pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Messa .into() } +fn view_categories<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { + const NAME_WIDTH: f32 = 120.0; + const TITLE_WIDTH: f32 = 170.0; + const TOGGLE_WIDTH: f32 = 110.0; + const TEMPLATE_WIDTH: f32 = 150.0; + const ACTION_WIDTH: f32 = 150.0; + + let mut languages = vec![state.main_language.clone()]; + for language in &state.blog_languages { + if !languages + .iter() + .any(|existing| existing.eq_ignore_ascii_case(language)) + { + languages.push(language.clone()); + } + } + + let header_cell = |label: String, width| { + container(text(label).size(12).color(inputs::LABEL_COLOR)) + .width(Length::Fixed(width)) + .into() + }; + let mut header: Vec> = + vec![header_cell(t(locale, "tags.name"), NAME_WIDTH)]; + header.extend(languages.iter().map(|language| { + header_cell( + tw(locale, "categories.title", &[("language", language)]), + TITLE_WIDTH, + ) + })); + header.extend([ + header_cell(t(locale, "settings.categoryRenderInLists"), TOGGLE_WIDTH), + header_cell(t(locale, "settings.categoryShowTitles"), TOGGLE_WIDTH), + header_cell(t(locale, "settings.categoryPostTemplate"), TEMPLATE_WIDTH), + header_cell(t(locale, "settings.categoryListTemplate"), TEMPLATE_WIDTH), + header_cell(t(locale, "categories.actions"), ACTION_WIDTH), + ]); + + let template_options = std::iter::once(String::new()) + .chain(state.template_options.iter().cloned()) + .collect::>(); + let mut table = column![iced::widget::Row::with_children(header).spacing(8)].spacing(8); + for category in &state.categories { + let mut cells: Vec> = vec![ + container(text(category.name.clone()).size(13)) + .width(Length::Fixed(NAME_WIDTH)) + .into(), + ]; + cells.extend(languages.iter().map(|language| { + let value = if language.eq_ignore_ascii_case(&state.main_language) { + &category.title + } else { + category + .translated_titles + .get(language) + .map(String::as_str) + .unwrap_or("") + }; + let name = category.name.clone(); + let language = language.clone(); + text_input("", value) + .on_input(move |value| { + Message::Settings(SettingsMsg::CategoryTitleChanged( + name.clone(), + language.clone(), + value, + )) + }) + .padding([7, 9]) + .style(inputs::field_style) + .width(Length::Fixed(TITLE_WIDTH)) + .into() + })); + let name = category.name.clone(); + cells.push( + container( + checkbox("", category.render_in_lists).on_toggle(move |value| { + Message::Settings(SettingsMsg::CategoryRenderInListsChanged( + name.clone(), + value, + )) + }), + ) + .width(Length::Fixed(TOGGLE_WIDTH)) + .into(), + ); + let name = category.name.clone(); + cells.push( + container(checkbox("", category.show_title).on_toggle(move |value| { + Message::Settings(SettingsMsg::CategoryShowTitleChanged(name.clone(), value)) + })) + .width(Length::Fixed(TOGGLE_WIDTH)) + .into(), + ); + let name = category.name.clone(); + cells.push( + pick_list( + template_options.clone(), + Some(category.post_template_slug.clone()), + move |value| { + Message::Settings(SettingsMsg::CategoryPostTemplateChanged( + name.clone(), + value, + )) + }, + ) + .padding([7, 9]) + .style(inputs::select_style) + .width(Length::Fixed(TEMPLATE_WIDTH)) + .into(), + ); + let name = category.name.clone(); + cells.push( + pick_list( + template_options.clone(), + Some(category.list_template_slug.clone()), + move |value| { + Message::Settings(SettingsMsg::CategoryListTemplateChanged( + name.clone(), + value, + )) + }, + ) + .padding([7, 9]) + .style(inputs::select_style) + .width(Length::Fixed(TEMPLATE_WIDTH)) + .into(), + ); + cells.push( + container( + row![ + button(text(t(locale, "common.save")).size(12)) + .on_press(Message::Settings(SettingsMsg::SaveCategory( + category.name.clone(), + ))) + .style(inputs::primary_button) + .padding([6, 10]), + button(text(t(locale, "common.remove")).size(12)) + .on_press_maybe((!category.is_protected).then(|| Message::Settings( + SettingsMsg::RemoveCategory(category.name.clone()), + ))) + .style(inputs::danger_button) + .padding([6, 10]), + ] + .spacing(6), + ) + .width(Length::Fixed(ACTION_WIDTH)) + .into(), + ); + table = table.push( + iced::widget::Row::with_children(cells) + .spacing(8) + .align_y(Alignment::Center), + ); + } + + let add = inputs::card( + row![ + inputs::labeled_input( + &t(locale, "settings.addCategory"), + "news", + &state.new_category_name, + |value| Message::Settings(SettingsMsg::AddCategoryNameChanged(value)), + ), + button(text(t(locale, "common.add")).size(13)) + .on_press(Message::Settings(SettingsMsg::AddCategory)) + .style(inputs::primary_button) + .padding([6, 12]), + button(text(t(locale, "settings.resetCategories")).size(13)) + .on_press(Message::Settings(SettingsMsg::ResetCategoriesToDefaults)) + .style(inputs::secondary_button) + .padding([6, 12]), + ] + .spacing(8) + .align_y(Alignment::End), + ); + let table_width = NAME_WIDTH + + TITLE_WIDTH * languages.len() as f32 + + TOGGLE_WIDTH * 2.0 + + TEMPLATE_WIDTH * 2.0 + + ACTION_WIDTH + + 8.0 * (languages.len() + 5) as f32 + + 24.0; + let table = scrollable( + inputs::card(table) + .padding(12) + .width(Length::Fixed(table_width)), + ) + .direction(scrollable::Direction::Horizontal( + inputs::compact_scrollbar(), + )) + .style(inputs::scrollable_style) + .width(Length::Fill); + + scrollable( + column![ + inputs::card(text(t(locale, "categories.section")).size(18)).padding(16), + add, + table, + ] + .spacing(12) + .padding(16), + ) + .direction(scrollable::Direction::Vertical(inputs::compact_scrollbar())) + .style(inputs::scrollable_style) + .height(Length::Fill) + .into() +} + fn section_tab<'a>(label: &str, active: bool, section: TagsSection) -> Element<'a, Message> { button(text(label.to_string()).size(13)) .on_press(Message::Tags(TagsMsg::SetSection(section))) diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index e585d6f..9366696 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -506,8 +506,8 @@ fn route_content_area<'a>( } } ContentRoute::Tags => { - if let Some(state) = tags_view_state { - tags_view::view(state, locale) + if let (Some(state), Some(settings)) = (tags_view_state, settings_state) { + tags_view::view(state, settings, locale) } else { loading_view(locale) } @@ -646,7 +646,7 @@ fn route_kind<'a>( } TabType::Chat => ContentRoute::Chat(tab_id), TabType::Tags => { - if tags_view_state.is_some() { + if tags_view_state.is_some() && settings_state.is_some() { ContentRoute::Tags } else { ContentRoute::Loading diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 43720c6..b3b8956 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -330,6 +330,7 @@ tags-nav-cloud = Tag-Wolke tags-nav-manage = Tags verwalten tags-nav-merge = Tags zusammenführen tags-nav-discover = Tags entdecken +tags-nav-categories = Kategorien sidebar-chatYesterday = Gestern sidebar-relativeDateMonthDay = { $day }. { $month } modal-confirmDelete-title = Löschen bestätigen @@ -464,6 +465,9 @@ editor-createdAt = Erstellt editor-updatedAt = Aktualisiert editor-publishedAt = Veröffentlicht tags-noTags = Noch keine Tags +categories-section = Kategorien +categories-title = Titel ({ $language }) +categories-actions = Aktionen tags-cloudSection = Wolke tags-cloudHelp = Wählen Sie einen oder mehrere Tags aus, um sie zu bearbeiten, zu löschen oder zusammenzuführen. tags-selectedCount = { $count } ausgewählt diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index a88349c..b7011a5 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -330,6 +330,7 @@ tags-nav-cloud = Tag Cloud tags-nav-manage = Manage Tags tags-nav-merge = Merge Tags tags-nav-discover = Discover Tags +tags-nav-categories = Categories sidebar-chatYesterday = Yesterday sidebar-relativeDateMonthDay = { $month } { $day } modal-confirmDelete-title = Confirm Delete @@ -464,6 +465,9 @@ editor-imageDropFailed = Failed to import { $path }: { $error } editor-imageDropEnrichment = Enrich dropped image: { $name } editor-imageDropEnrichmentFailed = Failed to enrich { $path }: { $error } tags-noTags = No tags yet +categories-section = Categories +categories-title = Title ({ $language }) +categories-actions = Actions tags-cloudSection = Cloud tags-cloudHelp = Select one or more tags to edit, delete, or merge them. tags-selectedCount = { $count } selected diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 289f849..044d4d7 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -330,6 +330,7 @@ tags-nav-cloud = Nube de etiquetas tags-nav-manage = Gestionar etiquetas tags-nav-merge = Fusionar etiquetas tags-nav-discover = Descubrir etiquetas +tags-nav-categories = Categorías sidebar-chatYesterday = Ayer sidebar-relativeDateMonthDay = { $day } { $month } modal-confirmDelete-title = Confirmar eliminación @@ -464,6 +465,9 @@ editor-createdAt = Creado editor-updatedAt = Actualizado editor-publishedAt = Publicado tags-noTags = Sin etiquetas +categories-section = Categorías +categories-title = Título ({ $language }) +categories-actions = Acciones tags-cloudSection = Nube tags-cloudHelp = Selecciona una o más etiquetas para editarlas, eliminarlas o fusionarlas. tags-selectedCount = { $count } seleccionadas diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 602b169..cfc3e3f 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -330,6 +330,7 @@ tags-nav-cloud = Nuage de tags tags-nav-manage = Gérer les tags tags-nav-merge = Fusionner les tags tags-nav-discover = Découvrir les tags +tags-nav-categories = Catégories sidebar-chatYesterday = Hier sidebar-relativeDateMonthDay = { $day } { $month } modal-confirmDelete-title = Confirmer la suppression @@ -464,6 +465,9 @@ editor-createdAt = Créé editor-updatedAt = Mis à jour editor-publishedAt = Publié tags-noTags = Aucun tag +categories-section = Catégories +categories-title = Titre ({ $language }) +categories-actions = Actions tags-cloudSection = Nuage tags-cloudHelp = Sélectionnez un ou plusieurs tags pour les modifier, les supprimer ou les fusionner. tags-selectedCount = { $count } sélectionnés diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 1dbe760..a33d17d 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -330,6 +330,7 @@ tags-nav-cloud = Nuvola di tag tags-nav-manage = Gestisci tag tags-nav-merge = Unisci tag tags-nav-discover = Scopri tag +tags-nav-categories = Categorie sidebar-chatYesterday = Ieri sidebar-relativeDateMonthDay = { $day } { $month } modal-confirmDelete-title = Conferma eliminazione @@ -464,6 +465,9 @@ editor-createdAt = Creato editor-updatedAt = Aggiornato editor-publishedAt = Pubblicato tags-noTags = Nessun tag +categories-section = Categorie +categories-title = Titolo ({ $language }) +categories-actions = Azioni tags-cloudSection = Nuvola tags-cloudHelp = Seleziona uno o più tag per modificarli, eliminarli o unirli. tags-selectedCount = { $count } selezionati diff --git a/specs/editor_settings.allium b/specs/editor_settings.allium index c0939cf..0526c91 100644 --- a/specs/editor_settings.allium +++ b/specs/editor_settings.allium @@ -14,7 +14,6 @@ value SettingsView { active_sections: List -- visible sections after search filter project_section: SettingsProjectSection? editor_section: SettingsEditorSection? - categories: List ai_section: SettingsAISection? technology_section: SettingsTechnologySection? publishing_section: SettingsPublishingSection? @@ -42,16 +41,6 @@ value SettingsEditorSection { hide_unchanged_regions: Boolean -- checkbox } -value SettingsCategoryRow { - name: String -- read-only for protected categories - title: String -- editable - render_in_lists: Boolean -- checkbox - show_titles: Boolean -- checkbox - post_template_slug: String? -- select - list_template_slug: String? -- select - is_protected: Boolean -- true for: article, aside, page, picture -} - value SettingsAISection { online: SettingsAIEndpoint airplane: SettingsAIEndpoint @@ -97,11 +86,6 @@ value SettingsDataSection { rebuild_targets: List -- posts | media | scripts | templates | links | thumbnails | embedding } -invariant SettingsProtectedCategories { - -- Protected categories (article, aside, page, picture) cannot be deleted. - -- Their Remove button is disabled. -} - invariant SettingsMCPAgents { -- MCP section has exactly 7 agent rows in this order: -- Claude Code, Claude Desktop, GitHub Copilot, Gemini CLI, diff --git a/specs/frontmatter.allium b/specs/frontmatter.allium index 8abf253..33164f4 100644 --- a/specs/frontmatter.allium +++ b/specs/frontmatter.allium @@ -314,6 +314,8 @@ value CategoryMetaJson { value CategorySettings { renderInLists: Boolean showTitle: Boolean + title: String? + titles: Map postTemplateSlug: String? listTemplateSlug: String? } diff --git a/specs/metadata.allium b/specs/metadata.allium index d052f34..29a4595 100644 --- a/specs/metadata.allium +++ b/specs/metadata.allium @@ -29,6 +29,7 @@ surface PublishingPreferencesSurface { value CategoryRenderSettings { title: String? + titles: Map -- translated titles keyed by language render_in_lists: Boolean show_title: Boolean post_template_slug: String? @@ -78,7 +79,9 @@ invariant MetadataPersistedAsFiles { -- sshMode, sshRemotePath, sshUser (nil keys absent; sshMode defaults to scp). -- Category names and per-category keys are byte-order sorted; per-category -- keys are listTemplateSlug, postTemplateSlug, renderInLists, showTitle, - -- title. categories.json uses case-sensitive byte-order sorting. tags.json + -- title, titles. The title is for the main language; titles contains + -- configured translations keyed by language. categories.json uses + -- case-sensitive byte-order sorting. tags.json -- remains case-insensitively name-sorted; entry keys are color, name, -- postTemplateSlug and blank optional values are absent. } diff --git a/specs/rendering.allium b/specs/rendering.allium index 09db232..e8a0456 100644 --- a/specs/rendering.allium +++ b/specs/rendering.allium @@ -222,6 +222,13 @@ rule BuildListAssigns { ensures: ListRenderAssigns } +invariant CategoryArchiveTitleLanguage { + -- A category archive uses the title configured for its render language. + -- The main-language archive uses title; translated archives use the entry + -- in titles keyed by their language; a missing title falls back to the + -- category name. +} + invariant SharedRenderPathForPreviewAndGeneration { -- Preview and generation produce identical HTML for the same input because -- both build assigns through this subsystem and render via the same Liquid diff --git a/specs/sidebar_views.allium b/specs/sidebar_views.allium index 4b8e43c..cc390e0 100644 --- a/specs/sidebar_views.allium +++ b/specs/sidebar_views.allium @@ -447,16 +447,15 @@ value SettingsNavEntry { } invariant SettingsNavSections { - -- Settings navigation has exactly 9 entries in this fixed order: + -- Settings navigation has exactly 8 entries in this fixed order: -- 1. section="project", icon="folder", label_key="settings.nav.project" -- 2. section="editor", icon="notepad", label_key="settings.nav.editor" - -- 3. section="content", icon="clipboard", label_key="settings.nav.content" - -- 4. section="ai", icon="robot", label_key="settings.nav.ai" - -- 5. section="technology", icon="gear", label_key="settings.nav.technology" - -- 6. section="publishing", icon="rocket", label_key="settings.nav.publishing" - -- 7. section="data", icon="database", label_key="settings.nav.data" - -- 8. section="mcp", icon="plug", label_key="settings.nav.mcp" - -- 9. section="style", icon="palette", label_key="settings.nav.style" + -- 3. section="ai", icon="robot", label_key="settings.nav.ai" + -- 4. section="technology", icon="gear", label_key="settings.nav.technology" + -- 5. section="publishing", icon="rocket", label_key="settings.nav.publishing" + -- 6. section="data", icon="database", label_key="settings.nav.data" + -- 7. section="mcp", icon="plug", label_key="settings.nav.mcp" + -- 8. section="style", icon="palette", label_key="settings.nav.style" -- Labels are localised via their label_key through i18n. } @@ -513,10 +512,11 @@ value TagsNavEntry { } invariant TagsNavSections { - -- Tags navigation has exactly 3 entries in this fixed order: + -- Tags navigation has exactly 4 entries in this fixed order: -- 1. section="cloud", icon="cloud", label_key="tags.nav.cloud" -- tag cloud visualisation -- 2. section="manage", icon="pencil", label_key="tags.nav.manage" -- create/edit tags -- 3. section="merge", icon="merge", label_key="tags.nav.merge" -- merge duplicate tags + -- 4. section="categories", icon="list", label_key="tags.nav.categories" -- edit categories -- Labels are localised via their label_key through i18n. } @@ -559,6 +559,39 @@ rule TagsNavClick { -- Active section is persisted across sidebar switches } +value CategoryEditor { + main_language: String + translation_languages: List + rows: List +} + +value CategoryEditorRow { + name: String + titles: Map + render_in_lists: Boolean + show_title: Boolean + post_template_slug: String? + list_template_slug: String? + is_protected: Boolean +} + +surface CategoryEditorSurface { + context editor: CategoryEditor + + exposes: + editor.main_language + editor.translation_languages + editor.rows.count + + @guarantee TableLayout + -- Category settings render as one table. Its first title column is the + -- main language and every configured translation language adds one + -- further title column. + + @guarantee ProtectedCategories + -- article, aside, page, and picture cannot be removed. +} + -- ─── 8. Chat view ───────────────────────────────────────────── -- Follows SidebarEntityListPattern.