From c6d26957dcb83ee620ed49e6b5019d57619bcf4f Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 9 Apr 2026 07:48:43 +0200 Subject: [PATCH] fix: more gaps in M3 --- crates/bds-core/src/engine/tag.rs | 31 +- crates/bds-ui/src/app.rs | 805 ++++++++++++++++++++--- crates/bds-ui/src/platform/menu.rs | 5 + crates/bds-ui/src/views/media_editor.rs | 111 +++- crates/bds-ui/src/views/modal.rs | 3 +- crates/bds-ui/src/views/panel.rs | 69 +- crates/bds-ui/src/views/post_editor.rs | 10 +- crates/bds-ui/src/views/script_editor.rs | 16 +- crates/bds-ui/src/views/tags_view.rs | 409 +++++++++--- crates/bds-ui/src/views/workspace.rs | 6 + locales/ui/de.json | 28 +- locales/ui/en.json | 28 +- locales/ui/es.json | 28 +- locales/ui/fr.json | 28 +- locales/ui/it.json | 28 +- 15 files changed, 1403 insertions(+), 202 deletions(-) diff --git a/crates/bds-core/src/engine/tag.rs b/crates/bds-core/src/engine/tag.rs index a5d089d..75f9ea9 100644 --- a/crates/bds-core/src/engine/tag.rs +++ b/crates/bds-core/src/engine/tag.rs @@ -274,11 +274,21 @@ pub fn sync_tags_from_posts( tag_q::insert_tag(conn, &tag)?; } } - let all_tags = tag_q::list_tags_by_project(conn, project_id)?; Ok(all_tags) } +/// Discover tags from posts and rewrite tags.json to match the resulting DB state. +pub fn discover_tags( + conn: &Connection, + data_dir: &Path, + project_id: &str, +) -> EngineResult> { + let tags = sync_tags_from_posts(conn, project_id)?; + rewrite_tags_json(conn, data_dir, project_id)?; + Ok(tags) +} + /// Rewrite meta/tags.json from DB state. pub fn rewrite_tags_json( conn: &Connection, @@ -520,7 +530,7 @@ mod tests { #[test] fn sync_tags_from_posts_creates_missing() { - let (db, dir) = setup(); + let (db, _dir) = setup(); insert_post( db.conn(), &make_post("x1", "a", vec!["rust".into(), "web".into()]), @@ -540,6 +550,23 @@ mod tests { assert!(names.contains(&"web")); } + #[test] + fn discover_tags_rewrites_tags_json() { + let (db, dir) = setup(); + insert_post( + db.conn(), + &make_post("x1", "a", vec!["rust".into(), "web".into()]), + ) + .unwrap(); + + let tags = discover_tags(db.conn(), dir.path(), "p1").unwrap(); + assert_eq!(tags.len(), 2); + + let entries = meta::read_tags_json(dir.path()).unwrap(); + let names = entries.into_iter().map(|entry| entry.name).collect::>(); + assert_eq!(names, vec!["rust".to_string(), "web".to_string()]); + } + #[test] fn import_tags_from_file_preserves_colors() { let (db, dir) = setup(); diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 5c55a25..4749c3f 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -23,10 +23,10 @@ use crate::state::toast::{Toast, ToastLevel}; use crate::views::{ modal, workspace, post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink}, - media_editor::{MediaEditorState, MediaEditorMsg}, + media_editor::{LinkedPostItem, MediaEditorState, MediaEditorMsg}, template_editor::{TemplateEditorState, TemplateEditorMsg}, script_editor::{ScriptEditorState, ScriptEditorMsg}, - tags_view::{self, TagsViewState, TagsMsg}, + tags_view::{self, TagsMsg, TagsSection, TagsViewState}, settings_view::{SettingsViewState, SettingsMsg}, dashboard::DashboardState, }; @@ -160,6 +160,8 @@ enum PersistedPostState { Translation(bds_core::model::PostTranslation), } +const POST_AUTO_SAVE_DELAY_MS: i64 = 3_000; + fn persist_post_editor_state_impl( db: &Database, data_dir: &Path, @@ -249,6 +251,7 @@ fn save_template_editor_state_impl( project_id: &str, state: &TemplateEditorState, ) -> Result { + engine::template::validate_template(&state.content)?; engine::template::update_template( db.conn(), &state.template_id, @@ -267,6 +270,7 @@ fn save_script_editor_state_impl( project_id: &str, state: &ScriptEditorState, ) -> Result { + engine::script::validate_script_syntax(&state.content)?; engine::script::update_script( db.conn(), &state.script_id, @@ -300,8 +304,8 @@ fn save_editor_settings_state_impl( #[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, PersistedMediaState, PersistedPostState}; - use crate::views::media_editor::MediaEditorState; + 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 crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; use crate::views::post_editor::PostEditorState; use crate::views::script_editor::ScriptEditorState; use crate::views::settings_view::SettingsViewState; @@ -353,6 +357,10 @@ mod tests { (db, project, tempdir) } + fn make_app(db: Database, project: Project, tmp: &TempDir) -> BdsApp { + BdsApp::new_for_tests(db, project, tmp.path().to_path_buf()) + } + #[test] fn post_editor_save_flow_persists_changes() { let (db, project, tmp) = setup(); @@ -407,7 +415,7 @@ mod tests { ).unwrap(); let media_record = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported.id).unwrap(); - let mut editor = MediaEditorState::from_media(&media_record, &["en".to_string()], &[]); + let mut editor = MediaEditorState::from_media(&media_record, &["en".to_string()], &[], Vec::new()); editor.title = "Tiny Updated".to_string(); editor.tags_input = "photo, lua".to_string(); @@ -450,6 +458,28 @@ mod tests { assert_eq!(saved.content.as_deref(), Some("
{{ title }}
")); } + #[test] + fn template_editor_save_rejects_invalid_content() { + let (db, project, _tmp) = setup(); + let created = template::create_template( + db.conn(), + &project.id, + "Broken Template", + TemplateKind::Post, + "
{{ title }}
", + ).unwrap(); + + let template_record = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap(); + let mut editor = TemplateEditorState::from_template(&template_record); + editor.content = "{% if title %}".to_string(); + + let error = save_template_editor_state_impl(&db, &project.id, &editor).unwrap_err(); + assert!(error.contains("endif") || error.contains("unclosed") || error.contains("missing")); + + let saved = bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap(); + assert_eq!(saved.content.as_deref(), Some("
{{ title }}
")); + } + #[test] fn script_editor_save_flow_persists_changes() { let (db, project, _tmp) = setup(); @@ -476,6 +506,29 @@ mod tests { assert_eq!(saved.content.as_deref(), Some("function main()\n return 'lua'\nend")); } + #[test] + fn script_editor_save_rejects_invalid_content() { + let (db, project, _tmp) = setup(); + let created = script::create_script( + db.conn(), + &project.id, + "Broken Script", + ScriptKind::Utility, + "function main()\n return 'ok'\nend", + Some("main"), + ).unwrap(); + + let script_record = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap(); + let mut editor = ScriptEditorState::from_script(&script_record); + editor.content = "function main()\n return 'oops'".to_string(); + + let error = save_script_editor_state_impl(&db, &project.id, &editor).unwrap_err(); + assert!(error.contains("end") || error.contains("unclosed") || error.contains("missing")); + + let saved = bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap(); + assert_eq!(saved.content.as_deref(), Some("function main()\n return 'ok'\nend")); + } + #[test] fn settings_editor_save_flow_persists_values() { let (db, _project, _tmp) = setup(); @@ -497,6 +550,152 @@ mod tests { assert_eq!(hide.value, "true"); assert_eq!(diff.value, "side-by-side"); } + + #[test] + fn task_tick_autosaves_dirty_post_editor() { + let (db, project, tmp) = setup(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Original", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ).unwrap(); + + let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); + let mut editor = PostEditorState::from_post(&editor_post, &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); + editor.title = "Autosaved".to_string(); + editor.mark_dirty(); + editor.last_edit_at_ms = bds_core::util::now_unix_ms() - POST_AUTO_SAVE_DELAY_MS - 100; + + let mut app = make_app(db, project, &tmp); + app.post_editors.insert(created.id.clone(), editor); + app.tabs.push(crate::state::tabs::Tab { + id: created.id.clone(), + tab_type: crate::state::tabs::TabType::Post, + title: "Original".to_string(), + is_transient: false, + is_dirty: true, + }); + app.active_tab = Some(created.id.clone()); + + let _ = app.update(Message::TaskTick); + + let saved = bds_core::db::queries::post::get_post_by_id(app.db.as_ref().unwrap().conn(), &created.id).unwrap(); + assert_eq!(saved.title, "Autosaved"); + assert!(!app.post_editors.get(&created.id).unwrap().is_dirty); + } + + #[test] + fn switching_tabs_flushes_active_post_editor() { + let (db, project, tmp) = setup(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Original", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ).unwrap(); + + let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); + let mut editor = PostEditorState::from_post(&editor_post, &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); + editor.title = "Switched".to_string(); + editor.mark_dirty(); + + let mut app = make_app(db, project, &tmp); + app.post_editors.insert(created.id.clone(), editor); + app.tabs.push(crate::state::tabs::Tab { + id: created.id.clone(), + tab_type: crate::state::tabs::TabType::Post, + title: "Original".to_string(), + is_transient: false, + is_dirty: true, + }); + app.tabs.push(crate::state::tabs::Tab { + id: "settings".to_string(), + tab_type: crate::state::tabs::TabType::Settings, + title: "Settings".to_string(), + is_transient: false, + is_dirty: false, + }); + app.active_tab = Some(created.id.clone()); + + let _ = app.update(Message::SelectTab("settings".to_string())); + + let saved = bds_core::db::queries::post::get_post_by_id(app.db.as_ref().unwrap().conn(), &created.id).unwrap(); + assert_eq!(saved.title, "Switched"); + assert_eq!(app.active_tab.as_deref(), Some("settings")); + } + + #[test] + fn media_editor_link_and_unlink_post_refreshes_relationships() { + let (db, project, tmp) = setup(); + let created_post = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Linked Post", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ).unwrap(); + let source = tmp.path().join("tiny.png"); + std::fs::write(&source, tiny_png_bytes()).unwrap(); + let imported = media::import_media( + db.conn(), + tmp.path(), + &project.id, + &source, + "tiny.png", + Some("Tiny"), + Some("Alt"), + None, + None, + Some("en"), + vec![], + ).unwrap(); + + let media_record = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported.id).unwrap(); + let mut app = make_app(db, project, &tmp); + app.media_editors.insert( + imported.id.clone(), + MediaEditorState::from_media(&media_record, &["en".to_string()], &[], Vec::new()), + ); + app.tabs.push(crate::state::tabs::Tab { + id: imported.id.clone(), + tab_type: crate::state::tabs::TabType::Media, + title: "Tiny".to_string(), + is_transient: false, + is_dirty: false, + }); + app.active_tab = Some(imported.id.clone()); + + let _ = app.update(Message::MediaEditor(MediaEditorMsg::LinkPost(created_post.id.clone()))); + + let linked = bds_core::engine::post_media::list_posts_for_media(app.db.as_ref().unwrap().conn(), &imported.id).unwrap(); + assert_eq!(linked.len(), 1); + assert_eq!(linked[0].id, created_post.id); + assert_eq!(app.media_editors.get(&imported.id).unwrap().linked_posts.len(), 1); + + let _ = app.update(Message::MediaEditor(MediaEditorMsg::UnlinkPost(created_post.id.clone()))); + + let linked = bds_core::engine::post_media::list_posts_for_media(app.db.as_ref().unwrap().conn(), &imported.id).unwrap(); + assert!(linked.is_empty()); + assert!(app.media_editors.get(&imported.id).unwrap().linked_posts.is_empty()); + } } // ─────────────────────────────────────────────────────────── @@ -551,7 +750,7 @@ pub struct BdsApp { output_entries: Vec, // Platform - _menu_bar: muda::Menu, + _menu_bar: Option, menu_registry: MenuRegistry, // i18n @@ -689,7 +888,7 @@ impl BdsApp { task_manager: Arc::new(TaskManager::default()), task_snapshots: Vec::new(), output_entries: Vec::new(), - _menu_bar: menu_bar, + _menu_bar: Some(menu_bar), menu_registry: registry, ui_locale: locale, content_language: "en".to_string(), @@ -712,6 +911,58 @@ impl BdsApp { ) } + #[cfg(test)] + fn new_for_tests(db: Database, project: Project, data_dir: PathBuf) -> Self { + Self { + db: Some(db), + db_path: data_dir.join("bds.db"), + active_project: Some(project.clone()), + projects: vec![project], + data_dir: Some(data_dir), + post_count: 0, + media_count: 0, + sidebar_posts: Vec::new(), + sidebar_media: Vec::new(), + sidebar_scripts: Vec::new(), + sidebar_templates: Vec::new(), + sidebar_media_thumbs: HashMap::new(), + sidebar_posts_has_more: false, + sidebar_media_has_more: false, + post_filter: PostFilter::default(), + page_filter: PostFilter::default(), + media_filter: MediaFilter::default(), + sidebar_view: SidebarView::Posts, + sidebar_visible: true, + sidebar_width: 280.0, + sidebar_dragging: false, + tabs: Vec::new(), + active_tab: None, + panel_visible: false, + panel_tab: PanelTab::Tasks, + task_manager: Arc::new(TaskManager::default()), + task_snapshots: Vec::new(), + output_entries: Vec::new(), + _menu_bar: None, + menu_registry: MenuRegistry::empty(), + ui_locale: UiLocale::En, + content_language: "en".to_string(), + blog_languages: Vec::new(), + offline_mode: false, + locale_dropdown_open: false, + project_dropdown_open: false, + theme_badge: String::from("pico"), + toasts: Vec::new(), + active_modal: None, + post_editors: HashMap::new(), + media_editors: HashMap::new(), + template_editors: HashMap::new(), + script_editors: HashMap::new(), + tags_view_state: None, + settings_state: None, + dashboard_state: None, + } + } + pub fn update(&mut self, message: Message) -> Task { match message { // ── Menu event dispatch ── @@ -781,6 +1032,7 @@ impl BdsApp { // ── Tabs ── Message::OpenTab(tab) => { + self.flush_active_post_editor(); let idx = tabs::open_tab(&mut self.tabs, tab); if let Some(t) = self.tabs.get(idx) { self.active_tab = Some(t.id.clone()); @@ -792,6 +1044,9 @@ impl BdsApp { Task::none() } Message::CloseTab(id) => { + if self.active_tab.as_deref() == Some(id.as_str()) { + self.flush_active_post_editor(); + } if let Some(next_idx) = tabs::close_tab(&mut self.tabs, &id) { self.active_tab = self.tabs.get(next_idx).map(|t| t.id.clone()); } else { @@ -803,6 +1058,9 @@ impl BdsApp { } Message::SelectTab(id) => { if self.tabs.iter().any(|t| t.id == id) { + if self.active_tab.as_deref() != Some(id.as_str()) { + self.flush_active_post_editor(); + } self.active_tab = Some(id); } self.enforce_panel_tab_fallback(); @@ -813,6 +1071,7 @@ impl BdsApp { Task::none() } Message::ClearTabs => { + self.flush_active_post_editor(); self.tabs.clear(); self.active_tab = None; Task::none() @@ -970,6 +1229,7 @@ impl BdsApp { // ── Tasks ── Message::TaskTick => { self.refresh_task_snapshots(); + self.auto_save_due_post_editors(); Task::none() } @@ -1309,10 +1569,11 @@ impl BdsApp { modal::ConfirmAction::DeletePost(id) => self.delete_post_editor(&id), modal::ConfirmAction::DeleteMedia(id) => self.delete_media_editor(&id), modal::ConfirmAction::DeleteScript(id) => self.delete_script_editor(&id), - modal::ConfirmAction::DeleteTemplate(id) => self.delete_template_editor(&id), + modal::ConfirmAction::DeleteTemplate(id) => self.delete_template_editor(&id, false), + modal::ConfirmAction::ForceDeleteTemplate(id) => self.delete_template_editor(&id, true), modal::ConfirmAction::DeleteTag(id) => self.delete_tag(&id), - modal::ConfirmAction::MergeTags { source, target } => { - self.merge_tags(&source, &target) + modal::ConfirmAction::MergeTags { sources, target } => { + self.merge_tags(&sources, &target) } } } @@ -1350,17 +1611,17 @@ impl BdsApp { if let Some(tab_id) = self.active_tab.clone() { if let Some(state) = self.post_editors.get_mut(&tab_id) { match msg { - PostEditorMsg::TitleChanged(s) => { state.title = s; state.is_dirty = true; } - PostEditorMsg::SlugChanged(s) => { state.slug = s; state.is_dirty = true; } - PostEditorMsg::ExcerptChanged(s) => { state.excerpt = s; state.is_dirty = true; } + PostEditorMsg::TitleChanged(s) => { state.title = s; state.mark_dirty(); } + PostEditorMsg::SlugChanged(s) => { state.slug = s; state.mark_dirty(); } + PostEditorMsg::ExcerptChanged(s) => { state.excerpt = s; state.mark_dirty(); } PostEditorMsg::ContentChanged(new_text) => { state.content = new_text; - state.is_dirty = true; + state.mark_dirty(); } - PostEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; } - PostEditorMsg::LanguageChanged(s) => { state.language = s; state.is_dirty = true; } - PostEditorMsg::TemplateSlugChanged(s) => { state.template_slug = s; state.is_dirty = true; } - PostEditorMsg::ToggleDoNotTranslate(b) => { state.do_not_translate = b; state.is_dirty = true; } + PostEditorMsg::AuthorChanged(s) => { state.author = s; state.mark_dirty(); } + PostEditorMsg::LanguageChanged(s) => { state.language = s; state.mark_dirty(); } + PostEditorMsg::TemplateSlugChanged(s) => { state.template_slug = s; state.mark_dirty(); } + PostEditorMsg::ToggleDoNotTranslate(b) => { state.do_not_translate = b; state.mark_dirty(); } PostEditorMsg::ToggleMetadata => { state.metadata_expanded = !state.metadata_expanded; } PostEditorMsg::ToggleExcerpt => { state.excerpt_expanded = !state.excerpt_expanded; } PostEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); } @@ -1369,26 +1630,26 @@ impl BdsApp { let tag = state.tags_input.trim().to_string(); if !tag.is_empty() && !state.tags.contains(&tag) { state.tags.push(tag); - state.is_dirty = true; + state.mark_dirty(); } state.tags_input.clear(); } PostEditorMsg::RemoveTag(tag) => { state.tags.retain(|t| t != &tag); - state.is_dirty = true; + state.mark_dirty(); } PostEditorMsg::CategoriesInputChanged(s) => { state.categories_input = s; } PostEditorMsg::CategoriesInputSubmit => { let cat = state.categories_input.trim().to_string(); if !cat.is_empty() && !state.categories.contains(&cat) { state.categories.push(cat); - state.is_dirty = true; + state.mark_dirty(); } state.categories_input.clear(); } PostEditorMsg::RemoveCategory(cat) => { state.categories.retain(|c| c != &cat); - state.is_dirty = true; + state.mark_dirty(); } PostEditorMsg::Save => { deferred = DeferredPostAction::Save(tab_id.clone()); @@ -1603,6 +1864,17 @@ impl BdsApp { } } Message::MediaEditor(msg) => { + enum DeferredMediaAction { + None, + LinkPost { media_id: String, post_id: String }, + OpenLinkedPost(String), + UnlinkPost { media_id: String, post_id: String }, + Save(String), + Delete { tab_id: String, name: String }, + } + + let mut deferred = DeferredMediaAction::None; + let mut picker_refresh: Option<(String, String)> = None; if let Some(tab_id) = self.active_tab.clone() { if let Some(state) = self.media_editors.get_mut(&tab_id) { match msg { @@ -1613,16 +1885,44 @@ impl BdsApp { MediaEditorMsg::LanguageChanged(s) => { state.language = s; state.is_dirty = true; } MediaEditorMsg::TagsChanged(s) => { state.tags_input = s; state.is_dirty = true; } MediaEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); } + MediaEditorMsg::TogglePostPicker => { + let next_open = !state.post_picker_open; + state.post_picker_open = next_open; + if !next_open { + state.post_picker_results.clear(); + } else { + picker_refresh = Some((state.media_id.clone(), state.post_picker_search.clone())); + } + } + MediaEditorMsg::PostPickerSearchChanged(search) => { + state.post_picker_search = search; + if state.post_picker_open { + picker_refresh = Some((state.media_id.clone(), state.post_picker_search.clone())); + } + } + MediaEditorMsg::LinkPost(post_id) => { + deferred = DeferredMediaAction::LinkPost { + media_id: state.media_id.clone(), + post_id, + }; + } + MediaEditorMsg::OpenLinkedPost(post_id) => { + deferred = DeferredMediaAction::OpenLinkedPost(post_id); + } + MediaEditorMsg::UnlinkPost(post_id) => { + deferred = DeferredMediaAction::UnlinkPost { + media_id: state.media_id.clone(), + post_id, + }; + } MediaEditorMsg::Save => { - return self.save_media_editor(&tab_id); + deferred = DeferredMediaAction::Save(tab_id.clone()); } MediaEditorMsg::Delete => { - let name = state.title.clone(); - return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete { - entity_name: name, - references: Vec::new(), - on_confirm: modal::ConfirmAction::DeleteMedia(tab_id), - })); + deferred = DeferredMediaAction::Delete { + tab_id: tab_id.clone(), + name: state.title.clone(), + }; } } if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *state.media_id.as_str()) { @@ -1630,7 +1930,38 @@ impl BdsApp { } } } - Task::none() + if let Some((media_id, search)) = picker_refresh { + let results = self.query_media_post_picker_results(&media_id, &search); + if let Some(state) = self.media_editors.get_mut(&media_id) { + state.post_picker_results = results; + } + } + match deferred { + DeferredMediaAction::None => Task::none(), + DeferredMediaAction::LinkPost { media_id, post_id } => self.link_media_to_post(&media_id, &post_id), + DeferredMediaAction::OpenLinkedPost(post_id) => { + let title = self + .db + .as_ref() + .and_then(|db| bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()) + .map(|post| post.title) + .unwrap_or_else(|| post_id.clone()); + Task::done(Message::OpenTab(Tab { + id: post_id, + tab_type: TabType::Post, + title, + is_transient: false, + is_dirty: false, + })) + } + DeferredMediaAction::UnlinkPost { media_id, post_id } => self.unlink_media_from_post(&media_id, &post_id), + DeferredMediaAction::Save(tab_id) => self.save_media_editor(&tab_id), + DeferredMediaAction::Delete { tab_id, name } => Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete { + entity_name: name, + references: Vec::new(), + on_confirm: modal::ConfirmAction::DeleteMedia(tab_id), + })), + } } Message::TemplateEditor(msg) => { if let Some(tab_id) = self.active_tab.clone() { @@ -1656,12 +1987,7 @@ impl BdsApp { } } TemplateEditorMsg::Delete => { - let name = state.title.clone(); - return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete { - entity_name: name, - references: Vec::new(), - on_confirm: modal::ConfirmAction::DeleteTemplate(tab_id), - })); + return self.show_template_delete_confirmation(&tab_id); } } if let Some(st) = self.template_editors.get(&tab_id) { @@ -1779,7 +2105,8 @@ impl BdsApp { db.conn(), &media.id, ).ok()) .unwrap_or_default(); - let state = MediaEditorState::from_media(&media, &self.blog_languages, &translations); + let linked_posts = self.load_media_linked_posts(&media.id); + let state = MediaEditorState::from_media(&media, &self.blog_languages, &translations, linked_posts); self.media_editors.insert(media.id.clone(), state); } Err(e) => self.notify(ToastLevel::Error, &e), @@ -1810,7 +2137,9 @@ impl BdsApp { Message::Noop => Task::none(), Message::InitMenuBar => { #[cfg(target_os = "macos")] - menu::init_menu_for_nsapp(&self._menu_bar); + if let Some(menu_bar) = self._menu_bar.as_ref() { + menu::init_menu_for_nsapp(menu_bar); + } Task::none() } } @@ -2078,6 +2407,44 @@ impl BdsApp { .collect(); } + fn flush_active_post_editor(&mut self) { + let Some(active_id) = self.active_tab.clone() else { return }; + let is_dirty_post = self + .tabs + .iter() + .find(|tab| tab.id == active_id) + .map(|tab| tab.tab_type == TabType::Post) + .unwrap_or(false) + && self + .post_editors + .get(&active_id) + .map(|state| state.is_dirty) + .unwrap_or(false); + if is_dirty_post { + let _ = self.persist_post_editor_state(&active_id); + } + } + + fn auto_save_due_post_editors(&mut self) { + let now = bds_core::util::now_unix_ms(); + let due_ids: Vec = self + .post_editors + .iter() + .filter_map(|(post_id, state)| { + (state.is_dirty + && state.last_edit_at_ms > 0 + && now - state.last_edit_at_ms >= POST_AUTO_SAVE_DELAY_MS) + .then(|| post_id.clone()) + }) + .collect(); + + for post_id in due_ids { + if let Err(error) = self.persist_post_editor_state(&post_id) { + self.notify(ToastLevel::Error, &format!("Auto-save failed: {error}")); + } + } + } + fn add_output(&mut self, text: &str) { self.output_entries.push(OutputEntry { timestamp: chrono::Utc::now().timestamp(), @@ -2618,6 +2985,35 @@ impl BdsApp { .unwrap_or_default() } + fn query_media_post_picker_results(&self, media_id: &str, search_query: &str) -> Vec { + let (Some(db), Some(project)) = (&self.db, &self.active_project) else { + return Vec::new(); + }; + + let linked_ids: std::collections::HashSet = bds_core::engine::post_media::list_posts_for_media(db.conn(), media_id) + .unwrap_or_default() + .into_iter() + .map(|post| post.id) + .collect(); + + let query = search_query.trim().to_lowercase(); + bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id) + .unwrap_or_default() + .into_iter() + .filter(|post| !linked_ids.contains(&post.id)) + .filter(|post| query.is_empty() || post.title.to_lowercase().contains(&query)) + .take(10) + .map(|post| LinkedPostItem { + post_id: post.id, + title: if post.title.is_empty() { + t(self.ui_locale, "editor.untitled") + } else { + post.title + }, + }) + .collect() + } + fn insert_markdown_into_post(&mut self, post_id: &str, markdown: &str) -> Task { let Some(state) = self.post_editors.get_mut(post_id) else { return Task::none() }; state.insert_markdown_at_cursor(markdown); @@ -2822,6 +3218,79 @@ impl BdsApp { Task::none() } + fn load_media_linked_posts(&self, media_id: &str) -> Vec { + let Some(ref db) = self.db else { + return Vec::new(); + }; + + bds_core::engine::post_media::list_posts_for_media(db.conn(), media_id) + .unwrap_or_default() + .into_iter() + .map(|post| LinkedPostItem { + post_id: post.id, + title: if post.title.is_empty() { + t(self.ui_locale, "editor.untitled") + } else { + post.title + }, + }) + .collect() + } + + fn refresh_media_relationships(&mut self, media_id: &str) { + let linked_posts = self.load_media_linked_posts(media_id); + let search = self + .media_editors + .get(media_id) + .map(|state| state.post_picker_search.clone()) + .unwrap_or_default(); + let post_picker_results = self.query_media_post_picker_results(media_id, &search); + if let Some(state) = self.media_editors.get_mut(media_id) { + state.linked_posts = linked_posts; + state.post_picker_results = post_picker_results; + } + } + + fn link_media_to_post(&mut self, media_id: &str, post_id: &str) -> Task { + if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, &self.active_project) { + let sort_order = bds_core::engine::post_media::list_media_for_post(db.conn(), post_id) + .map(|items| items.len() as i32) + .unwrap_or(0); + match bds_core::engine::post_media::link_media_to_post( + db.conn(), + data_dir, + &project.id, + post_id, + media_id, + sort_order, + ) { + Ok(_) => { + self.refresh_media_relationships(media_id); + self.refresh_post_relationships(post_id); + } + Err(error) => { + self.notify(ToastLevel::Error, &format!("Failed to link post: {error}")); + } + } + } + Task::none() + } + + fn unlink_media_from_post(&mut self, media_id: &str, post_id: &str) -> Task { + if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) { + match bds_core::engine::post_media::unlink_media_from_post(db.conn(), data_dir, post_id, media_id) { + Ok(()) => { + self.refresh_media_relationships(media_id); + self.refresh_post_relationships(post_id); + } + Err(error) => { + self.notify(ToastLevel::Error, &format!("Failed to unlink post: {error}")); + } + } + } + Task::none() + } + fn save_template_editor(&mut self, template_id: &str) -> Task { let Some(state) = self.template_editors.get(template_id) else { return Task::none() }; let Some(ref db) = self.db else { return Task::none() }; @@ -2832,6 +3301,7 @@ impl BdsApp { let s = self.template_editors.get_mut(template_id).unwrap(); s.is_dirty = false; s.updated_at = tmpl.updated_at; + s.validation_error = None; if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tmpl.id) { tab.is_dirty = false; tab.title = tmpl.title.clone(); @@ -2839,6 +3309,9 @@ impl BdsApp { self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } Err(e) => { + if let Some(s) = self.template_editors.get_mut(template_id) { + s.validation_error = Some(e.clone()); + } self.notify(ToastLevel::Error, &format!("Save failed: {e}")); } } @@ -2855,6 +3328,8 @@ impl BdsApp { let s = self.script_editors.get_mut(script_id).unwrap(); s.is_dirty = false; s.updated_at = script.updated_at; + s.discovered_entrypoints = engine::script::discover_entrypoints(&s.content); + s.validation_error = None; if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == script.id) { tab.is_dirty = false; tab.title = script.title.clone(); @@ -2862,6 +3337,9 @@ impl BdsApp { self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } Err(e) => { + if let Some(s) = self.script_editors.get_mut(script_id) { + s.validation_error = Some(e.clone()); + } self.notify(ToastLevel::Error, &format!("Save failed: {e}")); } } @@ -2884,6 +3362,7 @@ impl BdsApp { PersistedPostState::Translation(translation) => { if let Some(editor) = self.post_editors.get_mut(post_id) { editor.is_dirty = false; + editor.last_edit_at_ms = 0; if let Some(draft) = editor.translation_drafts.get_mut(&state.active_language) { draft.title = translation.title.clone(); draft.excerpt = translation.excerpt.clone().unwrap_or_default(); @@ -2908,6 +3387,7 @@ impl BdsApp { editor.updated_at = post.updated_at; editor.published_at = post.published_at; editor.is_dirty = false; + editor.last_edit_at_ms = 0; } if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post.id) { tab.is_dirty = false; @@ -3064,10 +3544,10 @@ impl BdsApp { Task::none() } - fn delete_template_editor(&mut self, template_id: &str) -> Task { + fn delete_template_editor(&mut self, template_id: &str, force: bool) -> Task { let Some(db) = &self.db else { return Task::none() }; let Some(data_dir) = &self.data_dir else { return Task::none() }; - match engine::template::delete_template(db.conn(), data_dir, template_id, false) { + match engine::template::delete_template(db.conn(), data_dir, template_id, force) { Ok(()) => { self.template_editors.remove(template_id); self.close_entity_tab(template_id); @@ -3078,6 +3558,47 @@ impl BdsApp { Task::none() } + fn show_template_delete_confirmation(&mut self, template_id: &str) -> Task { + let Some(db) = &self.db else { return Task::none() }; + let Ok(template) = bds_core::db::queries::template::get_template_by_id(db.conn(), template_id) else { + return Task::none(); + }; + + let referencing_posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &template.project_id) + .unwrap_or_default() + .into_iter() + .filter(|post| post.template_slug.as_deref() == Some(template.slug.as_str())) + .count(); + let referencing_tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &template.project_id) + .unwrap_or_default() + .into_iter() + .filter(|tag| tag.post_template_slug.as_deref() == Some(template.slug.as_str())) + .count(); + + if referencing_posts > 0 || referencing_tags > 0 { + let title = t(self.ui_locale, "template.forceDeleteTitle"); + let message = tw( + self.ui_locale, + "template.forceDeleteMessage", + &[ + ("posts", &referencing_posts.to_string()), + ("tags", &referencing_tags.to_string()), + ], + ); + return Task::done(Message::ShowModal(modal::ModalState::Confirm { + title, + message, + on_confirm: modal::ConfirmAction::ForceDeleteTemplate(template_id.to_string()), + })); + } + + Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete { + entity_name: template.title, + references: Vec::new(), + on_confirm: modal::ConfirmAction::DeleteTemplate(template_id.to_string()), + })) + } + fn delete_script_editor(&mut self, script_id: &str) -> Task { let Some(db) = &self.db else { return Task::none() }; let Some(data_dir) = &self.data_dir else { return Task::none() }; @@ -3097,6 +3618,11 @@ impl BdsApp { let Some(project) = &self.active_project else { return }; let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id) .unwrap_or_default(); + let 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::>(); let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id) .unwrap_or_default(); let mut counts = HashMap::new(); @@ -3108,8 +3634,28 @@ impl BdsApp { if let Some(state) = self.tags_view_state.as_mut() { state.tags = tags; state.tag_post_counts = counts; + state.template_options = template_options; + state.selected_tags.retain(|selected_id| state.tags.iter().any(|tag| &tag.id == selected_id)); + if state.selected_tags.len() == 1 { + if let Some(tag) = state.tags.iter().find(|tag| state.selected_tags[0] == tag.id) { + state.editing_tag = Some(tags_view::EditingTag { + id: tag.id.clone(), + original_name: tag.name.clone(), + name: tag.name.clone(), + color: tag.color.clone().unwrap_or_default(), + template_slug: tag.post_template_slug.clone().unwrap_or_default(), + }); + } + } else { + state.editing_tag = None; + } + if let Some(target_id) = state.merge_target.as_ref() { + if !state.selected_tags.iter().any(|selected_id| selected_id == target_id) { + state.merge_target = None; + } + } } else { - self.tags_view_state = Some(TagsViewState::new(tags, counts)); + self.tags_view_state = Some(TagsViewState::new(tags, counts, template_options)); } } @@ -3132,16 +3678,18 @@ impl BdsApp { Task::none() } - fn merge_tags(&mut self, source: &str, target: &str) -> Task { + fn merge_tags(&mut self, sources: &[String], target: &str) -> Task { let Some(db) = &self.db else { return Task::none() }; let Some(data_dir) = &self.data_dir else { return Task::none() }; let Some(project) = &self.active_project else { return Task::none() }; - match engine::tag::merge_tags(db.conn(), data_dir, &project.id, &[source], target) { + let source_refs = sources.iter().map(String::as_str).collect::>(); + match engine::tag::merge_tags(db.conn(), data_dir, &project.id, &source_refs, target) { Ok(()) => { self.reload_tags_state(); if let Some(state) = self.tags_view_state.as_mut() { - state.merge_source = None; + state.selected_tags.clear(); state.merge_target = None; + state.editing_tag = None; } self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } @@ -3199,44 +3747,69 @@ impl BdsApp { fn handle_tags_msg(&mut self, msg: TagsMsg) -> Task { // Ensure tags view state exists if self.tags_view_state.is_none() { - let (tags, counts) = if let (Some(db), Some(project)) = (&self.db, &self.active_project) { - let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id) - .unwrap_or_default(); - let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id) - .unwrap_or_default(); - let mut counts = std::collections::HashMap::new(); - for post in &posts { - for tag_name in &post.tags { - *counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1; - } - } - (tags, counts) - } else { - (Vec::new(), std::collections::HashMap::new()) - }; - self.tags_view_state = Some(TagsViewState::new(tags, counts)); + self.reload_tags_state(); + if self.tags_view_state.is_none() { + self.tags_view_state = Some(TagsViewState::new(Vec::new(), HashMap::new(), Vec::new())); + } } let state = self.tags_view_state.as_mut().unwrap(); match msg { TagsMsg::SetSection(s) => { state.section = s; } TagsMsg::SearchChanged(q) => { state.search_query = q; } - TagsMsg::SelectTag(id) => { - if let Some(tag) = state.tags.iter().find(|t| t.id == id) { - state.editing_tag = Some(tags_view::EditingTag { - id: tag.id.clone(), - name: tag.name.clone(), - color: tag.color.clone().unwrap_or_default(), - template_slug: tag.post_template_slug.clone().unwrap_or_default(), - }); + TagsMsg::ToggleTagSelection(id) => { + if let Some(pos) = state.selected_tags.iter().position(|selected_id| selected_id == &id) { + state.selected_tags.remove(pos); + } else { + state.selected_tags.push(id.clone()); + } + + if state.selected_tags.len() == 1 { + let selected_id = state.selected_tags[0].clone(); + if let Some(tag) = state.tags.iter().find(|tag| tag.id == selected_id) { + state.editing_tag = Some(tags_view::EditingTag { + id: tag.id.clone(), + original_name: tag.name.clone(), + name: tag.name.clone(), + color: tag.color.clone().unwrap_or_default(), + template_slug: tag.post_template_slug.clone().unwrap_or_default(), + }); + } + state.section = TagsSection::Manage; + } else { + state.editing_tag = None; + } + + if let Some(target_id) = state.merge_target.as_ref() { + if !state.selected_tags.iter().any(|selected_id| selected_id == target_id) { + state.merge_target = None; + } } } - TagsMsg::CreateTag(name) => { + TagsMsg::ClearSelection => { + state.selected_tags.clear(); + state.merge_target = None; + state.editing_tag = None; + } + TagsMsg::CreateNameChanged(name) => { state.create_name = name; } + TagsMsg::CreateColorChanged(color) => { state.create_color = color; } + TagsMsg::CreateTag => { let mut created_editing = None; if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, &self.active_project) { - match engine::tag::create_tag(db.conn(), data_dir, &project.id, &name, None) { + match engine::tag::create_tag( + db.conn(), + data_dir, + &project.id, + &state.create_name, + if state.create_color.trim().is_empty() { + None + } else { + Some(state.create_color.trim()) + }, + ) { Ok(tag) => { created_editing = Some(tags_view::EditingTag { id: tag.id, + original_name: tag.name.clone(), name: tag.name, color: tag.color.unwrap_or_default(), template_slug: tag.post_template_slug.unwrap_or_default(), @@ -3248,25 +3821,46 @@ impl BdsApp { if let Some(editing_tag) = created_editing { self.reload_tags_state(); if let Some(state) = self.tags_view_state.as_mut() { + state.selected_tags = vec![editing_tag.id.clone()]; state.editing_tag = Some(editing_tag); + state.create_name.clear(); + state.create_color.clear(); + state.section = TagsSection::Manage; } self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); } } TagsMsg::EditTagName(s) => { if let Some(ref mut e) = state.editing_tag { e.name = s; } } TagsMsg::EditTagColor(s) => { if let Some(ref mut e) = state.editing_tag { e.color = s; } } - TagsMsg::EditTagTemplate(s) => { if let Some(ref mut e) = state.editing_tag { e.template_slug = s; } } + TagsMsg::EditTagTemplate(option) => { + if let Some(ref mut e) = state.editing_tag { + e.template_slug = option.slug; + } + } TagsMsg::SaveTag => { if let Some(editing) = state.editing_tag.clone() { if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) { - match engine::tag::update_tag( - db.conn(), - data_dir, - &editing.id, - Some(&editing.name), - Some(&editing.color), - Some(&editing.template_slug), - ) { + let rename_result = if editing.name != editing.original_name { + engine::tag::rename_tag( + db.conn(), + data_dir, + self.active_project.as_ref().map(|project| project.id.as_str()).unwrap_or_default(), + &editing.id, + &editing.name, + ) + } else { + Ok(()) + }; + match rename_result.and_then(|_| { + engine::tag::update_tag( + db.conn(), + data_dir, + &editing.id, + None, + Some(&editing.color), + Some(&editing.template_slug), + ) + }) { Ok(()) => { self.reload_tags_state(); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); @@ -3278,26 +3872,49 @@ impl BdsApp { } TagsMsg::DeleteTag(id) => { let name = state.tags.iter().find(|t| t.id == id).map(|t| t.name.clone()).unwrap_or_default(); + let count = state.tags.iter() + .find(|t| t.id == id) + .and_then(|tag| state.tag_post_counts.get(&tag.name.to_lowercase()).copied()) + .unwrap_or(0); return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete { entity_name: name, - references: Vec::new(), + references: vec![tw(self.ui_locale, "tags.deleteUsage", &[("count", &count.to_string())])], on_confirm: modal::ConfirmAction::DeleteTag(id), })); } - TagsMsg::SetMergeSource(s) => { state.merge_source = Some(s); } - TagsMsg::SetMergeTarget(s) => { state.merge_target = Some(s); } + TagsMsg::SetMergeTarget(option) => { state.merge_target = Some(option.id); } TagsMsg::MergeTags => { - if let (Some(source), Some(target)) = (&state.merge_source, &state.merge_target) { + if let Some(target) = &state.merge_target { + let target_name = state.tags.iter().find(|tag| &tag.id == target).map(|tag| tag.name.clone()).unwrap_or_default(); + let sources = state.selected_tags.iter().filter(|tag_id| *tag_id != target).cloned().collect::>(); return Task::done(Message::ShowModal(modal::ModalState::Confirm { - title: t(self.ui_locale, "tags.mergeTags"), - message: t(self.ui_locale, "tags.mergeConfirm"), + title: t(self.ui_locale, "tags.mergeConfirmTitle"), + message: tw( + self.ui_locale, + "tags.mergeConfirmMessage", + &[ + ("count", &sources.len().to_string()), + ("target", &target_name), + ], + ), on_confirm: modal::ConfirmAction::MergeTags { - source: source.clone(), + sources, target: target.clone(), }, })); } } + TagsMsg::SyncTags => { + if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, &self.active_project) { + match engine::tag::discover_tags(db.conn(), data_dir, &project.id) { + Ok(_) => { + self.reload_tags_state(); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Discover failed: {e}")), + } + } + } } Task::none() } @@ -3622,7 +4239,11 @@ impl BdsApp { let translations = bds_core::db::queries::media_translation::list_media_translations_by_media( db.conn(), &media.id, ).unwrap_or_default(); - self.media_editors.insert(media.id.clone(), MediaEditorState::from_media(&media, &self.blog_languages, &translations)); + let linked_posts = self.load_media_linked_posts(&media.id); + self.media_editors.insert( + media.id.clone(), + MediaEditorState::from_media(&media, &self.blog_languages, &translations, linked_posts), + ); } Err(e) => { self.notify(ToastLevel::Error, &format!("Failed to load media: {e}")); @@ -3697,6 +4318,14 @@ impl BdsApp { db.conn(), project_id, ).unwrap_or_default(); + let 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::>(); // Compute post counts per tag for cloud sizing let posts = bds_core::db::queries::post::list_posts_by_project( db.conn(), project_id, @@ -3707,7 +4336,7 @@ impl BdsApp { *tag_post_counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1; } } - self.tags_view_state = Some(TagsViewState::new(tags, tag_post_counts)); + self.tags_view_state = Some(TagsViewState::new(tags, tag_post_counts, template_options)); } } TabType::Settings => { diff --git a/crates/bds-ui/src/platform/menu.rs b/crates/bds-ui/src/platform/menu.rs index 65ed639..9adfec0 100644 --- a/crates/bds-ui/src/platform/menu.rs +++ b/crates/bds-ui/src/platform/menu.rs @@ -135,6 +135,11 @@ impl MenuRegistry { } } + #[cfg(test)] + pub(crate) fn empty() -> Self { + Self::new() + } + fn register(&mut self, action: MenuAction, item: &MenuItem) { self.action_map.insert(item.id().clone(), action); self.id_map.insert(action, item.id().clone()); diff --git a/crates/bds-ui/src/views/media_editor.rs b/crates/bds-ui/src/views/media_editor.rs index 962103e..a1bc9f3 100644 --- a/crates/bds-ui/src/views/media_editor.rs +++ b/crates/bds-ui/src/views/media_editor.rs @@ -13,6 +13,12 @@ use crate::components::inputs; use crate::i18n::t; use crate::views::post_editor::TranslationFlag; +#[derive(Debug, Clone)] +pub struct LinkedPostItem { + pub post_id: String, + pub title: String, +} + /// Saved draft content for a single media translation language. #[derive(Debug, Clone)] pub struct MediaTranslationDraft { @@ -49,10 +55,19 @@ pub struct MediaEditorState { pub blog_languages: Vec, pub saved_canonical: Option, pub translation_drafts: HashMap, + pub linked_posts: Vec, + pub post_picker_open: bool, + pub post_picker_search: String, + pub post_picker_results: Vec, } impl MediaEditorState { - pub fn from_media(media: &Media, blog_languages: &[String], translations: &[MediaTranslation]) -> Self { + pub fn from_media( + media: &Media, + blog_languages: &[String], + translations: &[MediaTranslation], + linked_posts: Vec, + ) -> Self { let canonical_lang = media.language.clone().unwrap_or_else(|| "en".to_string()); let mut translation_drafts = HashMap::new(); @@ -89,6 +104,10 @@ impl MediaEditorState { blog_languages: blog_languages.to_vec(), saved_canonical: None, translation_drafts, + linked_posts, + post_picker_open: false, + post_picker_search: String::new(), + post_picker_results: Vec::new(), } } @@ -174,6 +193,11 @@ pub enum MediaEditorMsg { LanguageChanged(String), TagsChanged(String), SwitchLanguage(String), + TogglePostPicker, + PostPickerSearchChanged(String), + LinkPost(String), + OpenLinkedPost(String), + UnlinkPost(String), Save, Delete, } @@ -308,6 +332,88 @@ pub fn view<'a>( let meta_row2 = row![caption_input, author_input].spacing(16).width(Length::Fill); let meta_row3 = row![tags_input, language_input].spacing(16).width(Length::Fill); + let linked_posts_header = row![ + text(t(locale, "editor.linkedPosts")) + .size(12) + .color(Color::from_rgb(0.55, 0.58, 0.65)), + Space::with_width(Length::Fill), + button(text(t(locale, "editor.linkToPost")).size(12)) + .on_press(Message::MediaEditor(MediaEditorMsg::TogglePostPicker)) + .padding([4, 10]), + ] + .align_y(iced::Alignment::Center) + .spacing(8); + + let post_picker: Element<'a, Message> = if state.post_picker_open { + let search = inputs::labeled_input( + &t(locale, "editor.postPickerSearch"), + &t(locale, "editor.postPickerSearchPlaceholder"), + &state.post_picker_search, + |s| Message::MediaEditor(MediaEditorMsg::PostPickerSearchChanged(s)), + ); + let results: Vec> = if state.post_picker_results.is_empty() { + vec![ + text(t(locale, "sidebar.filter.noResults")) + .size(12) + .color(Color::from_rgb(0.55, 0.58, 0.65)) + .into(), + ] + } else { + state + .post_picker_results + .iter() + .map(|post| { + button(text(post.title.clone()).size(12)) + .on_press(Message::MediaEditor(MediaEditorMsg::LinkPost(post.post_id.clone()))) + .padding([4, 10]) + .width(Length::Fill) + .into() + }) + .collect() + }; + container(column![search, column(results).spacing(4)].spacing(8).padding(8)) + .style(|_: &iced::Theme| iced::widget::container::Style { + background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))), + border: iced::Border { + color: Color::from_rgb(0.28, 0.28, 0.32), + width: 1.0, + radius: 6.0.into(), + }, + ..iced::widget::container::Style::default() + }) + .into() + } else { + Space::new(0, 0).into() + }; + + let linked_posts_list: Element<'a, Message> = if state.linked_posts.is_empty() { + text(t(locale, "editor.linkedPostsEmpty")) + .size(12) + .color(Color::from_rgb(0.55, 0.58, 0.65)) + .into() + } else { + let rows: Vec> = state + .linked_posts + .iter() + .map(|post| { + row![ + button(text(post.title.clone()).size(12)) + .on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost(post.post_id.clone()))) + .padding([4, 0]), + Space::with_width(Length::Fill), + button(text(t(locale, "editor.unlinkMedia")).size(11)) + .on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost(post.post_id.clone()))) + .padding([3, 8]) + .style(inputs::danger_button), + ] + .align_y(iced::Alignment::Center) + .spacing(8) + .into() + }) + .collect(); + column(rows).spacing(6).into() + }; + // Footer let footer = row![ inputs::date_label(&t(locale, "editor.createdAt"), state.created_at), @@ -326,6 +432,9 @@ pub fn view<'a>( meta_row1, meta_row2, meta_row3, + linked_posts_header, + post_picker, + linked_posts_list, footer, ] .spacing(12) diff --git a/crates/bds-ui/src/views/modal.rs b/crates/bds-ui/src/views/modal.rs index aaad555..eb96d28 100644 --- a/crates/bds-ui/src/views/modal.rs +++ b/crates/bds-ui/src/views/modal.rs @@ -76,8 +76,9 @@ pub enum ConfirmAction { DeleteMedia(String), DeleteScript(String), DeleteTemplate(String), + ForceDeleteTemplate(String), DeleteTag(String), - MergeTags { source: String, target: String }, + MergeTags { sources: Vec, target: String }, } // ── Modal backdrop style ── diff --git a/crates/bds-ui/src/views/panel.rs b/crates/bds-ui/src/views/panel.rs index 6148370..ed8ef7e 100644 --- a/crates/bds-ui/src/views/panel.rs +++ b/crates/bds-ui/src/views/panel.rs @@ -7,6 +7,8 @@ use bds_core::i18n::UiLocale; use crate::app::Message; use crate::i18n::t; use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot}; +use crate::state::tabs::{Tab, TabType}; +use crate::views::post_editor::ResolvedPostLink; /// Panel background style. fn panel_style(_theme: &Theme) -> container::Style { @@ -66,6 +68,8 @@ pub fn view( panel_tab: PanelTab, task_snapshots: &[TaskSnapshot], output_entries: &[OutputEntry], + post_outlinks: &[ResolvedPostLink], + post_backlinks: &[ResolvedPostLink], locale: UiLocale, active_tab_is_post: bool, active_tab_is_post_or_media: bool, @@ -183,10 +187,51 @@ pub fn view( } } PanelTab::PostLinks => { - // Post Links content populated in M3 (editor integration) - container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted)) - .padding(8) + if post_outlinks.is_empty() && post_backlinks.is_empty() { + container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted)) + .padding(8) + .into() + } else { + let mut items: Vec> = vec![ + text(t(locale, "editor.outlinks")) + .size(12) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.75, 0.77, 0.82)) + .into(), + ]; + + if post_outlinks.is_empty() { + items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into()); + } else { + for link in post_outlinks { + items.push(post_link_button(locale, link)); + } + } + + items.push(Space::with_height(8.0).into()); + items.push( + text(t(locale, "editor.backlinks")) + .size(12) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.75, 0.77, 0.82)) + .into(), + ); + + if post_backlinks.is_empty() { + items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into()); + } else { + for link in post_backlinks { + items.push(post_link_button(locale, link)); + } + } + + scrollable( + iced::widget::Column::with_children(items) + .spacing(4) + .padding(8), + ) .into() + } } PanelTab::GitLog => { // Git Log content populated in extension bucket A (git integration) @@ -204,3 +249,21 @@ pub fn view( .style(panel_style) .into() } + +fn post_link_button(locale: UiLocale, link: &ResolvedPostLink) -> Element<'static, Message> { + button(text(link.title.clone()).size(11).shaping(Shaping::Advanced)) + .on_press(Message::OpenTab(Tab { + id: link.post_id.clone(), + title: if link.title.is_empty() { + t(locale, "editor.untitled") + } else { + link.title.clone() + }, + tab_type: TabType::Post, + is_transient: false, + is_dirty: false, + })) + .padding([4, 8]) + .style(tab_inactive) + .into() +} diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 291c3f2..578c048 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -67,6 +67,7 @@ pub struct PostEditorState { pub updated_at: i64, pub published_at: Option, pub is_dirty: bool, + pub last_edit_at_ms: i64, pub metadata_expanded: bool, pub excerpt_expanded: bool, pub tags_input: String, @@ -120,6 +121,7 @@ impl Clone for PostEditorState { updated_at: self.updated_at, published_at: self.published_at, is_dirty: self.is_dirty, + last_edit_at_ms: self.last_edit_at_ms, metadata_expanded: self.metadata_expanded, excerpt_expanded: self.excerpt_expanded, tags_input: self.tags_input.clone(), @@ -180,6 +182,7 @@ impl PostEditorState { updated_at: post.updated_at, published_at: post.published_at, is_dirty: false, + last_edit_at_ms: 0, metadata_expanded: title.is_empty(), excerpt_expanded: false, tags_input: String::new(), @@ -196,6 +199,11 @@ impl PostEditorState { } } + pub fn mark_dirty(&mut self) { + self.is_dirty = true; + self.last_edit_at_ms = bds_core::util::now_unix_ms(); + } + pub fn insert_markdown_at_cursor(&mut self, markdown: &str) { let new_content = { let mut buffer = self.editor_buffer.borrow_mut(); @@ -203,7 +211,7 @@ impl PostEditorState { buffer.text() }; self.content = new_content; - self.is_dirty = true; + self.mark_dirty(); } /// Switch the editor to display a different language. diff --git a/crates/bds-ui/src/views/script_editor.rs b/crates/bds-ui/src/views/script_editor.rs index ccb8dbb..ed5794e 100644 --- a/crates/bds-ui/src/views/script_editor.rs +++ b/crates/bds-ui/src/views/script_editor.rs @@ -178,12 +178,18 @@ pub fn view<'a>( |k| Message::ScriptEditor(ScriptEditorMsg::KindChanged(k)), ); - // Entrypoint: show discovered functions as a select or text input - let entrypoint_input = inputs::labeled_input( + let mut entrypoint_options = state.discovered_entrypoints.clone(); + if !entrypoint_options.iter().any(|entrypoint| entrypoint == &state.entrypoint) { + entrypoint_options.push(state.entrypoint.clone()); + } + let selected_entrypoint = entrypoint_options + .iter() + .find(|entrypoint| *entrypoint == &state.entrypoint); + let entrypoint_input = inputs::labeled_select( &t(locale, "editor.entrypoint"), - "render", - &state.entrypoint, - |s| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(s)), + &entrypoint_options, + selected_entrypoint, + |entrypoint| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(entrypoint)), ); let enabled_check = inputs::labeled_checkbox( diff --git a/crates/bds-ui/src/views/tags_view.rs b/crates/bds-ui/src/views/tags_view.rs index c33f7cf..f7cd467 100644 --- a/crates/bds-ui/src/views/tags_view.rs +++ b/crates/bds-ui/src/views/tags_view.rs @@ -8,16 +8,22 @@ use bds_core::model::Tag; use crate::app::Message; use crate::components::inputs; -use crate::i18n::t; +use crate::i18n::{t, tw}; const DEFAULT_TAG_COLOR: &str = "#6495ed"; +const COLOR_PRESETS: [&str; 17] = [ + "#e63946", "#f77f00", "#fcbf49", "#90be6d", "#43aa8b", "#4d908e", + "#577590", "#277da1", "#4361ee", "#7209b7", "#b5179e", "#f72585", + "#9c6644", "#6c757d", "#adb5bd", "#2a9d8f", "#264653", +]; /// Active view within the Tags tab. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TagsSection { Cloud, Manage, Merge, + Discover, } /// State for the tags view. @@ -27,31 +33,64 @@ pub struct TagsViewState { pub tags: Vec, pub tag_post_counts: HashMap, pub search_query: String, - /// For "Manage": the currently editing tag + pub selected_tags: Vec, + pub create_name: String, + pub create_color: String, pub editing_tag: Option, - /// For "Merge": source and target tag selection - pub merge_source: Option, pub merge_target: Option, + pub template_options: Vec, } #[derive(Debug, Clone)] pub struct EditingTag { pub id: String, + pub original_name: String, pub name: String, pub color: String, pub template_slug: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TagOption { + pub id: String, + pub name: String, +} + +impl std::fmt::Display for TagOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TemplateOption { + pub slug: String, + pub label: String, +} + +impl std::fmt::Display for TemplateOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.label) + } +} + impl TagsViewState { - pub fn new(tags: Vec, tag_post_counts: HashMap) -> Self { + pub fn new( + tags: Vec, + tag_post_counts: HashMap, + template_options: Vec, + ) -> Self { Self { section: TagsSection::Cloud, tags, tag_post_counts, search_query: String::new(), + selected_tags: Vec::new(), + create_name: String::new(), + create_color: String::new(), editing_tag: None, - merge_source: None, merge_target: None, + template_options, } } } @@ -61,28 +100,28 @@ impl TagsViewState { pub enum TagsMsg { SetSection(TagsSection), SearchChanged(String), - SelectTag(String), - CreateTag(String), + ToggleTagSelection(String), + ClearSelection, + CreateNameChanged(String), + CreateColorChanged(String), + CreateTag, EditTagName(String), EditTagColor(String), - EditTagTemplate(String), + EditTagTemplate(TemplateOption), SaveTag, DeleteTag(String), - SetMergeSource(String), - SetMergeTarget(String), + SetMergeTarget(TagOption), MergeTags, + SyncTags, } /// Render the tags management view. -pub fn view<'a>( - state: &'a TagsViewState, - locale: UiLocale, -) -> Element<'a, Message> { - // Section navigation tabs +pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> { let section_nav = row![ section_tab(&t(locale, "tags.nav.cloud"), state.section == TagsSection::Cloud, TagsSection::Cloud), section_tab(&t(locale, "tags.nav.manage"), state.section == TagsSection::Manage, TagsSection::Manage), section_tab(&t(locale, "tags.nav.merge"), state.section == TagsSection::Merge, TagsSection::Merge), + section_tab(&t(locale, "tags.nav.discover"), state.section == TagsSection::Discover, TagsSection::Discover), ] .spacing(4) .padding(8); @@ -91,6 +130,7 @@ pub fn view<'a>( TagsSection::Cloud => view_cloud(state, locale), TagsSection::Manage => view_manage(state, locale), TagsSection::Merge => view_merge(state, locale), + TagsSection::Discover => view_discover(state, locale), }; column![section_nav, content] @@ -137,13 +177,18 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes .into(); } - // Calculate min/max post counts for font scaling - let counts: Vec = state.tags.iter() - .map(|t| *state.tag_post_counts.get(&t.name.to_lowercase()).unwrap_or(&0)) + let counts: Vec = state + .tags + .iter() + .map(|tag| *state.tag_post_counts.get(&tag.name.to_lowercase()).unwrap_or(&0)) .collect(); let min_count = *counts.iter().min().unwrap_or(&0); let max_count = *counts.iter().max().unwrap_or(&1); - let count_range = if max_count > min_count { (max_count - min_count) as f32 } else { 1.0 }; + let count_range = if max_count > min_count { + (max_count - min_count) as f32 + } else { + 1.0 + }; const MIN_FONT: f32 = 11.0; const MAX_FONT: f32 = 24.0; @@ -155,28 +200,56 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes let post_count = *state.tag_post_counts.get(&tag.name.to_lowercase()).unwrap_or(&0); let font_size = MIN_FONT + ((post_count - min_count) as f32 / count_range) * (MAX_FONT - MIN_FONT); let vert_pad = ((MAX_FONT - font_size) / 4.0).max(2.0) as u16; - button(text(&tag.name).size(font_size).color(Color::WHITE)) - .on_press(Message::Tags(TagsMsg::SelectTag(tag.id.clone()))) - .padding([vert_pad, 10]) - .style(move |_: &Theme, _| button::Style { - background: Some(Background::Color(color)), - border: iced::Border { - radius: 12.0.into(), - ..iced::Border::default() - }, - text_color: Color::WHITE, - ..button::Style::default() - }) - .into() + let selected = state.selected_tags.iter().any(|selected_id| selected_id == &tag.id); + button( + row![ + text(&tag.name).size(font_size).color(Color::WHITE), + text(post_count.to_string()).size(11).color(Color::from_rgba(1.0, 1.0, 1.0, 0.85)), + ] + .spacing(6) + .align_y(Alignment::Center), + ) + .on_press(Message::Tags(TagsMsg::ToggleTagSelection(tag.id.clone()))) + .padding([vert_pad, 10]) + .style(move |_: &Theme, _| button::Style { + background: Some(Background::Color(color)), + border: iced::Border { + radius: 12.0.into(), + width: if selected { 2.0 } else { 0.0 }, + color: if selected { Color::WHITE } else { Color::TRANSPARENT }, + }, + text_color: Color::WHITE, + ..button::Style::default() + }) + .into() }) .collect(); - let cloud = row(chips).spacing(6).wrap(); + let selection_summary: Element<'a, Message> = if state.selected_tags.is_empty() { + text(t(locale, "tags.cloudHelp")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)).into() + } else { + row![ + text(tw(locale, "tags.selectedCount", &[("count", &state.selected_tags.len().to_string())])) + .size(12) + .color(Color::from_rgb(0.75, 0.77, 0.82)), + button(text(t(locale, "tags.clearSelection")).size(12)) + .on_press(Message::Tags(TagsMsg::ClearSelection)) + .padding([4, 8]), + ] + .spacing(8) + .align_y(Alignment::Center) + .into() + }; scrollable( - container(cloud) - .width(Length::Fill) - .padding(16), + column![ + inputs::section_header(&t(locale, "tags.cloudSection")), + selection_summary, + row(chips).spacing(6).wrap(), + ] + .spacing(12) + .width(Length::Fill) + .padding(16), ) .width(Length::Fill) .height(Length::Fill) @@ -184,16 +257,38 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes } fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> { + let create_panel = column![ + inputs::section_header(&t(locale, "tags.createSection")), + inputs::labeled_input( + &t(locale, "tags.name"), + &t(locale, "tags.createPlaceholder"), + &state.create_name, + |value| Message::Tags(TagsMsg::CreateNameChanged(value)), + ), + inputs::labeled_input( + &t(locale, "tags.color"), + DEFAULT_TAG_COLOR, + &state.create_color, + |value| Message::Tags(TagsMsg::CreateColorChanged(value)), + ), + color_swatches(locale, true), + button(text(t(locale, "tags.createButton")).size(13)) + .on_press_maybe((!state.create_name.trim().is_empty()).then_some(Message::Tags(TagsMsg::CreateTag))) + .style(inputs::primary_button) + .padding([6, 16]), + ] + .spacing(8); + let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query) - .on_input(|s| Message::Tags(TagsMsg::SearchChanged(s))) + .on_input(|value| Message::Tags(TagsMsg::SearchChanged(value))) .size(14); let filtered: Vec<&Tag> = state .tags .iter() - .filter(|t| { + .filter(|tag| { state.search_query.is_empty() - || t.name.to_lowercase().contains(&state.search_query.to_lowercase()) + || tag.name.to_lowercase().contains(&state.search_query.to_lowercase()) }) .collect(); @@ -201,9 +296,11 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me .iter() .map(|tag| { let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR)); - row![ - container(Space::new(12, 12)) - .style(move |_: &Theme| container::Style { + let selected = state.selected_tags.iter().any(|selected_id| selected_id == &tag.id); + let count = state.tag_post_counts.get(&tag.name.to_lowercase()).copied().unwrap_or(0); + button( + row![ + container(Space::new(12, 12)).style(move |_: &Theme| container::Style { background: Some(Background::Color(color)), border: iced::Border { radius: 6.0.into(), @@ -211,65 +308,92 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me }, ..container::Style::default() }), - text(&tag.name).size(14), - Space::with_width(Length::Fill), - button(text(t(locale, "modal.confirmDelete.delete")).size(12)) - .on_press(Message::Tags(TagsMsg::DeleteTag(tag.id.clone()))) - .style(inputs::danger_button) - .padding([3, 8]) - ] - .spacing(8) - .align_y(Alignment::Center) - .padding(4) + text(&tag.name).size(14), + text(format!("({count})")).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)), + Space::with_width(Length::Fill), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .on_press(Message::Tags(TagsMsg::ToggleTagSelection(tag.id.clone()))) + .padding([6, 8]) + .style(move |_theme: &Theme, _status| button::Style { + background: Some(Background::Color(if selected { + Color::from_rgb(0.22, 0.24, 0.30) + } else { + Color::TRANSPARENT + })), + border: iced::Border { + radius: 6.0.into(), + width: if selected { 1.0 } else { 0.0 }, + color: Color::from_rgb(0.35, 0.45, 0.65), + }, + ..button::Style::default() + }) .into() }) .collect(); - let tag_list = iced::widget::Column::with_children(rows).spacing(2); + let tag_list = iced::widget::Column::with_children(rows).spacing(4); - // Edit panel for selected tag let edit_panel: Element<'a, Message> = if let Some(ref editing) = state.editing_tag { + let template_options = template_options(state, locale); + let selected_template = template_options.iter().find(|option| option.slug == editing.template_slug); + let delete_button: Element<'a, Message> = button(text(t(locale, "modal.confirmDelete.delete")).size(13)) + .on_press(Message::Tags(TagsMsg::DeleteTag(editing.id.clone()))) + .style(inputs::danger_button) + .padding([6, 16]) + .into(); + column![ inputs::section_header(&t(locale, "tags.editTag")), inputs::labeled_input( &t(locale, "tags.name"), "", &editing.name, - |s| Message::Tags(TagsMsg::EditTagName(s)), + |value| Message::Tags(TagsMsg::EditTagName(value)), ), inputs::labeled_input( &t(locale, "tags.color"), - "#3498db", + DEFAULT_TAG_COLOR, &editing.color, - |s| Message::Tags(TagsMsg::EditTagColor(s)), + |value| Message::Tags(TagsMsg::EditTagColor(value)), ), - inputs::labeled_input( + color_swatches(locale, false), + inputs::labeled_select( &t(locale, "tags.postTemplate"), - "", - &editing.template_slug, - |s| Message::Tags(TagsMsg::EditTagTemplate(s)), + &template_options, + selected_template, + |choice| Message::Tags(TagsMsg::EditTagTemplate(choice)), ), - button(text(t(locale, "common.save")).size(13)) - .on_press(Message::Tags(TagsMsg::SaveTag)) - .style(inputs::primary_button) - .padding([6, 16]), + row![ + button(text(t(locale, "common.save")).size(13)) + .on_press(Message::Tags(TagsMsg::SaveTag)) + .style(inputs::primary_button) + .padding([6, 16]), + delete_button, + ] + .spacing(8), ] .spacing(8) - .padding(12) .into() } else { - Space::new(0, 0).into() + container(text(t(locale, "tags.selectTag")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65))) + .padding([8, 0]) + .into() }; scrollable( column![ + create_panel, + inputs::section_header(&t(locale, "tags.manageSection")), search, tag_list, edit_panel, ] .spacing(12) .padding(16) - .width(Length::Fill) + .width(Length::Fill), ) .width(Length::Fill) .height(Length::Fill) @@ -277,35 +401,53 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me } fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> { - let source_input = inputs::labeled_input( - &t(locale, "tags.mergeSource"), - &t(locale, "tags.selectTag"), - state.merge_source.as_deref().unwrap_or(""), - |s| Message::Tags(TagsMsg::SetMergeSource(s)), - ); - let target_input = inputs::labeled_input( - &t(locale, "tags.mergeTarget"), - &t(locale, "tags.selectTag"), - state.merge_target.as_deref().unwrap_or(""), - |s| Message::Tags(TagsMsg::SetMergeTarget(s)), - ); + if state.selected_tags.len() < 2 { + return container(text(t(locale, "tags.mergeHelp")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65))) + .padding(16) + .into(); + } - let can_merge = state.merge_source.is_some() && state.merge_target.is_some(); + let tag_options = selected_tag_options(state); + let selected_target = state + .merge_target + .as_ref() + .and_then(|target_id| tag_options.iter().find(|option| &option.id == target_id)); - let merge_btn = if can_merge { - button(text(t(locale, "tags.merge")).size(13)) - .on_press(Message::Tags(TagsMsg::MergeTags)) - .style(inputs::primary_button) - .padding([6, 16]) - } else { - button(text(t(locale, "tags.merge")).size(13)) - .padding([6, 16]) - }; + let merge_preview = state + .merge_target + .as_ref() + .map(|target_id| { + selected_tag_options(state) + .into_iter() + .filter(|option| &option.id != target_id) + .map(|option| option.name) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); column![ - source_input, - target_input, - merge_btn, + inputs::section_header(&t(locale, "tags.mergeSection")), + text(tw(locale, "tags.selectedCount", &[("count", &state.selected_tags.len().to_string())])) + .size(12) + .color(Color::from_rgb(0.75, 0.77, 0.82)), + inputs::labeled_select( + &t(locale, "tags.mergeTarget"), + &tag_options, + selected_target, + |choice| Message::Tags(TagsMsg::SetMergeTarget(choice)), + ), + if merge_preview.is_empty() { + Element::from(Space::new(0, 0)) + } else { + container(text(tw(locale, "tags.mergePreview", &[("tags", &merge_preview)])).size(12)) + .padding([4, 0]) + .into() + }, + button(text(t(locale, "tags.merge")).size(13)) + .on_press_maybe(state.merge_target.is_some().then_some(Message::Tags(TagsMsg::MergeTags))) + .style(inputs::primary_button) + .padding([6, 16]), ] .spacing(12) .padding(16) @@ -313,6 +455,81 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes .into() } +fn view_discover<'a>(_state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> { + column![ + inputs::section_header(&t(locale, "tags.discoverSection")), + text(t(locale, "tags.discoverDescription")) + .size(12) + .color(Color::from_rgb(0.60, 0.60, 0.65)), + button(text(t(locale, "tags.discoverButton")).size(13)) + .on_press(Message::Tags(TagsMsg::SyncTags)) + .style(inputs::primary_button) + .padding([6, 16]), + ] + .spacing(12) + .padding(16) + .width(Length::Fill) + .into() +} + +fn template_options(state: &TagsViewState, locale: UiLocale) -> Vec { + let mut options = vec![TemplateOption { + slug: String::new(), + label: t(locale, "tags.noTemplate"), + }]; + options.extend(state.template_options.iter().map(|slug| TemplateOption { + slug: slug.clone(), + label: slug.clone(), + })); + options +} + +fn selected_tag_options(state: &TagsViewState) -> Vec { + state + .selected_tags + .iter() + .filter_map(|tag_id| { + state.tags.iter().find(|tag| &tag.id == tag_id).map(|tag| TagOption { + id: tag.id.clone(), + name: tag.name.clone(), + }) + }) + .collect() +} + +fn color_swatches<'a>(locale: UiLocale, create_mode: bool) -> Element<'a, Message> { + let buttons: Vec> = COLOR_PRESETS + .iter() + .map(|hex| { + let color = parse_tag_color(hex); + let msg = if create_mode { + TagsMsg::CreateColorChanged((*hex).to_string()) + } else { + TagsMsg::EditTagColor((*hex).to_string()) + }; + button(Space::new(18, 18)) + .on_press(Message::Tags(msg)) + .padding(0) + .style(move |_theme: &Theme, _status| button::Style { + background: Some(Background::Color(color)), + border: iced::Border { + radius: 9.0.into(), + ..iced::Border::default() + }, + ..button::Style::default() + }) + .into() + }) + .collect(); + + column![ + text(t(locale, "tags.colorPresets")).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)), + row(buttons).spacing(6).wrap(), + ] + .spacing(6) + .into() +} + fn parse_tag_color(hex: &str) -> Color { let hex = hex.trim_start_matches('#'); if hex.len() == 6 { @@ -323,4 +540,4 @@ fn parse_tag_color(hex: &str) -> Color { } else { Color::from_rgb(0.4, 0.5, 0.7) } -} +} \ No newline at end of file diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index c1b1436..b1553c3 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -141,12 +141,18 @@ pub fn view<'a>( let active_tab_is_post = active_tab_type == Some(&TabType::Post); let active_tab_is_post_or_media = active_tab_is_post || active_tab_type == Some(&TabType::Media); + let (post_outlinks, post_backlinks) = active_tab + .and_then(|id| post_editors.get(id)) + .map(|editor| (editor.outlinks.as_slice(), editor.backlinks.as_slice())) + .unwrap_or((&[], &[])); right_col = right_col.push(separator_h()); right_col = right_col.push(panel::view( panel_tab, task_snapshots, output_entries, + post_outlinks, + post_backlinks, locale, active_tab_is_post, active_tab_is_post_or_media, diff --git a/locales/ui/de.json b/locales/ui/de.json index 746b56c..d07fd79 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -159,6 +159,7 @@ "tags.nav.cloud": "Tag-Wolke", "tags.nav.manage": "Tags verwalten", "tags.nav.merge": "Tags zusammenführen", + "tags.nav.discover": "Tags entdecken", "sidebar.chatYesterday": "Gestern", "modal.confirmDelete.title": "Löschen bestätigen", "modal.confirmDelete.warning": "Diese Aktion kann nicht rückgängig gemacht werden.", @@ -236,6 +237,11 @@ "editor.linkedMedia": "Verknüpfte Medien", "editor.linkedMediaEmpty": "Noch keine verknüpften Medien.", "editor.linkExistingMedia": "Vorhandenes verknüpfen", + "editor.linkToPost": "Mit Beitrag verknüpfen", + "editor.linkedPosts": "Verknüpfte Beiträge", + "editor.linkedPostsEmpty": "Mit keinen Beiträgen verknüpft.", + "editor.postPickerSearch": "Beiträge suchen", + "editor.postPickerSearchPlaceholder": "Beiträge suchen...", "editor.unlinkMedia": "Verknüpfung lösen", "editor.linkedMediaKindImage": "Bild", "editor.linkedMediaKindFile": "Datei", @@ -244,14 +250,34 @@ "editor.updatedAt": "Aktualisiert", "editor.publishedAt": "Veröffentlicht", "tags.noTags": "Noch keine Tags", + "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", + "tags.clearSelection": "Auswahl aufheben", + "tags.createSection": "Tag erstellen", + "tags.createPlaceholder": "neues-tag", + "tags.createButton": "Erstellen", "tags.editTag": "Tag bearbeiten", + "tags.manageSection": "Verwalten", "tags.name": "Name", "tags.color": "Farbe", + "tags.colorPresets": "Farbvorgaben", "tags.postTemplate": "Beitragsvorlage", + "tags.noTemplate": "Keine", "tags.merge": "Zusammenführen", - "tags.mergeSource": "Quell-Tag", "tags.mergeTarget": "Ziel-Tag", + "tags.mergeSection": "Zusammenführen", + "tags.mergeHelp": "Wählen Sie mindestens zwei Tags aus der Wolke oder Liste aus, um sie zusammenzuführen.", + "tags.mergePreview": "Zu löschende Tags: {tags}", + "tags.mergeConfirmTitle": "Tags zusammenführen", + "tags.mergeConfirmMessage": "{count} Tags in {target} zusammenführen? Dies kann nicht rückgängig gemacht werden.", + "tags.deleteUsage": "Dieses Tag wird in {count} Beiträgen verwendet.", + "tags.discoverSection": "Entdecken", + "tags.discoverDescription": "Tags aus aktuellen Beiträgen entdecken und tags.json passend zur Datenbank neu schreiben.", + "tags.discoverButton": "Entdecken", "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?", "settings.projectName": "Projektname", "settings.projectDescription": "Beschreibung", "settings.dataPath": "Datenordner", diff --git a/locales/ui/en.json b/locales/ui/en.json index 6c3d5e8..0dc2067 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -159,6 +159,7 @@ "tags.nav.cloud": "Tag Cloud", "tags.nav.manage": "Manage Tags", "tags.nav.merge": "Merge Tags", + "tags.nav.discover": "Discover Tags", "sidebar.chatYesterday": "Yesterday", "modal.confirmDelete.title": "Confirm Delete", "modal.confirmDelete.warning": "This action cannot be undone.", @@ -233,6 +234,11 @@ "editor.linkedMedia": "Linked Media", "editor.linkedMediaEmpty": "No linked media yet.", "editor.linkExistingMedia": "Link Existing", + "editor.linkToPost": "Link to Post", + "editor.linkedPosts": "Linked Posts", + "editor.linkedPostsEmpty": "Not linked to any posts.", + "editor.postPickerSearch": "Search Posts", + "editor.postPickerSearchPlaceholder": "Search posts...", "editor.unlinkMedia": "Unlink", "editor.linkedMediaKindImage": "Image", "editor.linkedMediaKindFile": "File", @@ -244,14 +250,34 @@ "editor.insertMedia": "Insert Media", "editor.gallery": "Gallery", "tags.noTags": "No tags yet", + "tags.cloudSection": "Cloud", + "tags.cloudHelp": "Select one or more tags to edit, delete, or merge them.", + "tags.selectedCount": "{count} selected", + "tags.clearSelection": "Clear selection", + "tags.createSection": "Create Tag", + "tags.createPlaceholder": "new-tag", + "tags.createButton": "Create", "tags.editTag": "Edit Tag", + "tags.manageSection": "Manage", "tags.name": "Name", "tags.color": "Color", + "tags.colorPresets": "Color presets", "tags.postTemplate": "Post Template", + "tags.noTemplate": "None", "tags.merge": "Merge", - "tags.mergeSource": "Source Tag", "tags.mergeTarget": "Target Tag", + "tags.mergeSection": "Merge", + "tags.mergeHelp": "Select at least two tags from the cloud or list to merge them.", + "tags.mergePreview": "Tags to delete: {tags}", + "tags.mergeConfirmTitle": "Merge Tags", + "tags.mergeConfirmMessage": "Merge {count} tags into {target}? This cannot be undone.", + "tags.deleteUsage": "This tag is used in {count} posts.", + "tags.discoverSection": "Discover", + "tags.discoverDescription": "Discover tags from current posts and rewrite tags.json to match the database.", + "tags.discoverButton": "Discover", "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?", "settings.projectName": "Project Name", "settings.projectDescription": "Description", "settings.dataPath": "Data Folder", diff --git a/locales/ui/es.json b/locales/ui/es.json index 5669b96..2a8700f 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -159,6 +159,7 @@ "tags.nav.cloud": "Nube de etiquetas", "tags.nav.manage": "Gestionar etiquetas", "tags.nav.merge": "Fusionar etiquetas", + "tags.nav.discover": "Descubrir etiquetas", "sidebar.chatYesterday": "Ayer", "modal.confirmDelete.title": "Confirmar eliminación", "modal.confirmDelete.warning": "Esta acción no se puede deshacer.", @@ -236,6 +237,11 @@ "editor.linkedMedia": "Medios vinculados", "editor.linkedMediaEmpty": "Todavía no hay medios vinculados.", "editor.linkExistingMedia": "Vincular existente", + "editor.linkToPost": "Vincular a artículo", + "editor.linkedPosts": "Artículos vinculados", + "editor.linkedPostsEmpty": "No está vinculado a ningún artículo.", + "editor.postPickerSearch": "Buscar artículos", + "editor.postPickerSearchPlaceholder": "Buscar artículos...", "editor.unlinkMedia": "Desvincular", "editor.linkedMediaKindImage": "Imagen", "editor.linkedMediaKindFile": "Archivo", @@ -244,14 +250,34 @@ "editor.updatedAt": "Actualizado", "editor.publishedAt": "Publicado", "tags.noTags": "Sin etiquetas", + "tags.cloudSection": "Nube", + "tags.cloudHelp": "Selecciona una o más etiquetas para editarlas, eliminarlas o fusionarlas.", + "tags.selectedCount": "{count} seleccionadas", + "tags.clearSelection": "Borrar selección", + "tags.createSection": "Crear etiqueta", + "tags.createPlaceholder": "nueva-etiqueta", + "tags.createButton": "Crear", "tags.editTag": "Editar etiqueta", + "tags.manageSection": "Gestionar", "tags.name": "Nombre", "tags.color": "Color", + "tags.colorPresets": "Colores predefinidos", "tags.postTemplate": "Plantilla de artículo", + "tags.noTemplate": "Ninguna", "tags.merge": "Fusionar", - "tags.mergeSource": "Etiqueta origen", "tags.mergeTarget": "Etiqueta destino", + "tags.mergeSection": "Fusionar", + "tags.mergeHelp": "Selecciona al menos dos etiquetas de la nube o la lista para fusionarlas.", + "tags.mergePreview": "Etiquetas para eliminar: {tags}", + "tags.mergeConfirmTitle": "Fusionar etiquetas", + "tags.mergeConfirmMessage": "¿Fusionar {count} etiquetas en {target}? Esta acción no se puede deshacer.", + "tags.deleteUsage": "Esta etiqueta se usa en {count} publicaciones.", + "tags.discoverSection": "Descubrir", + "tags.discoverDescription": "Descubre etiquetas a partir de las publicaciones actuales y reescribe tags.json para reflejar la base de datos.", + "tags.discoverButton": "Descubrir", "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?", "settings.projectName": "Nombre del proyecto", "settings.projectDescription": "Descripción", "settings.dataPath": "Carpeta de datos", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index be27b57..7007b95 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -159,6 +159,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", "sidebar.chatYesterday": "Hier", "modal.confirmDelete.title": "Confirmer la suppression", "modal.confirmDelete.warning": "Cette action est irréversible.", @@ -236,6 +237,11 @@ "editor.linkedMedia": "Médias liés", "editor.linkedMediaEmpty": "Aucun média lié pour le moment.", "editor.linkExistingMedia": "Lier un existant", + "editor.linkToPost": "Lier à un article", + "editor.linkedPosts": "Articles liés", + "editor.linkedPostsEmpty": "Lié à aucun article.", + "editor.postPickerSearch": "Rechercher des articles", + "editor.postPickerSearchPlaceholder": "Rechercher des articles...", "editor.unlinkMedia": "Dissocier", "editor.linkedMediaKindImage": "Image", "editor.linkedMediaKindFile": "Fichier", @@ -244,14 +250,34 @@ "editor.updatedAt": "Mis à jour", "editor.publishedAt": "Publié", "tags.noTags": "Aucun tag", + "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", + "tags.clearSelection": "Effacer la sélection", + "tags.createSection": "Créer un tag", + "tags.createPlaceholder": "nouveau-tag", + "tags.createButton": "Créer", "tags.editTag": "Modifier le tag", + "tags.manageSection": "Gérer", "tags.name": "Nom", "tags.color": "Couleur", + "tags.colorPresets": "Couleurs prédéfinies", "tags.postTemplate": "Modèle d'article", + "tags.noTemplate": "Aucun", "tags.merge": "Fusionner", - "tags.mergeSource": "Tag source", "tags.mergeTarget": "Tag cible", + "tags.mergeSection": "Fusionner", + "tags.mergeHelp": "Sélectionnez au moins deux tags dans le nuage ou la liste pour les fusionner.", + "tags.mergePreview": "Tags à supprimer : {tags}", + "tags.mergeConfirmTitle": "Fusionner les tags", + "tags.mergeConfirmMessage": "Fusionner {count} tags dans {target} ? Cette action est irréversible.", + "tags.deleteUsage": "Ce tag est utilisé dans {count} articles.", + "tags.discoverSection": "Découvrir", + "tags.discoverDescription": "Découvrir les tags à partir des articles actuels et réécrire tags.json pour refléter la base de données.", + "tags.discoverButton": "Découvrir", "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 ?", "settings.projectName": "Nom du projet", "settings.projectDescription": "Description", "settings.dataPath": "Dossier de données", diff --git a/locales/ui/it.json b/locales/ui/it.json index 5c97e78..1bdeb4d 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -159,6 +159,7 @@ "tags.nav.cloud": "Nuvola di tag", "tags.nav.manage": "Gestisci tag", "tags.nav.merge": "Unisci tag", + "tags.nav.discover": "Scopri tag", "sidebar.chatYesterday": "Ieri", "modal.confirmDelete.title": "Conferma eliminazione", "modal.confirmDelete.warning": "Questa azione non può essere annullata.", @@ -236,6 +237,11 @@ "editor.linkedMedia": "Media collegati", "editor.linkedMediaEmpty": "Nessun media collegato.", "editor.linkExistingMedia": "Collega esistente", + "editor.linkToPost": "Collega a articolo", + "editor.linkedPosts": "Articoli collegati", + "editor.linkedPostsEmpty": "Non collegato ad alcun articolo.", + "editor.postPickerSearch": "Cerca articoli", + "editor.postPickerSearchPlaceholder": "Cerca articoli...", "editor.unlinkMedia": "Scollega", "editor.linkedMediaKindImage": "Immagine", "editor.linkedMediaKindFile": "File", @@ -244,14 +250,34 @@ "editor.updatedAt": "Aggiornato", "editor.publishedAt": "Pubblicato", "tags.noTags": "Nessun tag", + "tags.cloudSection": "Nuvola", + "tags.cloudHelp": "Seleziona uno o più tag per modificarli, eliminarli o unirli.", + "tags.selectedCount": "{count} selezionati", + "tags.clearSelection": "Cancella selezione", + "tags.createSection": "Crea tag", + "tags.createPlaceholder": "nuovo-tag", + "tags.createButton": "Crea", "tags.editTag": "Modifica tag", + "tags.manageSection": "Gestisci", "tags.name": "Nome", "tags.color": "Colore", + "tags.colorPresets": "Colori predefiniti", "tags.postTemplate": "Modello articolo", + "tags.noTemplate": "Nessuno", "tags.merge": "Unisci", - "tags.mergeSource": "Tag sorgente", "tags.mergeTarget": "Tag destinazione", + "tags.mergeSection": "Unisci", + "tags.mergeHelp": "Seleziona almeno due tag dalla nuvola o dall'elenco per unirli.", + "tags.mergePreview": "Tag da eliminare: {tags}", + "tags.mergeConfirmTitle": "Unisci tag", + "tags.mergeConfirmMessage": "Unire {count} tag in {target}? Questa azione non può essere annullata.", + "tags.deleteUsage": "Questo tag è usato in {count} post.", + "tags.discoverSection": "Scopri", + "tags.discoverDescription": "Scopri i tag dai post correnti e riscrivi tags.json per riflettere il database.", + "tags.discoverButton": "Scopri", "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?", "settings.projectName": "Nome progetto", "settings.projectDescription": "Descrizione", "settings.dataPath": "Cartella dati",