diff --git a/crates/bds-core/src/engine/rebuild.rs b/crates/bds-core/src/engine/rebuild.rs index bf16a62..1829610 100644 --- a/crates/bds-core/src/engine/rebuild.rs +++ b/crates/bds-core/src/engine/rebuild.rs @@ -113,7 +113,7 @@ pub fn rebuild_from_filesystem_with_progress( report.templates_updated = tpl_report.updated; report.errors.extend(tpl_report.errors); - // 5. Rebuild scripts (0.85 .. 1.0) + // 5. Rebuild scripts (0.85 .. 0.95) progress(0.85, "Rebuilding scripts..."); let script_report = script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?; @@ -121,6 +121,11 @@ pub fn rebuild_from_filesystem_with_progress( report.scripts_updated = script_report.updated; report.errors.extend(script_report.errors); + // 6. Import tags from tags.json and sync from posts (0.95 .. 1.0) + progress(0.95, "Importing tags..."); + let _ = super::tag::import_tags_from_file(conn, data_dir, project_id); + let _ = super::tag::sync_tags_from_posts(conn, project_id); + progress(1.0, "Rebuild complete"); Ok(report) } diff --git a/crates/bds-core/src/engine/tag.rs b/crates/bds-core/src/engine/tag.rs index 49a7236..a5d089d 100644 --- a/crates/bds-core/src/engine/tag.rs +++ b/crates/bds-core/src/engine/tag.rs @@ -188,11 +188,61 @@ pub fn merge_tags( Ok(()) } -/// Sync tags from all posts: collect unique tag names, create missing tags in DB. -pub fn sync_tags_from_posts( +/// Import tags from meta/tags.json into DB, preserving colors and properties. +/// Creates new tags or updates existing ones with file-based properties. +pub fn import_tags_from_file( conn: &Connection, data_dir: &Path, project_id: &str, +) -> EngineResult<()> { + let entries = match meta::read_tags_json(data_dir) { + Ok(entries) => entries, + Err(_) => return Ok(()), // File doesn't exist or is invalid — nothing to import + }; + + let now = now_unix_ms(); + for entry in &entries { + let name = entry.name.trim(); + if name.is_empty() { + continue; + } + match tag_q::get_tag_by_project_and_name(conn, project_id, name) { + Ok(existing) => { + // Update color/post_template_slug from file if provided + if entry.color.is_some() || entry.post_template_slug.is_some() { + let mut updated = existing; + if entry.color.is_some() { + updated.color = entry.color.clone(); + } + if entry.post_template_slug.is_some() { + updated.post_template_slug = entry.post_template_slug.clone(); + } + updated.updated_at = now; + tag_q::update_tag(conn, &updated)?; + } + } + Err(_) => { + let tag = Tag { + id: Uuid::new_v4().to_string(), + project_id: project_id.to_string(), + name: name.to_string(), + color: entry.color.clone(), + post_template_slug: entry.post_template_slug.clone(), + created_at: now, + updated_at: now, + }; + tag_q::insert_tag(conn, &tag)?; + } + } + } + Ok(()) +} + +/// Sync tags from all posts: collect unique tag names, create missing tags in DB. +/// This is additive only — it does NOT rewrite tags.json. +pub fn sync_tags_from_posts( + conn: &Connection, + project_id: &str, ) -> EngineResult> { let posts = post_q::list_posts_by_project(conn, project_id)?; @@ -225,7 +275,6 @@ pub fn sync_tags_from_posts( } } - rewrite_tags_json(conn, data_dir, project_id)?; let all_tags = tag_q::list_tags_by_project(conn, project_id)?; Ok(all_tags) } @@ -483,15 +532,32 @@ mod tests { ) .unwrap(); - let tags = sync_tags_from_posts(db.conn(), dir.path(), "p1").unwrap(); + let tags = sync_tags_from_posts(db.conn(), "p1").unwrap(); assert_eq!(tags.len(), 3); let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); assert!(names.contains(&"go")); assert!(names.contains(&"rust")); assert!(names.contains(&"web")); + } - let entries = meta::read_tags_json(dir.path()).unwrap(); - assert_eq!(entries.len(), 3); + #[test] + fn import_tags_from_file_preserves_colors() { + let (db, dir) = setup(); + // Write tags.json with colors + let entries = vec![ + TagEntry { name: "rust".into(), color: Some("#ff0000".into()), post_template_slug: None }, + TagEntry { name: "web".into(), color: None, post_template_slug: Some("blog".into()) }, + ]; + meta::write_tags_json(dir.path(), &entries).unwrap(); + + import_tags_from_file(db.conn(), dir.path(), "p1").unwrap(); + + let tags = tag_q::list_tags_by_project(db.conn(), "p1").unwrap(); + assert_eq!(tags.len(), 2); + let rust_tag = tags.iter().find(|t| t.name == "rust").unwrap(); + assert_eq!(rust_tag.color.as_deref(), Some("#ff0000")); + let web_tag = tags.iter().find(|t| t.name == "web").unwrap(); + assert_eq!(web_tag.post_template_slug.as_deref(), Some("blog")); } #[test] diff --git a/crates/bds-editor/src/widget.rs b/crates/bds-editor/src/widget.rs index 0f17261..0f1e0a0 100644 --- a/crates/bds-editor/src/widget.rs +++ b/crates/bds-editor/src/widget.rs @@ -95,12 +95,49 @@ fn syntect_to_iced(c: syntect::highlighting::Color) -> Color { ) } +/// Map a visual row index (from scroll top) to (logical_line, char_offset). +/// Returns None if beyond end of buffer. +fn visual_to_logical( + buf: &EditorBuffer, + scroll: usize, + visual_row: usize, + chars_per_visual_line: usize, +) -> Option<(usize, usize)> { + if chars_per_visual_line == 0 { + // No wrapping — direct 1:1 mapping + let line = scroll + visual_row; + return if line < buf.line_count() { Some((line, 0)) } else { None }; + } + let target = scroll + visual_row; + let mut vis_count = 0usize; + for line_idx in 0..buf.line_count() { + let line_chars = buf.line(line_idx) + .map(|l| { + let n = l.len_chars(); + if n > 0 && l.char(n - 1) == '\n' { n - 1 } else { n } + }) + .unwrap_or(0); + let wrap_count = if line_chars > chars_per_visual_line { + (line_chars + chars_per_visual_line - 1) / chars_per_visual_line + } else { + 1 + }; + if vis_count + wrap_count > target { + let sub = target - vis_count; + return Some((line_idx, sub * chars_per_visual_line)); + } + vis_count += wrap_count; + } + None +} + /// A syntax-highlighting code editor widget for Iced. pub struct CodeEditor<'a, Message> { buffer: &'a RefCell, highlighter: &'a Highlighter, extension: &'a str, on_change: Option Message + 'a>>, + word_wrap: bool, } impl<'a, Message> CodeEditor<'a, Message> { @@ -114,6 +151,7 @@ impl<'a, Message> CodeEditor<'a, Message> { highlighter, extension, on_change: None, + word_wrap: false, } } @@ -121,6 +159,11 @@ impl<'a, Message> CodeEditor<'a, Message> { self.on_change = Some(Box::new(f)); self } + + pub fn word_wrap(mut self, enabled: bool) -> Self { + self.word_wrap = enabled; + self + } } impl<'a, Message, Renderer> Widget for CodeEditor<'a, Message> @@ -200,22 +243,53 @@ where let highlighted = self.highlighter.highlight_lines(&full_text, syntax); let font = iced::Font::MONOSPACE; + let text_area_width = bounds.width - GUTTER_WIDTH - 8.0; + let chars_per_visual_line = if self.word_wrap && text_area_width > metrics.char_width { + (text_area_width / metrics.char_width).floor() as usize + } else { + 0 // 0 = no wrapping + }; - // Render visible lines - for vis_idx in 0..visible_lines { - let line_idx = scroll + vis_idx; - if line_idx >= buf.line_count() { - break; + // Build visual line map: skip 'scroll' visual lines, then render 'visible_lines' visual lines. + // Each entry: (logical_line, char_offset, is_first_visual_line) + let mut visual_rows: Vec<(usize, usize, bool)> = Vec::new(); + let mut vis_skip = 0usize; // visual lines to skip for scroll offset + let mut line_idx = 0usize; + + while visual_rows.len() < visible_lines && line_idx < buf.line_count() { + let line_chars = buf.line(line_idx) + .map(|l| { + let n = l.len_chars(); + if n > 0 && l.char(n - 1) == '\n' { n - 1 } else { n } + }) + .unwrap_or(0); + + let wrap_count = if chars_per_visual_line > 0 && line_chars > chars_per_visual_line { + (line_chars + chars_per_visual_line - 1) / chars_per_visual_line + } else { + 1 + }; + + for w in 0..wrap_count { + if vis_skip < scroll { + vis_skip += 1; + } else if visual_rows.len() < visible_lines { + visual_rows.push((line_idx, w * chars_per_visual_line, w == 0)); + } } + line_idx += 1; + } + let text_x = bounds.x + GUTTER_WIDTH + 8.0; + + // Render visual rows + for (vis_idx, &(line_idx, char_offset, is_first)) in visual_rows.iter().enumerate() { let y = bounds.y + vis_idx as f32 * metrics.line_height; if y + metrics.line_height < bounds.y || y > bounds.y + bounds.height { continue; } - let text_x = bounds.x + GUTTER_WIDTH + 8.0; - - // Draw selection highlight for this line + // Draw selection highlight for this visual line if let Some(sel) = selection { if !sel.is_empty() { let (start, end) = sel.ordered(); @@ -223,33 +297,28 @@ where .line(line_idx) .map(|l| { let len = l.len_chars(); - if len > 0 && l.char(len - 1) == '\n' { - len - 1 - } else { - len - } + if len > 0 && l.char(len - 1) == '\n' { len - 1 } else { len } }) .unwrap_or(0); if line_idx >= start.0 && line_idx <= end.0 { let sel_start_col = if line_idx == start.0 { start.1 } else { 0 }; - let sel_end_col = if line_idx == end.0 { - end.1 + let sel_end_col = if line_idx == end.0 { end.1 } else { line_len + 1 }; + + // Clip selection to current visual line range + let vis_end = if chars_per_visual_line > 0 { + char_offset + chars_per_visual_line } else { - line_len + 1 + usize::MAX }; - let sel_x = text_x + sel_start_col as f32 * metrics.char_width; - let sel_w = - (sel_end_col - sel_start_col) as f32 * metrics.char_width; - if sel_w > 0.0 { + let s = sel_start_col.max(char_offset); + let e = sel_end_col.min(vis_end); + if s < e { + let sel_x = text_x + (s - char_offset) as f32 * metrics.char_width; + let sel_w = (e - s) as f32 * metrics.char_width; renderer.fill_quad( renderer::Quad { - bounds: Rectangle { - x: sel_x, - y, - width: sel_w, - height: metrics.line_height, - }, + bounds: Rectangle { x: sel_x, y, width: sel_w, height: metrics.line_height }, border: iced::Border::default(), shadow: iced::Shadow::default(), }, @@ -260,54 +329,70 @@ where } } - // Line number - let line_num = format!("{:>4}", line_idx + 1); - let num_color = if line_idx == cursor_line { - ACTIVE_LINE_NUM - } else { - GUTTER_TEXT - }; - renderer.fill_text( - text::Text { - content: line_num, - bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height), - size: Pixels(FONT_SIZE), - line_height: iced::widget::text::LineHeight::Absolute(Pixels( - metrics.line_height, - )), - font, - horizontal_alignment: iced::alignment::Horizontal::Right, - vertical_alignment: iced::alignment::Vertical::Top, - shaping: iced::widget::text::Shaping::Basic, - wrapping: iced::widget::text::Wrapping::None, - }, - Point::new(bounds.x, y), - num_color, - bounds, - ); + // Line number (only on first visual line of each logical line) + if is_first { + let line_num = format!("{:>4}", line_idx + 1); + let num_color = if line_idx == cursor_line { ACTIVE_LINE_NUM } else { GUTTER_TEXT }; + renderer.fill_text( + text::Text { + content: line_num, + bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height), + size: Pixels(FONT_SIZE), + line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)), + font, + horizontal_alignment: iced::alignment::Horizontal::Right, + vertical_alignment: iced::alignment::Vertical::Top, + shaping: iced::widget::text::Shaping::Basic, + wrapping: iced::widget::text::Wrapping::None, + }, + Point::new(bounds.x, y), + num_color, + bounds, + ); + } // Line content with syntax highlighting if line_idx < highlighted.len() { let spans = &highlighted[line_idx]; - let mut x_off = text_x; + // Flatten spans into characters with colors for correct wrapping + let mut chars_with_color: Vec<(char, Color)> = Vec::new(); for (style, span_text) in spans { - let mut display_text = span_text.clone(); - if display_text.ends_with('\n') { - display_text.pop(); - } - if display_text.is_empty() { - continue; - } let color = syntect_to_iced(style.foreground); + for ch in span_text.chars() { + if ch == '\n' { continue; } + chars_with_color.push((ch, color)); + } + } + + // Extract the slice for this visual line + let end_char = if chars_per_visual_line > 0 { + (char_offset + chars_per_visual_line).min(chars_with_color.len()) + } else { + chars_with_color.len() + }; + let slice = if char_offset < chars_with_color.len() { + &chars_with_color[char_offset..end_char] + } else { + &[] + }; + + // Group consecutive chars with same color into spans + let mut x_off = text_x; + let mut span_start = 0; + while span_start < slice.len() { + let color = slice[span_start].1; + let mut span_end = span_start + 1; + while span_end < slice.len() && slice[span_end].1 == color { + span_end += 1; + } + let display_text: String = slice[span_start..span_end].iter().map(|(c, _)| *c).collect(); let span_width = display_text.len() as f32 * metrics.char_width; renderer.fill_text( text::Text { content: display_text, bounds: Size::new(span_width + metrics.char_width, metrics.line_height), size: Pixels(FONT_SIZE), - line_height: iced::widget::text::LineHeight::Absolute(Pixels( - metrics.line_height, - )), + line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)), font, horizontal_alignment: iced::alignment::Horizontal::Left, vertical_alignment: iced::alignment::Vertical::Top, @@ -319,52 +404,61 @@ where bounds, ); x_off += span_width; + span_start = span_end; } } else if let Some(line) = buf.line(line_idx) { // Fallback: plain text rendering - let mut line_text: String = line.chars().collect(); - if line_text.ends_with('\n') { - line_text.pop(); + let line_text: String = line.chars().filter(|c| *c != '\n').collect(); + let end_char = if chars_per_visual_line > 0 { + (char_offset + chars_per_visual_line).min(line_text.len()) + } else { + line_text.len() + }; + let display_text = if char_offset < line_text.len() { + &line_text[char_offset..end_char] + } else { + "" + }; + if !display_text.is_empty() { + renderer.fill_text( + text::Text { + content: display_text.to_string(), + bounds: Size::new(text_area_width, metrics.line_height), + size: Pixels(FONT_SIZE), + line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)), + font, + horizontal_alignment: iced::alignment::Horizontal::Left, + vertical_alignment: iced::alignment::Vertical::Top, + shaping: iced::widget::text::Shaping::Advanced, + wrapping: iced::widget::text::Wrapping::None, + }, + Point::new(text_x, y), + TEXT_COLOR, + bounds, + ); } - renderer.fill_text( - text::Text { - content: line_text, - bounds: Size::new( - bounds.width - GUTTER_WIDTH - 8.0, - metrics.line_height, - ), - size: Pixels(FONT_SIZE), - line_height: iced::widget::text::LineHeight::Absolute(Pixels( - metrics.line_height, - )), - font, - horizontal_alignment: iced::alignment::Horizontal::Left, - vertical_alignment: iced::alignment::Vertical::Top, - shaping: iced::widget::text::Shaping::Advanced, - wrapping: iced::widget::text::Wrapping::None, - }, - Point::new(text_x, y), - TEXT_COLOR, - bounds, - ); } - // Draw cursor on current line + // Draw cursor on this visual line if it contains the cursor if line_idx == cursor_line && state.is_focused { - let cursor_x = text_x + cursor_col as f32 * metrics.char_width; - renderer.fill_quad( - renderer::Quad { - bounds: Rectangle { - x: cursor_x, - y, - width: 2.0, - height: metrics.line_height, + let vis_end = if chars_per_visual_line > 0 { + char_offset + chars_per_visual_line + } else { + usize::MAX + }; + if cursor_col >= char_offset && cursor_col < vis_end { + let cursor_x = text_x + (cursor_col - char_offset) as f32 * metrics.char_width; + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: cursor_x, y, width: 2.0, height: metrics.line_height, + }, + border: iced::Border::default(), + shadow: iced::Shadow::default(), }, - border: iced::Border::default(), - shadow: iced::Shadow::default(), - }, - CURSOR_COLOR, - ); + CURSOR_COLOR, + ); + } } } } @@ -383,6 +477,12 @@ where let state = tree.state.downcast_mut::(); let bounds = layout.bounds(); let metrics = mono_metrics(); + let text_area_width = bounds.width - GUTTER_WIDTH - 8.0; + let cpl = if self.word_wrap && text_area_width > metrics.char_width { + (text_area_width / metrics.char_width).floor() as usize + } else { + 0 + }; match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { @@ -392,10 +492,11 @@ where if let Some(pos) = cursor.position_in(bounds) { let mut buf = self.buffer.borrow_mut(); - let line = (pos.y / metrics.line_height) as usize - + buf.scroll_offset(); - let col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) - as usize; + let vis_row = (pos.y / metrics.line_height) as usize; + let raw_col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize; + let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl) + .unwrap_or((buf.line_count().saturating_sub(1), 0)); + let col = char_off + raw_col; // Double-click detection let now = std::time::Instant::now(); @@ -429,10 +530,11 @@ where if state.is_dragging && state.is_focused { if let Some(pos) = cursor.position_in(bounds) { let mut buf = self.buffer.borrow_mut(); - let line = (pos.y / metrics.line_height) as usize - + buf.scroll_offset(); - let col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) - as usize; + let vis_row = (pos.y / metrics.line_height) as usize; + let raw_col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize; + let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl) + .unwrap_or((buf.line_count().saturating_sub(1), 0)); + let col = char_off + raw_col; // Extend selection by simulating shift+movement let clamped_line = line.min(buf.line_count().saturating_sub(1)); let clamped_col = if clamped_line < buf.line_count() { diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 50de056..4acb442 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -1002,6 +1002,7 @@ impl BdsApp { MediaEditorMsg::AltChanged(s) => { state.alt = s; state.is_dirty = true; } MediaEditorMsg::CaptionChanged(s) => { state.caption = s; state.is_dirty = true; } MediaEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; } + MediaEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); } MediaEditorMsg::Save => { return self.save_media_editor(&tab_id); } @@ -1134,11 +1135,31 @@ impl BdsApp { Message::PostLoaded(result) => { match result { Ok(post) => { - let translations = self.db.as_ref() + let mut translations = self.db.as_ref() .and_then(|db| bds_core::db::queries::post_translation::list_post_translations_by_post( db.conn(), &post.id, ).ok()) .unwrap_or_default(); + // Published translations don't store body in DB — read from file + if let Some(ref data_dir) = self.data_dir { + for tr in &mut translations { + if tr.content.is_none() { + let rel = bds_core::util::paths::translation_file_path( + post.created_at, + &post.slug, + &tr.language, + ); + let path = data_dir.join(&rel); + if let Ok(raw) = std::fs::read_to_string(&path) { + if let Ok((_fm, body)) = + bds_core::util::frontmatter::read_translation_file(&raw) + { + tr.content = Some(body); + } + } + } + } + } let (outlinks, backlinks) = self.load_post_links(&post.id); let state = PostEditorState::from_post(&post, &self.blog_languages, &translations, outlinks, backlinks); self.post_editors.insert(post.id.clone(), state); @@ -1150,7 +1171,12 @@ impl BdsApp { Message::MediaLoaded(result) => { match result { Ok(media) => { - let state = MediaEditorState::from_media(&media); + let translations = self.db.as_ref() + .and_then(|db| bds_core::db::queries::media_translation::list_media_translations_by_media( + db.conn(), &media.id, + ).ok()) + .unwrap_or_default(); + let state = MediaEditorState::from_media(&media, &self.blog_languages, &translations); self.media_editors.insert(media.id.clone(), state); } Err(e) => self.notify(ToastLevel::Error, &e), @@ -1873,13 +1899,22 @@ 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 = if let (Some(db), Some(project)) = (&self.db, &self.active_project) { - bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id) - .unwrap_or_default() + 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() + (Vec::new(), std::collections::HashMap::new()) }; - self.tags_view_state = Some(TagsViewState::new(tags)); + self.tags_view_state = Some(TagsViewState::new(tags, counts)); } let state = self.tags_view_state.as_mut().unwrap(); match msg { @@ -2042,6 +2077,18 @@ impl BdsApp { let _ = open::that(dir); } } + SettingsMsg::FocusSection(section) => { + // Expand the target section, collapse all others + use crate::views::settings_view::SettingsSection; + let all_others: Vec = SettingsSection::all() + .iter() + .filter(|s| **s != section) + .cloned() + .collect(); + state.collapsed = all_others; + // Clear search filter to ensure section is visible + state.search_query.clear(); + } } Task::none() } @@ -2104,9 +2151,29 @@ impl BdsApp { } } // Load translations for translation flags bar - let translations = bds_core::db::queries::post_translation::list_post_translations_by_post( + let mut translations = bds_core::db::queries::post_translation::list_post_translations_by_post( db.conn(), &post.id, ).unwrap_or_default(); + // Published translations don't store body in DB — read from file + if let Some(ref data_dir) = self.data_dir { + for tr in &mut translations { + if tr.content.is_none() { + let rel = bds_core::util::paths::translation_file_path( + post.created_at, + &post.slug, + &tr.language, + ); + let path = data_dir.join(&rel); + if let Ok(raw) = std::fs::read_to_string(&path) { + if let Ok((_fm, body)) = + bds_core::util::frontmatter::read_translation_file(&raw) + { + tr.content = Some(body); + } + } + } + } + } let (outlinks, backlinks) = self.load_post_links(&post.id); self.post_editors.insert( post.id.clone(), @@ -2123,7 +2190,10 @@ impl BdsApp { if !self.media_editors.contains_key(&tab.id) { match bds_core::db::queries::media::get_media_by_id(db.conn(), &tab.id) { Ok(media) => { - self.media_editors.insert(media.id.clone(), MediaEditorState::from_media(&media)); + 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)); } Err(e) => { self.notify(ToastLevel::Error, &format!("Failed to load media: {e}")); @@ -2185,17 +2255,30 @@ impl BdsApp { TabType::Tags => { if self.tags_view_state.is_none() { let project_id = self.active_project.as_ref().map(|p| p.id.as_str()).unwrap_or(""); - // Sync tags from posts first to ensure tags table is populated + // Import tags from file first, then sync from posts (additive only) if let Some(ref data_dir) = self.data_dir { - let _ = bds_core::engine::tag::sync_tags_from_posts( + let _ = bds_core::engine::tag::import_tags_from_file( db.conn(), data_dir, project_id, ); + let _ = bds_core::engine::tag::sync_tags_from_posts( + db.conn(), project_id, + ); } let tags = bds_core::db::queries::tag::list_tags_by_project( db.conn(), project_id, ).unwrap_or_default(); - self.tags_view_state = Some(TagsViewState::new(tags)); + // Compute post counts per tag for cloud sizing + let posts = bds_core::db::queries::post::list_posts_by_project( + db.conn(), project_id, + ).unwrap_or_default(); + let mut tag_post_counts = std::collections::HashMap::new(); + for post in &posts { + for tag_name in &post.tags { + *tag_post_counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1; + } + } + self.tags_view_state = Some(TagsViewState::new(tags, tag_post_counts)); } } TabType::Settings => { diff --git a/crates/bds-ui/src/views/media_editor.rs b/crates/bds-ui/src/views/media_editor.rs index 304a562..028e286 100644 --- a/crates/bds-ui/src/views/media_editor.rs +++ b/crates/bds-ui/src/views/media_editor.rs @@ -1,14 +1,26 @@ +use std::collections::HashMap; use std::path::Path; use iced::widget::{button, column, container, image, row, scrollable, text, Space}; +use iced::widget::text::Shaping; use iced::{Color, Element, Length}; -use bds_core::i18n::UiLocale; -use bds_core::model::Media; +use bds_core::i18n::{self, UiLocale}; +use bds_core::model::{Media, MediaTranslation}; use crate::app::Message; use crate::components::inputs; use crate::i18n::t; +use crate::views::post_editor::TranslationFlag; + +/// Saved draft content for a single media translation language. +#[derive(Debug, Clone)] +pub struct MediaTranslationDraft { + pub title: String, + pub alt: String, + pub caption: String, + pub is_dirty: bool, +} /// State for an open media editor. #[derive(Debug, Clone)] @@ -30,10 +42,28 @@ pub struct MediaEditorState { pub created_at: i64, pub updated_at: i64, pub is_dirty: bool, + // ── Translation flags ── + pub active_language: String, + pub canonical_language: String, + pub blog_languages: Vec, + pub saved_canonical: Option, + pub translation_drafts: HashMap, } impl MediaEditorState { - pub fn from_media(media: &Media) -> Self { + pub fn from_media(media: &Media, blog_languages: &[String], translations: &[MediaTranslation]) -> Self { + let canonical_lang = media.language.clone().unwrap_or_else(|| "en".to_string()); + + let mut translation_drafts = HashMap::new(); + for tr in translations { + translation_drafts.insert(tr.language.clone(), MediaTranslationDraft { + title: tr.title.clone().unwrap_or_default(), + alt: tr.alt.clone().unwrap_or_default(), + caption: tr.caption.clone().unwrap_or_default(), + is_dirty: false, + }); + } + Self { media_id: media.id.clone(), filename: media.filename.clone(), @@ -46,14 +76,90 @@ impl MediaEditorState { alt: media.alt.clone().unwrap_or_default(), caption: media.caption.clone().unwrap_or_default(), author: media.author.clone().unwrap_or_default(), - language: media.language.clone().unwrap_or_default(), + language: canonical_lang.clone(), file_path: media.file_path.clone(), tags: media.tags.clone(), created_at: media.created_at, updated_at: media.updated_at, is_dirty: false, + active_language: canonical_lang.clone(), + canonical_language: canonical_lang, + blog_languages: blog_languages.to_vec(), + saved_canonical: None, + translation_drafts, } } + + /// Switch to a different language. Saves current fields, loads target. + pub fn switch_language(&mut self, target_lang: &str) { + if target_lang == self.active_language { + return; + } + // Save current fields + if self.active_language == self.canonical_language { + self.saved_canonical = Some(MediaTranslationDraft { + title: self.title.clone(), + alt: self.alt.clone(), + caption: self.caption.clone(), + is_dirty: self.is_dirty, + }); + } else { + self.translation_drafts.insert(self.active_language.clone(), MediaTranslationDraft { + title: self.title.clone(), + alt: self.alt.clone(), + caption: self.caption.clone(), + is_dirty: self.is_dirty, + }); + } + // Load target fields + if target_lang == self.canonical_language { + if let Some(saved) = &self.saved_canonical { + self.title = saved.title.clone(); + self.alt = saved.alt.clone(); + self.caption = saved.caption.clone(); + self.is_dirty = saved.is_dirty; + } + } else if let Some(draft) = self.translation_drafts.get(target_lang) { + self.title = draft.title.clone(); + self.alt = draft.alt.clone(); + self.caption = draft.caption.clone(); + self.is_dirty = draft.is_dirty; + } else { + self.title = String::new(); + self.alt = String::new(); + self.caption = String::new(); + self.is_dirty = false; + } + self.active_language = target_lang.to_string(); + } + + /// Build translation flags for the view. + pub fn translation_flags(&self) -> Vec { + if self.translation_drafts.is_empty() { + return Vec::new(); + } + let mut flags = Vec::new(); + let canon = &self.canonical_language; + let canon_locale = i18n::normalize_language(canon); + flags.push(TranslationFlag { + language: canon.clone(), + flag_emoji: canon_locale.flag_emoji().to_string(), + status: "canonical".to_string(), + is_active: self.active_language == *canon, + }); + let mut langs: Vec<&String> = self.translation_drafts.keys().collect(); + langs.sort(); + for lang in langs { + let locale = i18n::normalize_language(lang); + flags.push(TranslationFlag { + language: lang.clone(), + flag_emoji: locale.flag_emoji().to_string(), + status: "translation".to_string(), + is_active: self.active_language == **lang, + }); + } + flags + } } /// Media editor messages. @@ -63,6 +169,7 @@ pub enum MediaEditorMsg { AltChanged(String), CaptionChanged(String), AuthorChanged(String), + SwitchLanguage(String), Save, Delete, } @@ -91,6 +198,30 @@ pub fn view<'a>( ], ); + // Translation flags bar + let flags = state.translation_flags(); + let flags_bar: Element<'a, Message> = if flags.is_empty() { + Space::new(0, 0).into() + } else { + let flag_buttons: Vec> = flags + .iter() + .map(|flag| { + let label = format!("{} {}", flag.flag_emoji, flag.language); + let color = if flag.is_active { + Color::WHITE + } else { + Color::from_rgb(0.55, 0.58, 0.65) + }; + button(text(label).size(12).shaping(Shaping::Advanced).color(color)) + .on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage(flag.language.clone()))) + .padding([4, 8]) + .style(|_: &iced::Theme, _| button::Style::default()) + .into() + }) + .collect(); + row(flag_buttons).spacing(4).into() + }; + // Preview section let preview: Element<'a, Message> = if state.mime_type.starts_with("image/") { if let Some(dir) = data_dir { @@ -166,6 +297,7 @@ pub fn view<'a>( let body = scrollable( column![ header, + flags_bar, preview, info, inputs::section_header(&t(locale, "editor.metadata")), diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 02eebbb..107506c 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -301,6 +301,7 @@ pub enum PostEditorMsg { pub fn view<'a>( state: &'a PostEditorState, locale: UiLocale, + word_wrap: bool, ) -> Element<'a, Message> { // ── Header bar ── let dirty_indicator = if state.is_dirty { " \u{25CF}" } else { "" }; @@ -521,6 +522,7 @@ pub fn view<'a>( highlighter(), "md", ) + .word_wrap(word_wrap) .on_change(|msg| match msg { EditorMessage::ContentChanged(s) => Message::PostEditor(PostEditorMsg::ContentChanged(s)), EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save), diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index 6aa43e8..909f0cb 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -174,6 +174,8 @@ pub enum SettingsMsg { RebuildLinks, RegenerateThumbnails, OpenDataFolder, + /// Navigate to a specific section from sidebar; expand it, collapse all others. + FocusSection(SettingsSection), } /// Render the settings view. diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index da44452..ba53ebd 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -905,35 +905,41 @@ pub fn view( } SidebarView::Settings => { // Per sidebar_views.allium SettingsNav: 9 fixed-order sections - let sections = [ - "settings.nav.project", - "settings.nav.editor", - "settings.nav.content", - "settings.nav.ai", - "settings.nav.technology", - "settings.nav.publishing", - "settings.nav.data", - "settings.nav.mcp", - "settings.nav.style", + use crate::views::settings_view::{SettingsSection, SettingsMsg}; + let sections: &[(&str, Option)] = &[ + ("settings.nav.project", Some(SettingsSection::Project)), + ("settings.nav.editor", Some(SettingsSection::Editor)), + ("settings.nav.content", Some(SettingsSection::Content)), + ("settings.nav.ai", Some(SettingsSection::AI)), + ("settings.nav.technology", Some(SettingsSection::Technology)), + ("settings.nav.publishing", Some(SettingsSection::Publishing)), + ("settings.nav.data", Some(SettingsSection::Data)), + ("settings.nav.mcp", Some(SettingsSection::MCP)), + ("settings.nav.style", None), ]; let items: Vec> = sections .iter() - .map(|key| { + .map(|(key, section_opt)| { let label = t(locale, key); let label_text = text(label) .size(12) .shaping(Shaping::Advanced); - button( - container(label_text) - .width(Length::Fill) - ) - .on_press(Message::OpenTab(Tab { + let msg = if let Some(section) = section_opt { + Message::Settings(SettingsMsg::FocusSection(section.clone())) + } else { + Message::OpenTab(Tab { id: "settings".to_string(), tab_type: TabType::Settings, title: t(locale, "common.settings"), is_transient: false, is_dirty: false, - })) + }) + }; + button( + container(label_text) + .width(Length::Fill) + ) + .on_press(msg) .padding([3, 6]) .width(Length::Fill) .style(item_style) diff --git a/crates/bds-ui/src/views/tags_view.rs b/crates/bds-ui/src/views/tags_view.rs index 64bb284..c33f7cf 100644 --- a/crates/bds-ui/src/views/tags_view.rs +++ b/crates/bds-ui/src/views/tags_view.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::{Alignment, Background, Color, Element, Length, Theme}; @@ -23,6 +25,7 @@ pub enum TagsSection { pub struct TagsViewState { pub section: TagsSection, pub tags: Vec, + pub tag_post_counts: HashMap, pub search_query: String, /// For "Manage": the currently editing tag pub editing_tag: Option, @@ -40,10 +43,11 @@ pub struct EditingTag { } impl TagsViewState { - pub fn new(tags: Vec) -> Self { + pub fn new(tags: Vec, tag_post_counts: HashMap) -> Self { Self { section: TagsSection::Cloud, tags, + tag_post_counts, search_query: String::new(), editing_tag: None, merge_source: None, @@ -133,14 +137,27 @@ 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)) + .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 }; + const MIN_FONT: f32 = 11.0; + const MAX_FONT: f32 = 24.0; + let chips: Vec> = state .tags .iter() .map(|tag| { let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR)); - button(text(&tag.name).size(13).color(Color::WHITE)) + 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([4, 10]) + .padding([vert_pad, 10]) .style(move |_: &Theme, _| button::Style { background: Some(Background::Color(color)), border: iced::Border { diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index c976fb0..0ef6313 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -310,7 +310,8 @@ fn route_content_area<'a>( match tab.tab_type { TabType::Post => { if let Some(state) = post_editors.get(tab_id) { - post_editor::view(state, locale) + let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(false); + post_editor::view(state, locale, wrap) } else { loading_view(locale) }