feat: reworked the category editing

This commit is contained in:
2026-07-24 09:00:12 +02:00
parent d9085cf681
commit 7a3c5683e7
23 changed files with 471 additions and 193 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -314,6 +314,8 @@ value CategoryMetaJson {
value CategorySettings {
renderInLists: Boolean
showTitle: Boolean
title: String?
titles: Map<String, String>
postTemplateSlug: String?
listTemplateSlug: String?
}

View File

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

View File

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

View File

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