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

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