feat: reworked the category editing
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub titles: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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</a></h2>"));
|
||||
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("<h1 class=\"archive-heading\">Artikel</h1>"));
|
||||
assert!(english_category.contains("<h1 class=\"archive-heading\">Articles</h1>"));
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, String>,
|
||||
pub render_in_lists: bool,
|
||||
pub show_title: bool,
|
||||
pub post_template_slug: String,
|
||||
@@ -82,6 +83,7 @@ pub fn default_category_rows() -> Vec<SettingsCategoryRow> {
|
||||
.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<SettingsCategoryRow>,
|
||||
pub new_category_name: String,
|
||||
pub template_options: Vec<String>,
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -1234,7 +1234,6 @@ pub fn view(
|
||||
let sections: &[(&str, Option<SettingsSection>)] = &[
|
||||
("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<Element<'static, Message>> = 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)
|
||||
|
||||
@@ -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<Element<'a, Message>> =
|
||||
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::<Vec<_>>();
|
||||
let mut table = column![iced::widget::Row::with_children(header).spacing(8)].spacing(8);
|
||||
for category in &state.categories {
|
||||
let mut cells: Vec<Element<'a, Message>> = 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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,6 @@ value SettingsView {
|
||||
active_sections: List<String> -- visible sections after search filter
|
||||
project_section: SettingsProjectSection?
|
||||
editor_section: SettingsEditorSection?
|
||||
categories: List<SettingsCategoryRow>
|
||||
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<String> -- 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,
|
||||
|
||||
@@ -314,6 +314,8 @@ value CategoryMetaJson {
|
||||
value CategorySettings {
|
||||
renderInLists: Boolean
|
||||
showTitle: Boolean
|
||||
title: String?
|
||||
titles: Map<String, String>
|
||||
postTemplateSlug: String?
|
||||
listTemplateSlug: String?
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ surface PublishingPreferencesSurface {
|
||||
|
||||
value CategoryRenderSettings {
|
||||
title: String?
|
||||
titles: Map<String, String> -- 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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>
|
||||
rows: List<CategoryEditorRow>
|
||||
}
|
||||
|
||||
value CategoryEditorRow {
|
||||
name: String
|
||||
titles: Map<String, String>
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user