diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 4749c3f..da9f6ad 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -8,7 +8,7 @@ use bds_core::db::Database; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; use bds_core::engine; use bds_core::i18n::{detect_os_locale, UiLocale}; -use bds_core::model::{Media, Post, Project, PublishingPreferences, Script, SshMode, Template}; +use bds_core::model::{Media, Post, PostStatus, Project, PublishingPreferences, Script, SshMode, Template}; use crate::i18n::{t, tw}; use crate::platform::menu::{self, MenuAction, MenuRegistry}; @@ -27,8 +27,8 @@ use crate::views::{ template_editor::{TemplateEditorState, TemplateEditorMsg}, script_editor::{ScriptEditorState, ScriptEditorMsg}, tags_view::{self, TagsMsg, TagsSection, TagsViewState}, - settings_view::{SettingsViewState, SettingsMsg}, - dashboard::DashboardState, + settings_view::{default_category_rows, SettingsCategoryRow, SettingsViewState, SettingsMsg}, + dashboard::{DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardTimelineMonth}, }; // ─────────────────────────────────────────────────────────── @@ -302,9 +302,50 @@ fn save_editor_settings_state_impl( .map_err(|e| e.to_string()) } +fn month_abbreviation(month: u32) -> String { + match month { + 1 => "Jan", + 2 => "Feb", + 3 => "Mar", + 4 => "Apr", + 5 => "May", + 6 => "Jun", + 7 => "Jul", + 8 => "Aug", + 9 => "Sep", + 10 => "Oct", + 11 => "Nov", + 12 => "Dec", + _ => "?", + } + .to_string() +} + +fn format_timestamp(timestamp_ms: i64) -> String { + let secs = timestamp_ms / 1000; + let (year, month, day) = bds_core::util::timestamp::year_month_day_from_unix_ms(timestamp_ms); + let hour = ((secs % 86_400) / 3_600) as u32; + let minute = ((secs % 3_600) / 60) as u32; + format!("{year}-{month:02}-{day:02} {hour:02}:{minute:02}") +} + +fn format_bytes(size: i64) -> String { + let size = size.max(0) as f64; + if size < 1024.0 { + return format!("{} B", size as i64); + } + if size < 1024.0 * 1024.0 { + return format!("{:.1} KB", size / 1024.0); + } + if size < 1024.0 * 1024.0 * 1024.0 { + return format!("{:.1} MB", size / (1024.0 * 1024.0)); + } + format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0)) +} + #[cfg(test)] mod tests { - use super::{persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, POST_AUTO_SAVE_DELAY_MS}; + use super::{persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, PostStatus, SettingsMsg, POST_AUTO_SAVE_DELAY_MS}; use crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; use crate::views::post_editor::PostEditorState; use crate::views::script_editor::ScriptEditorState; @@ -352,8 +393,18 @@ mod tests { insert_project(db.conn(), &project).unwrap(); let tempdir = TempDir::new().unwrap(); std::fs::create_dir_all(tempdir.path().join("meta")).unwrap(); - std::fs::write(tempdir.path().join("meta/project.json"), "{}\n").unwrap(); + std::fs::write( + tempdir.path().join("meta/project.json"), + r#"{"name":"Test Project","maxPostsPerPage":50,"blogLanguages":["en"],"semanticSimilarityEnabled":false}"#, + ) + .unwrap(); std::fs::write(tempdir.path().join("meta/publishing.json"), "{}\n").unwrap(); + std::fs::write( + tempdir.path().join("meta/categories.json"), + r#"["article","aside","page","picture"]"#, + ) + .unwrap(); + std::fs::write(tempdir.path().join("meta/category-meta.json"), "{}\n").unwrap(); (db, project, tempdir) } @@ -696,6 +747,117 @@ mod tests { assert!(linked.is_empty()); assert!(app.media_editors.get(&imported.id).unwrap().linked_posts.is_empty()); } + + #[test] + fn refresh_counts_populates_dashboard_state() { + let (db, project, tmp) = setup(); + let first = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "First", + Some("Body"), + vec!["rust".to_string()], + vec!["article".to_string()], + None, + Some("en"), + None, + ) + .unwrap(); + let second = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Second", + Some("Body"), + vec!["lua".to_string()], + vec!["aside".to_string()], + None, + Some("en"), + None, + ) + .unwrap(); + let mut second_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &second.id).unwrap(); + second_post.status = PostStatus::Published; + bds_core::db::queries::post::update_post(db.conn(), &second_post).unwrap(); + bds_core::engine::tag::discover_tags(db.conn(), tmp.path(), &project.id).unwrap(); + + let source = tmp.path().join("tiny.png"); + std::fs::write(&source, tiny_png_bytes()).unwrap(); + media::import_media( + db.conn(), + tmp.path(), + &project.id, + &source, + "tiny.png", + Some("Tiny"), + Some("Alt"), + None, + None, + Some("en"), + vec!["cover".to_string()], + ) + .unwrap(); + + let mut app = make_app(db, project, &tmp); + let _ = app.refresh_counts(); + + let dash = app.dashboard_state.expect("dashboard state should be set"); + assert_eq!(dash.stats.total_posts, 2); + assert_eq!(dash.stats.published_count, 1); + assert_eq!(dash.stats.media_count, 1); + assert_eq!(dash.stats.tag_count, 2); + assert_eq!(dash.recent_posts.len(), 2); + assert!(!dash.category_cloud.is_empty()); + assert!(!dash.timeline.is_empty()); + assert_eq!(dash.recent_posts[0].title, "Second"); + assert_eq!(first.title, "First"); + } + + #[test] + fn save_project_persists_languages_and_blogmark_category() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + let mut state = app.hydrate_settings_state(); + state.project_name = "Localized Project".to_string(); + state.main_language = "de".to_string(); + state.blog_languages = vec!["de".to_string(), "en".to_string()]; + state.blogmark_category = "article".to_string(); + state.semantic_similarity_enabled = true; + app.settings_state = Some(state); + + let _ = app.handle_settings_msg(SettingsMsg::SaveProject); + + let meta = bds_core::engine::meta::read_project_json(tmp.path()).unwrap(); + assert_eq!(meta.name, "Localized Project"); + assert_eq!(meta.main_language.as_deref(), Some("de")); + assert_eq!(meta.blog_languages, vec!["de".to_string(), "en".to_string()]); + assert_eq!(meta.blogmark_category.as_deref(), Some("article")); + assert!(meta.semantic_similarity_enabled); + } + + #[test] + fn add_category_and_reset_defaults_updates_metadata_files() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + app.settings_state = Some(app.hydrate_settings_state()); + + let _ = app.handle_settings_msg(SettingsMsg::AddCategoryNameChanged("news".to_string())); + let _ = app.handle_settings_msg(SettingsMsg::AddCategory); + + let categories = bds_core::engine::meta::read_categories_json(tmp.path()).unwrap(); + assert!(categories.iter().any(|category| category == "news")); + + let _ = app.handle_settings_msg(SettingsMsg::ResetCategoriesToDefaults); + + let categories = bds_core::engine::meta::read_categories_json(tmp.path()).unwrap(); + assert_eq!(categories, vec![ + "article".to_string(), + "aside".to_string(), + "page".to_string(), + "picture".to_string(), + ]); + } } // ─────────────────────────────────────────────────────────── @@ -2539,6 +2701,8 @@ impl BdsApp { } } } + + self.dashboard_state = Some(self.hydrate_dashboard_state()); } // Refresh sidebar data with current filters (async — off main thread) @@ -2548,6 +2712,119 @@ impl BdsApp { Task::batch([t1, t2]) } + fn hydrate_dashboard_state(&self) -> DashboardState { + let Some(project) = &self.active_project else { + return DashboardState::new(String::new()); + }; + let Some(db) = &self.db else { + return DashboardState::new(project.name.clone()); + }; + + let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id) + .unwrap_or_default(); + let media = bds_core::db::queries::media::list_media_by_project(db.conn(), &project.id) + .unwrap_or_default(); + let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id) + .unwrap_or_default(); + + let mut draft_count = 0usize; + let mut published_count = 0usize; + let mut archived_count = 0usize; + let mut monthly_counts: std::collections::BTreeMap<(i32, u32), usize> = std::collections::BTreeMap::new(); + let mut category_counts: HashMap = HashMap::new(); + let mut tag_counts: HashMap = HashMap::new(); + + for post in &posts { + match post.status { + PostStatus::Draft => draft_count += 1, + PostStatus::Published => published_count += 1, + PostStatus::Archived => archived_count += 1, + } + + let (year, month, _) = bds_core::util::timestamp::year_month_day_from_unix_ms(post.updated_at); + let year = year.parse::().unwrap_or(0); + let month = month.parse::().unwrap_or(0); + *monthly_counts.entry((year, month)).or_insert(0) += 1; + + for category in &post.categories { + *category_counts.entry(category.clone()).or_insert(0) += 1; + } + for tag in &post.tags { + *tag_counts.entry(tag.to_lowercase()).or_insert(0) += 1; + } + } + + let image_count = media.iter().filter(|item| item.mime_type.starts_with("image/")).count(); + let total_media_size = media.iter().map(|item| item.size).sum::(); + + let mut timeline = monthly_counts + .into_iter() + .rev() + .take(12) + .map(|((year, month), count)| DashboardTimelineMonth { + label: month_abbreviation(month), + year, + count, + }) + .collect::>(); + timeline.reverse(); + + let mut tag_cloud = tags + .into_iter() + .map(|tag| DashboardTag { + count: tag_counts.get(&tag.name.to_lowercase()).copied().unwrap_or(0), + name: tag.name, + color: tag.color, + }) + .collect::>(); + tag_cloud.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase())); + let tag_overflow_count = tag_cloud.len().saturating_sub(40); + tag_cloud.truncate(40); + + let mut category_cloud = category_counts + .into_iter() + .map(|(name, count)| DashboardCategory { name, count }) + .collect::>(); + category_cloud.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase())); + + let mut sorted_posts = posts; + sorted_posts.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); + let mut recent_posts = sorted_posts + .into_iter() + .map(|post| DashboardRecentPost { + post_id: post.id, + title: if post.title.trim().is_empty() { "Untitled".to_string() } else { post.title }, + status: match post.status { + PostStatus::Published => "published".to_string(), + _ => "draft".to_string(), + }, + date: format_timestamp(post.updated_at), + }) + .collect::>(); + recent_posts.truncate(5); + + DashboardState { + title: t(self.ui_locale, "dashboard.overview"), + subtitle: project.name.clone(), + stats: DashboardStats { + total_posts: draft_count + published_count + archived_count, + published_count, + draft_count, + archived_count, + media_count: media.len(), + image_count, + total_media_size: format_bytes(total_media_size), + tag_count: tag_counts.len(), + category_count: category_cloud.len(), + }, + timeline, + tag_cloud, + tag_overflow_count, + category_cloud, + recent_posts, + } + } + /// Number of items to load per sidebar page. /// Matches the TypeScript app's limit of 500 for initial load. const SIDEBAR_PAGE_SIZE: i64 = 500; @@ -3710,9 +3987,43 @@ impl BdsApp { if let Some(data_dir) = &self.data_dir { if let Ok(meta) = engine::meta::read_project_json(data_dir) { state.public_url = meta.public_url.unwrap_or_default(); + state.main_language = meta.main_language.unwrap_or_else(|| self.content_language.clone()); state.default_author = meta.default_author.unwrap_or_default(); state.max_posts_per_page = meta.max_posts_per_page.to_string(); + state.blogmark_category = meta.blogmark_category.unwrap_or_default(); + state.blog_languages = if meta.blog_languages.is_empty() { + vec![state.main_language.clone()] + } else { + meta.blog_languages + }; + if !state.blog_languages.iter().any(|language| language == &state.main_language) { + state.blog_languages.push(state.main_language.clone()); + } + state.semantic_similarity_enabled = meta.semantic_similarity_enabled; } + let categories = engine::meta::read_categories_json(data_dir).unwrap_or_else(|_| { + default_category_rows().into_iter().map(|row| row.name).collect() + }); + let category_meta = engine::meta::read_category_meta_json(data_dir).unwrap_or_default(); + state.categories = categories + .into_iter() + .map(|name| { + let meta = category_meta.get(&name); + SettingsCategoryRow { + title: name.clone(), + 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 + .and_then(|value| value.post_template_slug.clone()) + .unwrap_or_default(), + list_template_slug: meta + .and_then(|value| value.list_template_slug.clone()) + .unwrap_or_default(), + is_protected: ["article", "aside", "page", "picture"].contains(&name.as_str()), + name, + } + }) + .collect(); if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) { state.ssh_host = pub_prefs.ssh_host.unwrap_or_default(); state.ssh_username = pub_prefs.ssh_user.unwrap_or_default(); @@ -3724,6 +4035,13 @@ impl BdsApp { } } if let Some(db) = &self.db { + if let Some(project) = &self.active_project { + state.template_options = bds_core::db::queries::template::list_templates_by_project(db.conn(), &project.id) + .unwrap_or_default() + .into_iter() + .map(|template| template.slug) + .collect(); + } if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.default_mode") { state.default_mode = setting.value; } @@ -3948,8 +4266,28 @@ impl BdsApp { } } SettingsMsg::PublicUrlChanged(s) => { state.public_url = s; } + SettingsMsg::MainLanguageChanged(s) => { + state.main_language = s.clone(); + if !state.blog_languages.iter().any(|language| language == &s) { + state.blog_languages.push(s); + } + } + SettingsMsg::ToggleBlogLanguage(language) => { + if language == state.main_language { + if !state.blog_languages.iter().any(|item| item == &language) { + state.blog_languages.push(language); + } + } else if let Some(index) = state.blog_languages.iter().position(|item| item == &language) { + state.blog_languages.remove(index); + } else { + state.blog_languages.push(language); + } + state.blog_languages.sort(); + state.blog_languages.dedup(); + } SettingsMsg::DefaultAuthorChanged(s) => { state.default_author = s; } SettingsMsg::MaxPostsPerPageChanged(s) => { state.max_posts_per_page = s; } + SettingsMsg::BlogmarkCategoryChanged(s) => { state.blogmark_category = s; } SettingsMsg::SaveProject => { if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, self.active_project.as_mut()) { let max_posts = match state.max_posts_per_page.trim().parse::() { @@ -3977,8 +4315,12 @@ impl BdsApp { if value.trim().is_empty() { None } else { Some(value) } }; meta.public_url = if state.public_url.trim().is_empty() { None } else { Some(state.public_url.clone()) }; + meta.main_language = if state.main_language.trim().is_empty() { None } else { Some(state.main_language.clone()) }; meta.default_author = if state.default_author.trim().is_empty() { None } else { Some(state.default_author.clone()) }; meta.max_posts_per_page = max_posts; + meta.blogmark_category = if state.blogmark_category.trim().is_empty() { None } else { Some(state.blogmark_category.clone()) }; + meta.blog_languages = state.blog_languages.clone(); + meta.semantic_similarity_enabled = state.semantic_similarity_enabled; if let Err(e) = meta.validate() { self.notify(ToastLevel::Error, &format!("Save failed: {e}")); return Task::none(); @@ -3994,6 +4336,9 @@ impl BdsApp { if let Some(listing) = self.projects.iter_mut().find(|p| p.id == project.id) { *listing = project.clone(); } + self.content_language = state.main_language.clone(); + self.blog_languages = state.blog_languages.clone(); + self.dashboard_state = Some(self.hydrate_dashboard_state()); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } (Err(e), _) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), @@ -4013,6 +4358,124 @@ impl BdsApp { } } } + SettingsMsg::AddCategoryNameChanged(value) => { state.new_category_name = value; } + SettingsMsg::AddCategory => { + let category_name = state.new_category_name.trim(); + if category_name.is_empty() { + self.notify(ToastLevel::Error, "Category name required"); + return Task::none(); + } + if state.categories.iter().any(|row| row.name.eq_ignore_ascii_case(category_name)) { + self.notify(ToastLevel::Error, "Category already exists"); + return Task::none(); + } + if let Some(data_dir) = &self.data_dir { + match engine::meta::add_category(data_dir, category_name) { + Ok(()) => { + self.settings_state = Some(self.hydrate_settings_state()); + if let Some(state) = self.settings_state.as_mut() { + state.new_category_name.clear(); + } + self.dashboard_state = Some(self.hydrate_dashboard_state()); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } + } + } + SettingsMsg::CategoryTitleChanged(name, value) => { + if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) { + row.title = value; + } + } + SettingsMsg::CategoryRenderInListsChanged(name, value) => { + if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) { + row.render_in_lists = value; + } + } + SettingsMsg::CategoryShowTitleChanged(name, value) => { + if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) { + row.show_title = value; + } + } + SettingsMsg::CategoryPostTemplateChanged(name, value) => { + if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) { + row.post_template_slug = value; + } + } + SettingsMsg::CategoryListTemplateChanged(name, value) => { + if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) { + row.list_template_slug = value; + } + } + SettingsMsg::SaveCategory(name) => { + if let Some(data_dir) = &self.data_dir { + if let Some(row) = state.categories.iter().find(|row| row.name == name) { + let mut category_meta = engine::meta::read_category_meta_json(data_dir).unwrap_or_default(); + category_meta.insert( + row.name.clone(), + bds_core::model::metadata::CategorySettings { + render_in_lists: row.render_in_lists, + show_title: row.show_title, + post_template_slug: (!row.post_template_slug.is_empty()).then(|| row.post_template_slug.clone()), + list_template_slug: (!row.list_template_slug.is_empty()).then(|| row.list_template_slug.clone()), + }, + ); + match engine::meta::write_category_meta_json(data_dir, &category_meta) { + Ok(()) => { + self.dashboard_state = Some(self.hydrate_dashboard_state()); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } + } + } + } + SettingsMsg::RemoveCategory(name) => { + if let Some(data_dir) = &self.data_dir { + match engine::meta::remove_category(data_dir, &name) { + Ok(()) => { + self.settings_state = Some(self.hydrate_settings_state()); + self.dashboard_state = Some(self.hydrate_dashboard_state()); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } + } + } + SettingsMsg::ResetCategoriesToDefaults => { + if let Some(data_dir) = &self.data_dir { + let default_names = default_category_rows() + .into_iter() + .map(|row| row.name) + .collect::>(); + let default_meta = default_category_rows() + .into_iter() + .map(|row| { + ( + row.name, + bds_core::model::metadata::CategorySettings { + render_in_lists: row.render_in_lists, + show_title: row.show_title, + post_template_slug: None, + list_template_slug: None, + }, + ) + }) + .collect::>(); + match ( + engine::meta::write_categories_json(data_dir, &default_names), + engine::meta::write_category_meta_json(data_dir, &default_meta), + ) { + (Ok(()), Ok(())) => { + self.settings_state = Some(self.hydrate_settings_state()); + self.dashboard_state = Some(self.hydrate_dashboard_state()); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + (Err(e), _) | (_, Err(e)) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } + } + } SettingsMsg::SshModeChanged(s) => { state.ssh_mode = s; } SettingsMsg::SshHostChanged(s) => { state.ssh_host = s; } SettingsMsg::SshUsernameChanged(s) => { state.ssh_username = s; } @@ -4059,6 +4522,9 @@ impl BdsApp { SettingsMsg::ResetSystemPrompt => { state.system_prompt = iced::widget::text_editor::Content::new(); } + SettingsMsg::SemanticSimilarityChanged(value) => { + state.semantic_similarity_enabled = value; + } SettingsMsg::RebuildPosts => { return Task::done(Message::RebuildDatabase); } SettingsMsg::RebuildMedia => { return Task::done(Message::RebuildDatabase); } SettingsMsg::RebuildScripts => { return Task::done(Message::RebuildDatabase); } diff --git a/crates/bds-ui/src/views/dashboard.rs b/crates/bds-ui/src/views/dashboard.rs index c0ee014..069c4d4 100644 --- a/crates/bds-ui/src/views/dashboard.rs +++ b/crates/bds-ui/src/views/dashboard.rs @@ -1,4 +1,4 @@ -use iced::widget::{column, container, row, text, Space}; +use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Alignment, Background, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -6,29 +6,83 @@ use bds_core::i18n::UiLocale; use crate::app::Message; use crate::components::inputs; use crate::i18n::t; +use crate::state::tabs::{Tab, TabType}; + +#[derive(Debug, Clone)] +pub struct DashboardStats { + pub total_posts: usize, + pub published_count: usize, + pub draft_count: usize, + pub archived_count: usize, + pub media_count: usize, + pub image_count: usize, + pub total_media_size: String, + pub tag_count: usize, + pub category_count: usize, +} + +#[derive(Debug, Clone)] +pub struct DashboardTimelineMonth { + pub label: String, + pub year: i32, + pub count: usize, +} + +#[derive(Debug, Clone)] +pub struct DashboardTag { + pub name: String, + pub count: usize, + pub color: Option, +} + +#[derive(Debug, Clone)] +pub struct DashboardCategory { + pub name: String, + pub count: usize, +} + +#[derive(Debug, Clone)] +pub struct DashboardRecentPost { + pub post_id: String, + pub title: String, + pub status: String, + pub date: String, +} /// Dashboard overview state. #[derive(Debug, Clone)] pub struct DashboardState { - pub post_count: usize, - pub media_count: usize, - pub template_count: usize, - pub script_count: usize, - pub draft_count: usize, - pub published_count: usize, - pub project_name: String, + pub title: String, + pub subtitle: String, + pub stats: DashboardStats, + pub timeline: Vec, + pub tag_cloud: Vec, + pub tag_overflow_count: usize, + pub category_cloud: Vec, + pub recent_posts: Vec, } impl DashboardState { pub fn new(project_name: String) -> Self { Self { - post_count: 0, - media_count: 0, - template_count: 0, - script_count: 0, - draft_count: 0, - published_count: 0, - project_name, + title: t(UiLocale::En, "dashboard.overview"), + subtitle: project_name, + stats: DashboardStats { + total_posts: 0, + published_count: 0, + draft_count: 0, + archived_count: 0, + media_count: 0, + image_count: 0, + total_media_size: "0 B".to_string(), + tag_count: 0, + category_count: 0, + }, + timeline: Vec::new(), + tag_cloud: Vec::new(), + tag_overflow_count: 0, + category_cloud: Vec::new(), + recent_posts: Vec::new(), } } } @@ -38,55 +92,89 @@ pub fn view<'a>( state: &'a DashboardState, locale: UiLocale, ) -> Element<'a, Message> { - let header = text(t(locale, "dashboard.overview")) - .size(20) + let header = text(state.title.clone()) + .size(24) .color(Color::WHITE); - let project_label = text(state.project_name.clone()) + let project_label = text(state.subtitle.clone()) .size(14) .color(Color::from_rgb(0.6, 0.6, 0.7)); let counts_row = row![ - stat_card(&t(locale, "dashboard.posts"), state.post_count), - stat_card(&t(locale, "dashboard.media"), state.media_count), - stat_card(&t(locale, "dashboard.templates"), state.template_count), - stat_card(&t(locale, "dashboard.scripts"), state.script_count), + stat_card( + &t(locale, "dashboard.posts"), + state.stats.total_posts.to_string(), + { + let mut details = vec![ + format!("{} {}", state.stats.published_count, t(locale, "dashboard.published")), + format!("{} {}", state.stats.draft_count, t(locale, "dashboard.drafts")), + ]; + if state.stats.archived_count > 0 { + details.push(format!("{} {}", state.stats.archived_count, t(locale, "dashboard.archived"))); + } + details + }, + ), + stat_card( + &t(locale, "dashboard.media"), + state.stats.media_count.to_string(), + vec![ + format!("{} {}", state.stats.image_count, t(locale, "dashboard.images")), + state.stats.total_media_size.clone(), + ], + ), + stat_card( + &t(locale, "dashboard.tags"), + state.stats.tag_count.to_string(), + vec![format!("{} {}", state.stats.category_count, t(locale, "dashboard.categories"))], + ), ] .spacing(16); - let status_row = row![ - stat_card(&t(locale, "dashboard.drafts"), state.draft_count), - stat_card(&t(locale, "dashboard.published"), state.published_count), - ] - .spacing(16); + let timeline = timeline_chart(&state.timeline, locale); + let tags = tag_cloud(&state.tag_cloud, state.tag_overflow_count, locale); + let categories = category_cloud(&state.category_cloud, locale); + let recent = recent_posts(&state.recent_posts, locale); - container( + scrollable(container( column![ header, project_label, Space::with_height(16), - inputs::section_header(&t(locale, "dashboard.entityCounts")), + inputs::section_header(&t(locale, "dashboard.statCards")), counts_row, - Space::with_height(12), - inputs::section_header(&t(locale, "dashboard.statusCounts")), - status_row, + Space::with_height(20), + inputs::section_header(&t(locale, "dashboard.timeline")), + timeline, + Space::with_height(20), + row![tags, categories].spacing(16), + Space::with_height(20), + inputs::section_header(&t(locale, "dashboard.recentPosts")), + recent, ] .spacing(8) .padding(24) .width(Length::Fill), - ) + )) .width(Length::Fill) .height(Length::Fill) .into() } -fn stat_card<'a>(label: &str, count: usize) -> Element<'a, Message> { +fn stat_card<'a>(label: &str, value: String, details: Vec) -> Element<'a, Message> { let card_bg = Color::from_rgb(0.15, 0.16, 0.20); container( - column![ - text(count.to_string()).size(28).color(Color::WHITE), - text(label.to_string()).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)), - ] + column( + std::iter::once(text(value).size(28).color(Color::WHITE).into()) + .chain(std::iter::once(text(label.to_string()).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)).into())) + .chain(details.into_iter().map(|detail| { + text(detail) + .size(12) + .color(Color::from_rgb(0.72, 0.74, 0.80)) + .into() + })) + .collect::>>(), + ) .spacing(4) .align_x(Alignment::Center), ) @@ -102,3 +190,187 @@ fn stat_card<'a>(label: &str, count: usize) -> Element<'a, Message> { }) .into() } + +fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], locale: UiLocale) -> Element<'a, Message> { + if months.is_empty() { + return text(t(locale, "dashboard.noTimelineData")) + .size(13) + .color(Color::from_rgb(0.6, 0.62, 0.68)) + .into(); + } + + let max_count = months.iter().map(|month| month.count).max().unwrap_or(1).max(1); + row( + months + .iter() + .map(|month| { + let height = 24.0 + (month.count as f32 / max_count as f32) * 96.0; + container( + column![ + text(month.count.to_string()).size(12).color(Color::WHITE), + container(Space::with_height(height)) + .width(Length::Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))), + border: iced::Border { radius: 6.0.into(), ..iced::Border::default() }, + ..container::Style::default() + }), + text(format!("{} {}", month.label, month.year)).size(11).color(Color::from_rgb(0.7, 0.72, 0.78)), + ] + .spacing(6) + .align_x(Alignment::Center), + ) + .width(Length::FillPortion(1)) + .into() + }) + .collect::>(), + ) + .spacing(10) + .align_y(Alignment::End) + .into() +} + +fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLocale) -> Element<'a, Message> { + let items = tags.iter().fold(column![inputs::section_header(&t(locale, "dashboard.tagCloud"))].spacing(8), |column, tag| { + let bg = parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28)); + column.push( + container( + row![ + text(tag.name.clone()).size(scale_font(tag.count)).color(contrast_color(bg)), + text(tag.count.to_string()).size(12).color(contrast_color(bg)), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .padding([6, 10]) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(bg)), + border: iced::Border { radius: 999.0.into(), ..iced::Border::default() }, + ..container::Style::default() + }), + ) + }); + + let with_overflow = if overflow_count > 0 { + items.push(text(format!("{} {}", t(locale, "dashboard.and"), overflow_count)).size(12).color(Color::from_rgb(0.6, 0.62, 0.68))) + } else { + items + }; + + container(with_overflow).width(Length::FillPortion(1)).into() +} + +fn category_cloud<'a>(categories: &'a [DashboardCategory], locale: UiLocale) -> Element<'a, Message> { + let items = categories.iter().fold(column![inputs::section_header(&t(locale, "dashboard.categoryCloud"))].spacing(8), |column, category| { + column.push( + container( + row![ + text(category.name.clone()).size(13).color(Color::WHITE), + text(category.count.to_string()).size(12).color(Color::from_rgb(0.72, 0.74, 0.80)), + ] + .spacing(8), + ) + .padding([6, 10]) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))), + border: iced::Border { radius: 999.0.into(), ..iced::Border::default() }, + ..container::Style::default() + }), + ) + }); + container(items).width(Length::FillPortion(1)).into() +} + +fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Element<'a, Message> { + if posts.is_empty() { + return text(t(locale, "dashboard.noRecentPosts")) + .size(13) + .color(Color::from_rgb(0.6, 0.62, 0.68)) + .into(); + } + + column( + posts + .iter() + .map(|post| { + container( + row![ + button( + column![ + text(post.title.clone()).size(14).color(Color::WHITE), + text(post.date.clone()).size(12).color(Color::from_rgb(0.6, 0.62, 0.68)), + ] + .spacing(4), + ) + .on_press(Message::OpenTab(Tab { + id: post.post_id.clone(), + tab_type: TabType::Post, + title: post.title.clone(), + is_transient: true, + is_dirty: false, + })) + .width(Length::Fill), + status_badge(&post.status), + button(text(t(locale, "dashboard.pin")).size(12)) + .on_press(Message::OpenTab(Tab { + id: post.post_id.clone(), + tab_type: TabType::Post, + title: post.title.clone(), + is_transient: false, + is_dirty: false, + })) + .padding([6, 10]), + ] + .spacing(12) + .align_y(Alignment::Center), + ) + .padding(12) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb(0.13, 0.14, 0.18))), + border: iced::Border { radius: 8.0.into(), ..iced::Border::default() }, + ..container::Style::default() + }) + .into() + }) + .collect::>(), + ) + .spacing(8) + .into() +} + +fn status_badge<'a>(status: &str) -> Element<'a, Message> { + let bg = if status.eq_ignore_ascii_case("published") { + Color::from_rgb(0.16, 0.42, 0.24) + } else { + Color::from_rgb(0.45, 0.33, 0.14) + }; + container(text(status.to_string()).size(11).color(Color::WHITE)) + .padding([4, 8]) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(bg)), + border: iced::Border { radius: 999.0.into(), ..iced::Border::default() }, + ..container::Style::default() + }) + .into() +} + +fn scale_font(count: usize) -> u16 { + let clamped = count.min(20) as f32; + (11.0 + (clamped / 20.0) * 11.0).round() as u16 +} + +fn parse_color(value: Option<&str>) -> Option { + let value = value?.trim_start_matches('#'); + if value.len() != 6 { + return None; + } + let red = u8::from_str_radix(&value[0..2], 16).ok()?; + let green = u8::from_str_radix(&value[2..4], 16).ok()?; + let blue = u8::from_str_radix(&value[4..6], 16).ok()?; + Some(Color::from_rgb8(red, green, blue)) +} + +fn contrast_color(background: Color) -> Color { + let luma = 0.299 * background.r + 0.587 * background.g + 0.114 * background.b; + if luma > 0.55 { Color::BLACK } else { Color::WHITE } +} diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index 42e089d..276edd9 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -6,7 +6,7 @@ use bds_core::i18n::UiLocale; use crate::app::Message; use crate::components::inputs; -use crate::i18n::t; +use crate::i18n::{t, tw}; /// Collapsible section identifiers per editor_settings.allium. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -21,6 +21,40 @@ pub enum SettingsSection { MCP, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingsCategoryRow { + pub name: String, + pub title: String, + pub render_in_lists: bool, + pub show_title: bool, + pub post_template_slug: String, + pub list_template_slug: String, + pub is_protected: bool, +} + +impl std::fmt::Display for SettingsCategoryRow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.name) + } +} + +const PROTECTED_CATEGORIES: [&str; 4] = ["article", "aside", "page", "picture"]; + +pub fn default_category_rows() -> Vec { + PROTECTED_CATEGORIES + .iter() + .map(|name| SettingsCategoryRow { + name: (*name).to_string(), + title: (*name).to_string(), + render_in_lists: true, + show_title: true, + post_template_slug: String::new(), + list_template_slug: String::new(), + is_protected: true, + }) + .collect() +} + impl SettingsSection { pub fn all() -> &'static [SettingsSection] { &[ @@ -59,13 +93,21 @@ pub struct SettingsViewState { pub project_description: text_editor::Content, pub data_path: String, pub public_url: String, + pub main_language: String, + pub blog_languages: Vec, + pub available_languages: Vec, pub default_author: String, pub max_posts_per_page: String, + pub blogmark_category: String, // Editor pub default_mode: String, pub diff_view_style: String, pub wrap_long_lines: bool, pub hide_unchanged_regions: bool, + // Content + pub categories: Vec, + pub new_category_name: String, + pub template_options: Vec, // Publishing pub ssh_mode: String, pub ssh_host: String, @@ -74,6 +116,8 @@ pub struct SettingsViewState { // AI pub offline_mode: bool, pub system_prompt: text_editor::Content, + // Technology + pub semantic_similarity_enabled: bool, } impl std::fmt::Debug for SettingsViewState { @@ -94,18 +138,26 @@ impl Clone for SettingsViewState { project_description: text_editor::Content::with_text(&self.project_description.text()), data_path: self.data_path.clone(), public_url: self.public_url.clone(), + main_language: self.main_language.clone(), + blog_languages: self.blog_languages.clone(), + available_languages: self.available_languages.clone(), default_author: self.default_author.clone(), max_posts_per_page: self.max_posts_per_page.clone(), + blogmark_category: self.blogmark_category.clone(), default_mode: self.default_mode.clone(), diff_view_style: self.diff_view_style.clone(), wrap_long_lines: self.wrap_long_lines, hide_unchanged_regions: self.hide_unchanged_regions, + categories: self.categories.clone(), + new_category_name: self.new_category_name.clone(), + template_options: self.template_options.clone(), ssh_mode: self.ssh_mode.clone(), ssh_host: self.ssh_host.clone(), ssh_username: self.ssh_username.clone(), ssh_remote_path: self.ssh_remote_path.clone(), offline_mode: self.offline_mode, system_prompt: text_editor::Content::with_text(&self.system_prompt.text()), + semantic_similarity_enabled: self.semantic_similarity_enabled, } } } @@ -120,18 +172,26 @@ impl Default for SettingsViewState { project_description: text_editor::Content::new(), data_path: String::new(), public_url: String::new(), + main_language: "en".to_string(), + blog_languages: vec!["en".to_string()], + available_languages: vec!["en".to_string(), "de".to_string(), "fr".to_string(), "it".to_string(), "es".to_string()], default_author: String::new(), max_posts_per_page: "50".to_string(), + blogmark_category: String::new(), default_mode: "markdown".to_string(), diff_view_style: "inline".to_string(), wrap_long_lines: true, hide_unchanged_regions: false, + categories: default_category_rows(), + new_category_name: String::new(), + template_options: Vec::new(), ssh_mode: "rsync".to_string(), ssh_host: String::new(), ssh_username: String::new(), ssh_remote_path: String::new(), offline_mode: false, system_prompt: text_editor::Content::new(), + semantic_similarity_enabled: false, } } } @@ -171,8 +231,11 @@ pub enum SettingsMsg { BrowseDataPath, ResetDataPath, PublicUrlChanged(String), + MainLanguageChanged(String), + ToggleBlogLanguage(String), DefaultAuthorChanged(String), MaxPostsPerPageChanged(String), + BlogmarkCategoryChanged(String), SaveProject, // Editor DefaultModeChanged(String), @@ -180,6 +243,17 @@ pub enum SettingsMsg { WrapLongLinesChanged(bool), HideUnchangedRegionsChanged(bool), SaveEditor, + // Content + AddCategoryNameChanged(String), + AddCategory, + CategoryTitleChanged(String, String), + CategoryRenderInListsChanged(String, bool), + CategoryShowTitleChanged(String, bool), + CategoryPostTemplateChanged(String, String), + CategoryListTemplateChanged(String, String), + SaveCategory(String), + RemoveCategory(String), + ResetCategoriesToDefaults, // Publishing SshModeChanged(String), SshHostChanged(String), @@ -192,6 +266,8 @@ pub enum SettingsMsg { SystemPromptAction(text_editor::Action), SaveSystemPrompt, ResetSystemPrompt, + // Technology + SemanticSimilarityChanged(bool), // Data maintenance RebuildPosts, RebuildMedia, @@ -215,7 +291,7 @@ pub fn view<'a>( let query_lower = state.search_query.to_lowercase(); - let mut sections = column![].spacing(12).width(Length::Fill); + let mut section_items = Vec::new(); for section in state.ordered_sections() { let label = t(locale, section.i18n_key()); if !query_lower.is_empty() && !label.to_lowercase().contains(&query_lower) { @@ -223,9 +299,24 @@ pub fn view<'a>( } let collapsed = state.collapsed.contains(§ion); let section_el = render_section(state, §ion, &label, collapsed, locale); - sections = sections.push(section_el); + section_items.push(section_el); } + let sections = if section_items.is_empty() { + column![ + text(t(locale, "common.noResults")) + .size(14) + .color(Color::from_rgb(0.7, 0.72, 0.78)), + button(text(t(locale, "common.clear")).size(13)) + .on_press(Message::Settings(SettingsMsg::SearchChanged(String::new()))) + .padding([6, 12]), + ] + .spacing(8) + .width(Length::Fill) + } else { + column(section_items).spacing(12).width(Length::Fill) + }; + let body = scrollable( column![search, sections] .spacing(16) @@ -301,9 +392,9 @@ 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(locale), + SettingsSection::Content => section_content(state, locale), SettingsSection::AI => section_ai(state, locale), - SettingsSection::Technology => section_technology(locale), + SettingsSection::Technology => section_technology(state, locale), SettingsSection::Publishing => section_publishing(state, locale), SettingsSection::Data => section_data(locale), SettingsSection::MCP => section_mcp(locale), @@ -352,6 +443,31 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen &state.public_url, |s| Message::Settings(SettingsMsg::PublicUrlChanged(s)), ); + let language_options = state.available_languages.clone(); + let main_language = inputs::labeled_select( + &t(locale, "settings.mainLanguage"), + &language_options, + Some(&state.main_language), + |s| Message::Settings(SettingsMsg::MainLanguageChanged(s)), + ); + let blog_languages = column( + state + .available_languages + .iter() + .map(|language| { + let label = if *language == state.main_language { + format!("{} ({})", language, t(locale, "settings.mainLanguageRequired")) + } else { + language.clone() + }; + inputs::labeled_checkbox(&label, state.blog_languages.iter().any(|item| item == language), { + let language = language.clone(); + move |_| Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone())) + }) + }) + .collect::>(), + ) + .spacing(6); let author = inputs::labeled_input( &t(locale, "settings.defaultAuthor"), "", @@ -364,18 +480,60 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen &state.max_posts_per_page, |s| Message::Settings(SettingsMsg::MaxPostsPerPageChanged(s)), ); + let blogmark_category = inputs::labeled_select( + &t(locale, "settings.blogmarkCategory"), + &state.categories, + state.categories.iter().find(|row| row.name == state.blogmark_category), + |row| Message::Settings(SettingsMsg::BlogmarkCategoryChanged(row.name)), + ); let save = button(text(t(locale, "common.save")).size(13)) .on_press(Message::Settings(SettingsMsg::SaveProject)) .style(inputs::primary_button) .padding([6, 16]); - column![name, desc, data_path, url, author, max_posts, save] + column![ + name, + desc, + data_path, + url, + main_language, + column![ + text(t(locale, "settings.blogLanguages")) + .size(12) + .color(inputs::LABEL_COLOR) + .shaping(Shaping::Advanced), + blog_languages, + ] + .spacing(4), + author, + max_posts, + blogmark_category, + save, + ] .spacing(8) .padding([0, 16]) .into() } fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { + let mode_options = vec![ + "wysiwyg".to_string(), + "markdown".to_string(), + "preview".to_string(), + ]; + let diff_options = vec!["inline".to_string(), "side-by-side".to_string()]; + let mode = inputs::labeled_select( + &t(locale, "settings.defaultMode"), + &mode_options, + Some(&state.default_mode), + |s| Message::Settings(SettingsMsg::DefaultModeChanged(s)), + ); + let diff = inputs::labeled_select( + &t(locale, "settings.diffViewStyle"), + &diff_options, + Some(&state.diff_view_style), + |s| Message::Settings(SettingsMsg::DiffViewStyleChanged(s)), + ); let wrap = inputs::labeled_checkbox( &t(locale, "settings.wrapLongLines"), state.wrap_long_lines, @@ -391,17 +549,112 @@ fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element .style(inputs::primary_button) .padding([6, 16]); - column![wrap, hide, save] + column![mode, diff, wrap, hide, save] .spacing(8) .padding([0, 16]) .into() } -fn section_content<'a>(locale: UiLocale) -> Element<'a, Message> { - // Categories table placeholder — full implementation in future iteration - text(t(locale, "settings.contentPlaceholder")) - .size(13) - .color(Color::from_rgb(0.5, 0.5, 0.5)) +fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { + let template_options = std::iter::once(String::new()) + .chain(state.template_options.iter().cloned()) + .collect::>(); + + let category_rows = state.categories.iter().fold(column![].spacing(8), |column, category| { + let title = inputs::labeled_input( + &tw(locale, "settings.categoryTitle", &[("category", &category.name)]), + "", + &category.title, + { + let name = category.name.clone(); + move |value| Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value)) + }, + ); + let post_template = inputs::labeled_select( + &t(locale, "settings.categoryPostTemplate"), + &template_options, + Some(&category.post_template_slug), + { + let name = category.name.clone(); + move |value| Message::Settings(SettingsMsg::CategoryPostTemplateChanged(name.clone(), value)) + }, + ); + let list_template = inputs::labeled_select( + &t(locale, "settings.categoryListTemplate"), + &template_options, + Some(&category.list_template_slug), + { + let name = category.name.clone(); + move |value| Message::Settings(SettingsMsg::CategoryListTemplateChanged(name.clone(), value)) + }, + ); + let toggles = column![ + inputs::labeled_checkbox( + &t(locale, "settings.categoryRenderInLists"), + category.render_in_lists, + { + let name = category.name.clone(); + move |value| Message::Settings(SettingsMsg::CategoryRenderInListsChanged(name.clone(), value)) + }, + ), + inputs::labeled_checkbox( + &t(locale, "settings.categoryShowTitles"), + category.show_title, + { + let name = category.name.clone(); + move |value| Message::Settings(SettingsMsg::CategoryShowTitleChanged(name.clone(), value)) + }, + ), + ] + .spacing(6); + let actions = row![ + button(text(t(locale, "common.save")).size(13)) + .on_press(Message::Settings(SettingsMsg::SaveCategory(category.name.clone()))) + .style(inputs::primary_button) + .padding([6, 12]), + button(text(t(locale, "common.remove")).size(13)) + .on_press_maybe((!category.is_protected).then(|| Message::Settings(SettingsMsg::RemoveCategory(category.name.clone())))) + .style(inputs::danger_button) + .padding([6, 12]), + ] + .spacing(8); + + column.push( + container( + 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)) + .padding([6, 12]), + ] + .spacing(8) + .align_y(Alignment::End); + + column![category_rows, add_row] + .spacing(12) + .padding([0, 16]) .into() } @@ -436,11 +689,20 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, .into() } -fn section_technology<'a>(locale: UiLocale) -> Element<'a, Message> { - text(t(locale, "settings.technologyPlaceholder")) - .size(13) - .color(Color::from_rgb(0.5, 0.5, 0.5)) - .into() +fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { + column![ + text(t(locale, "settings.luaRuntimeOnly")) + .size(13) + .color(Color::from_rgb(0.7, 0.72, 0.78)), + inputs::labeled_checkbox( + &t(locale, "settings.semanticSimilarityEnabled"), + state.semantic_similarity_enabled, + |value| Message::Settings(SettingsMsg::SemanticSimilarityChanged(value)), + ), + ] + .spacing(8) + .padding([0, 16]) + .into() } fn section_publishing<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index b1553c3..9c03edf 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -551,7 +551,7 @@ mod tests { Some(&dashboard), ); match route { - ContentRoute::Dashboard(state) => assert_eq!(state.project_name, "Test Project"), + ContentRoute::Dashboard(state) => assert_eq!(state.subtitle, "Test Project"), _ => panic!("expected dashboard route"), } } diff --git a/locales/ui/de.json b/locales/ui/de.json index d07fd79..213a672 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -278,19 +278,38 @@ "tags.selectTag": "Tag zum Bearbeiten auswählen", "template.forceDeleteTitle": "Vorlage zwangsweise löschen", "template.forceDeleteMessage": "Diese Vorlage wird von {posts} Beiträgen und {tags} Tags verwendet. Trotzdem löschen?", + "common.add": "Hinzufügen", + "common.remove": "Entfernen", + "common.clear": "Leeren", + "common.noResults": "Keine Ergebnisse", "settings.projectName": "Projektname", "settings.projectDescription": "Beschreibung", "settings.dataPath": "Datenordner", "settings.browse": "Durchsuchen...", "settings.reset": "Zurücksetzen", "settings.publicUrl": "Öffentliche URL", + "settings.mainLanguage": "Hauptsprache", + "settings.mainLanguageRequired": "erforderlich", + "settings.blogLanguages": "Blogsprachen", "settings.defaultAuthor": "Standardautor", "settings.maxPostsPerPage": "Beiträge pro Seite", + "settings.blogmarkCategory": "Blogmark-Kategorie", + "settings.defaultMode": "Standard-Editormodus", + "settings.diffViewStyle": "Diff-Ansicht", + "settings.categoryTitle": "Titel für {category}", + "settings.categoryPostTemplate": "Beitragsvorlage", + "settings.categoryListTemplate": "Listenvorlage", + "settings.categoryRenderInLists": "In Listen rendern", + "settings.categoryShowTitles": "Titel anzeigen", + "settings.addCategory": "Kategorie hinzufügen", + "settings.resetCategories": "Auf Standard zurücksetzen", "settings.wrapLongLines": "Lange Zeilen umbrechen", "settings.hideUnchangedRegions": "Unveränderte Bereiche ausblenden", "settings.offlineMode": "Flugmodus", "settings.systemPrompt": "System-Prompt", "settings.resetToDefault": "Auf Standard zurücksetzen", + "settings.luaRuntimeOnly": "Lua ist die einzige Skriptlaufzeit in der Rust-App.", + "settings.semanticSimilarityEnabled": "Semantische Ähnlichkeit", "settings.sshHost": "SSH-Host", "settings.sshUsername": "SSH-Benutzername", "settings.sshRemotePath": "Remote-Pfad", @@ -308,10 +327,19 @@ "dashboard.overview": "Übersicht", "dashboard.posts": "Beiträge", "dashboard.media": "Medien", - "dashboard.templates": "Vorlagen", - "dashboard.scripts": "Skripte", + "dashboard.tags": "Tags", + "dashboard.categories": "Kategorien", + "dashboard.images": "Bilder", "dashboard.drafts": "Entwürfe", "dashboard.published": "Veröffentlicht", - "dashboard.entityCounts": "Anzahl Entitäten", - "dashboard.statusCounts": "Statusübersicht" + "dashboard.archived": "Archiviert", + "dashboard.statCards": "Überblick", + "dashboard.timeline": "Zeitachse", + "dashboard.noTimelineData": "Noch keine Zeitachsendaten", + "dashboard.tagCloud": "Tag-Wolke", + "dashboard.categoryCloud": "Kategorie-Wolke", + "dashboard.recentPosts": "Letzte Beiträge", + "dashboard.noRecentPosts": "Keine aktuellen Beiträge", + "dashboard.and": "und", + "dashboard.pin": "Anheften" } diff --git a/locales/ui/en.json b/locales/ui/en.json index 0dc2067..170476e 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -278,19 +278,38 @@ "tags.selectTag": "Select a tag to edit", "template.forceDeleteTitle": "Force Delete Template", "template.forceDeleteMessage": "This template is used by {posts} posts and {tags} tags. Force delete?", + "common.add": "Add", + "common.remove": "Remove", + "common.clear": "Clear", + "common.noResults": "No results", "settings.projectName": "Project Name", "settings.projectDescription": "Description", "settings.dataPath": "Data Folder", "settings.browse": "Browse...", "settings.reset": "Reset", "settings.publicUrl": "Public URL", + "settings.mainLanguage": "Main Language", + "settings.mainLanguageRequired": "required", + "settings.blogLanguages": "Blog Languages", "settings.defaultAuthor": "Default Author", "settings.maxPostsPerPage": "Posts per Page", + "settings.blogmarkCategory": "Blogmark Category", + "settings.defaultMode": "Default Editor Mode", + "settings.diffViewStyle": "Diff View Style", + "settings.categoryTitle": "Title for {category}", + "settings.categoryPostTemplate": "Post Template", + "settings.categoryListTemplate": "List Template", + "settings.categoryRenderInLists": "Render in Lists", + "settings.categoryShowTitles": "Show Titles", + "settings.addCategory": "Add Category", + "settings.resetCategories": "Reset to Defaults", "settings.wrapLongLines": "Wrap Long Lines", "settings.hideUnchangedRegions": "Hide Unchanged Regions", "settings.offlineMode": "Airplane Mode", "settings.systemPrompt": "System Prompt", "settings.resetToDefault": "Reset to Default", + "settings.luaRuntimeOnly": "Lua is the only scripting runtime in the Rust app.", + "settings.semanticSimilarityEnabled": "Semantic Similarity", "settings.sshHost": "SSH Host", "settings.sshUsername": "SSH Username", "settings.sshRemotePath": "Remote Path", @@ -308,10 +327,19 @@ "dashboard.overview": "Overview", "dashboard.posts": "Posts", "dashboard.media": "Media", - "dashboard.templates": "Templates", - "dashboard.scripts": "Scripts", + "dashboard.tags": "Tags", + "dashboard.categories": "Categories", + "dashboard.images": "Images", "dashboard.drafts": "Drafts", "dashboard.published": "Published", - "dashboard.entityCounts": "Entity Counts", - "dashboard.statusCounts": "Status Breakdown" + "dashboard.archived": "Archived", + "dashboard.statCards": "Snapshot", + "dashboard.timeline": "Timeline", + "dashboard.noTimelineData": "No timeline data yet", + "dashboard.tagCloud": "Tag Cloud", + "dashboard.categoryCloud": "Category Cloud", + "dashboard.recentPosts": "Recent Posts", + "dashboard.noRecentPosts": "No recent posts", + "dashboard.and": "and", + "dashboard.pin": "Pin" } diff --git a/locales/ui/es.json b/locales/ui/es.json index 2a8700f..a9a756a 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -278,19 +278,38 @@ "tags.selectTag": "Seleccione una etiqueta para editar", "template.forceDeleteTitle": "Forzar eliminación de plantilla", "template.forceDeleteMessage": "Esta plantilla se usa en {posts} publicaciones y {tags} etiquetas. ¿Forzar eliminación?", + "common.add": "Agregar", + "common.remove": "Eliminar", + "common.clear": "Limpiar", + "common.noResults": "Sin resultados", "settings.projectName": "Nombre del proyecto", "settings.projectDescription": "Descripción", "settings.dataPath": "Carpeta de datos", "settings.browse": "Explorar...", "settings.reset": "Restablecer", "settings.publicUrl": "URL pública", + "settings.mainLanguage": "Idioma principal", + "settings.mainLanguageRequired": "obligatorio", + "settings.blogLanguages": "Idiomas del blog", "settings.defaultAuthor": "Autor predeterminado", "settings.maxPostsPerPage": "Artículos por página", + "settings.blogmarkCategory": "Categoría de Blogmark", + "settings.defaultMode": "Modo de editor predeterminado", + "settings.diffViewStyle": "Estilo de diff", + "settings.categoryTitle": "Título para {category}", + "settings.categoryPostTemplate": "Plantilla de artículo", + "settings.categoryListTemplate": "Plantilla de lista", + "settings.categoryRenderInLists": "Mostrar en listas", + "settings.categoryShowTitles": "Mostrar títulos", + "settings.addCategory": "Agregar categoría", + "settings.resetCategories": "Restablecer categorías predeterminadas", "settings.wrapLongLines": "Ajuste de línea automático", "settings.hideUnchangedRegions": "Ocultar regiones sin cambios", "settings.offlineMode": "Modo avión", "settings.systemPrompt": "Prompt del sistema", "settings.resetToDefault": "Restaurar valores predeterminados", + "settings.luaRuntimeOnly": "Lua es el único tiempo de ejecución de scripts en la app Rust.", + "settings.semanticSimilarityEnabled": "Similitud semántica", "settings.sshHost": "Host SSH", "settings.sshUsername": "Nombre de usuario SSH", "settings.sshRemotePath": "Ruta remota", @@ -308,10 +327,19 @@ "dashboard.overview": "Resumen", "dashboard.posts": "Artículos", "dashboard.media": "Medios", - "dashboard.templates": "Plantillas", - "dashboard.scripts": "Scripts", + "dashboard.tags": "Etiquetas", + "dashboard.categories": "Categorías", + "dashboard.images": "Imágenes", "dashboard.drafts": "Borradores", "dashboard.published": "Publicados", - "dashboard.entityCounts": "Recuento de entidades", - "dashboard.statusCounts": "Desglose por estado" + "dashboard.archived": "Archivados", + "dashboard.statCards": "Resumen", + "dashboard.timeline": "Cronología", + "dashboard.noTimelineData": "Todavía no hay datos de cronología", + "dashboard.tagCloud": "Nube de etiquetas", + "dashboard.categoryCloud": "Nube de categorías", + "dashboard.recentPosts": "Publicaciones recientes", + "dashboard.noRecentPosts": "No hay publicaciones recientes", + "dashboard.and": "y", + "dashboard.pin": "Fijar" } diff --git a/locales/ui/fr.json b/locales/ui/fr.json index 7007b95..96d9828 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -278,19 +278,38 @@ "tags.selectTag": "Sélectionnez un tag à modifier", "template.forceDeleteTitle": "Forcer la suppression du modèle", "template.forceDeleteMessage": "Ce modèle est utilisé par {posts} articles et {tags} tags. Forcer la suppression ?", + "common.add": "Ajouter", + "common.remove": "Supprimer", + "common.clear": "Effacer", + "common.noResults": "Aucun résultat", "settings.projectName": "Nom du projet", "settings.projectDescription": "Description", "settings.dataPath": "Dossier de données", "settings.browse": "Parcourir...", "settings.reset": "Réinitialiser", "settings.publicUrl": "URL publique", + "settings.mainLanguage": "Langue principale", + "settings.mainLanguageRequired": "requise", + "settings.blogLanguages": "Langues du blog", "settings.defaultAuthor": "Auteur par défaut", "settings.maxPostsPerPage": "Articles par page", + "settings.blogmarkCategory": "Catégorie Blogmark", + "settings.defaultMode": "Mode d'édition par défaut", + "settings.diffViewStyle": "Style de diff", + "settings.categoryTitle": "Titre pour {category}", + "settings.categoryPostTemplate": "Modèle d'article", + "settings.categoryListTemplate": "Modèle de liste", + "settings.categoryRenderInLists": "Afficher dans les listes", + "settings.categoryShowTitles": "Afficher les titres", + "settings.addCategory": "Ajouter une catégorie", + "settings.resetCategories": "Rétablir les catégories par défaut", "settings.wrapLongLines": "Retour à la ligne automatique", "settings.hideUnchangedRegions": "Masquer les régions inchangées", "settings.offlineMode": "Mode avion", "settings.systemPrompt": "Prompt système", "settings.resetToDefault": "Rétablir les valeurs par défaut", + "settings.luaRuntimeOnly": "Lua est l'unique moteur de script dans l'application Rust.", + "settings.semanticSimilarityEnabled": "Similarité sémantique", "settings.sshHost": "Hôte SSH", "settings.sshUsername": "Nom d'utilisateur SSH", "settings.sshRemotePath": "Chemin distant", @@ -308,10 +327,19 @@ "dashboard.overview": "Aperçu", "dashboard.posts": "Articles", "dashboard.media": "Médias", - "dashboard.templates": "Modèles", - "dashboard.scripts": "Scripts", + "dashboard.tags": "Tags", + "dashboard.categories": "Catégories", + "dashboard.images": "Images", "dashboard.drafts": "Brouillons", "dashboard.published": "Publiés", - "dashboard.entityCounts": "Nombre d'entités", - "dashboard.statusCounts": "Répartition par statut" + "dashboard.archived": "Archivés", + "dashboard.statCards": "Instantané", + "dashboard.timeline": "Chronologie", + "dashboard.noTimelineData": "Aucune donnée de chronologie", + "dashboard.tagCloud": "Nuage de tags", + "dashboard.categoryCloud": "Nuage de catégories", + "dashboard.recentPosts": "Articles récents", + "dashboard.noRecentPosts": "Aucun article récent", + "dashboard.and": "et", + "dashboard.pin": "Épingler" } diff --git a/locales/ui/it.json b/locales/ui/it.json index 1bdeb4d..bf984f3 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -278,19 +278,38 @@ "tags.selectTag": "Seleziona un tag da modificare", "template.forceDeleteTitle": "Forza eliminazione template", "template.forceDeleteMessage": "Questo template è usato da {posts} post e {tags} tag. Forzare l'eliminazione?", + "common.add": "Aggiungi", + "common.remove": "Rimuovi", + "common.clear": "Cancella", + "common.noResults": "Nessun risultato", "settings.projectName": "Nome progetto", "settings.projectDescription": "Descrizione", "settings.dataPath": "Cartella dati", "settings.browse": "Sfoglia...", "settings.reset": "Ripristina", "settings.publicUrl": "URL pubblica", + "settings.mainLanguage": "Lingua principale", + "settings.mainLanguageRequired": "obbligatoria", + "settings.blogLanguages": "Lingue del blog", "settings.defaultAuthor": "Autore predefinito", "settings.maxPostsPerPage": "Articoli per pagina", + "settings.blogmarkCategory": "Categoria Blogmark", + "settings.defaultMode": "Modalità editor predefinita", + "settings.diffViewStyle": "Stile diff", + "settings.categoryTitle": "Titolo per {category}", + "settings.categoryPostTemplate": "Modello articolo", + "settings.categoryListTemplate": "Modello elenco", + "settings.categoryRenderInLists": "Mostra negli elenchi", + "settings.categoryShowTitles": "Mostra titoli", + "settings.addCategory": "Aggiungi categoria", + "settings.resetCategories": "Ripristina categorie predefinite", "settings.wrapLongLines": "A capo automatico", "settings.hideUnchangedRegions": "Nascondi regioni invariate", "settings.offlineMode": "Modalità aereo", "settings.systemPrompt": "Prompt di sistema", "settings.resetToDefault": "Ripristina predefiniti", + "settings.luaRuntimeOnly": "Lua è l'unico runtime di scripting nell'app Rust.", + "settings.semanticSimilarityEnabled": "Similarità semantica", "settings.sshHost": "Host SSH", "settings.sshUsername": "Nome utente SSH", "settings.sshRemotePath": "Percorso remoto", @@ -308,10 +327,19 @@ "dashboard.overview": "Panoramica", "dashboard.posts": "Articoli", "dashboard.media": "Media", - "dashboard.templates": "Modelli", - "dashboard.scripts": "Script", + "dashboard.tags": "Tag", + "dashboard.categories": "Categorie", + "dashboard.images": "Immagini", "dashboard.drafts": "Bozze", "dashboard.published": "Pubblicati", - "dashboard.entityCounts": "Conteggio entità", - "dashboard.statusCounts": "Ripartizione per stato" + "dashboard.archived": "Archiviati", + "dashboard.statCards": "Riepilogo", + "dashboard.timeline": "Cronologia", + "dashboard.noTimelineData": "Nessun dato cronologico disponibile", + "dashboard.tagCloud": "Nuvola di tag", + "dashboard.categoryCloud": "Nuvola di categorie", + "dashboard.recentPosts": "Post recenti", + "dashboard.noRecentPosts": "Nessun post recente", + "dashboard.and": "e", + "dashboard.pin": "Fissa" }