diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 3cc0c6d..f7e55bf 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -200,6 +200,8 @@ pub struct BdsApp { /// Content/render language — the blog's main_language from project.json. /// Separate from ui_locale per i18n.allium TwoLocaleAxes. content_language: String, + /// All blog languages from project.json (for translation flags). + blog_languages: Vec, // Flags offline_mode: bool, @@ -330,6 +332,7 @@ impl BdsApp { menu_registry: registry, ui_locale: locale, content_language: "en".to_string(), + blog_languages: Vec::new(), offline_mode: false, locale_dropdown_open: false, project_dropdown_open: false, @@ -462,6 +465,7 @@ impl BdsApp { // Extract content language from project metadata if let Ok(meta) = engine::meta::read_project_json(&data_dir) { self.content_language = meta.main_language.unwrap_or_else(|| "en".to_string()); + self.blog_languages = meta.blog_languages; } } self.refresh_counts(); @@ -484,6 +488,7 @@ impl BdsApp { let _ = engine::meta::startup_sync(&data_dir); if let Ok(meta) = engine::meta::read_project_json(&data_dir) { self.content_language = meta.main_language.unwrap_or_else(|| "en".to_string()); + self.blog_languages = meta.blog_languages; } } let name = self.active_project.as_ref().map(|p| p.name.clone()).unwrap_or_default(); @@ -936,6 +941,7 @@ impl BdsApp { PostEditorMsg::ToggleDoNotTranslate(b) => { state.do_not_translate = b; state.is_dirty = true; } PostEditorMsg::ToggleMetadata => { state.metadata_expanded = !state.metadata_expanded; } PostEditorMsg::ToggleExcerpt => { state.excerpt_expanded = !state.excerpt_expanded; } + PostEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); } PostEditorMsg::TagsInputChanged(s) => { state.tags_input = s; } PostEditorMsg::TagsInputSubmit => { let tag = state.tags_input.trim().to_string(); @@ -1122,7 +1128,12 @@ impl BdsApp { Message::PostLoaded(result) => { match result { Ok(post) => { - let state = PostEditorState::from_post(&post); + let 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(); + let state = PostEditorState::from_post(&post, &self.blog_languages, &translations); self.post_editors.insert(post.id.clone(), state); } Err(e) => self.notify(ToastLevel::Error, &e), @@ -2024,7 +2035,14 @@ impl BdsApp { } } } - self.post_editors.insert(post.id.clone(), PostEditorState::from_post(&post)); + // Load translations for translation flags bar + let translations = bds_core::db::queries::post_translation::list_post_translations_by_post( + db.conn(), &post.id, + ).unwrap_or_default(); + self.post_editors.insert( + post.id.clone(), + PostEditorState::from_post(&post, &self.blog_languages, &translations), + ); } Err(e) => { self.notify(ToastLevel::Error, &format!("Failed to load post: {e}")); diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index e3778b0..8fa8f46 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -1,14 +1,35 @@ +use std::collections::HashMap; + use iced::widget::{button, column, container, row, scrollable, text, text_input, text_editor, Space}; use iced::widget::text::{Shaping, Wrapping}; use iced::{Color, Element, Length, Theme}; -use bds_core::i18n::UiLocale; -use bds_core::model::{Post, PostStatus}; +use bds_core::i18n::{self, UiLocale}; +use bds_core::model::{Post, PostStatus, PostTranslation}; use crate::app::Message; use crate::components::inputs; use crate::i18n::t; +/// Per editor_post.allium TranslationFlag value. +#[derive(Debug, Clone)] +pub struct TranslationFlag { + pub language: String, + pub flag_emoji: String, + pub status: String, // "draft" | "published" | "missing" + pub is_active: bool, +} + +/// Saved draft content for a single translation language. +#[derive(Debug, Clone)] +pub struct TranslationDraft { + pub title: String, + pub excerpt: String, + pub content: String, + pub status: PostStatus, + pub is_dirty: bool, +} + /// State for an open post editor. pub struct PostEditorState { pub post_id: String, @@ -31,6 +52,17 @@ pub struct PostEditorState { pub excerpt_expanded: bool, pub tags_input: String, pub categories_input: String, + // ── Translation flags ── + /// Currently-displayed language (canonical or translation). + pub active_language: String, + /// The post's own language (canonical). + pub canonical_language: String, + /// All blog languages from project metadata. + pub blog_languages: Vec, + /// Saved canonical title/excerpt/content when viewing a translation. + pub saved_canonical: Option, + /// Translation drafts keyed by language code. + pub translation_drafts: HashMap, } impl std::fmt::Debug for PostEditorState { @@ -38,6 +70,7 @@ impl std::fmt::Debug for PostEditorState { f.debug_struct("PostEditorState") .field("post_id", &self.post_id) .field("title", &self.title) + .field("active_language", &self.active_language) .finish_non_exhaustive() } } @@ -65,14 +98,36 @@ impl Clone for PostEditorState { excerpt_expanded: self.excerpt_expanded, tags_input: self.tags_input.clone(), categories_input: self.categories_input.clone(), + active_language: self.active_language.clone(), + canonical_language: self.canonical_language.clone(), + blog_languages: self.blog_languages.clone(), + saved_canonical: self.saved_canonical.clone(), + translation_drafts: self.translation_drafts.clone(), } } } impl PostEditorState { - pub fn from_post(post: &Post) -> Self { + pub fn from_post( + post: &Post, + blog_languages: &[String], + translations: &[PostTranslation], + ) -> Self { let title = post.title.clone(); let content = post.content.clone().unwrap_or_default(); + let canonical_lang = post.language.clone().unwrap_or_else(|| "en".to_string()); + + let mut translation_drafts = HashMap::new(); + for tr in translations { + translation_drafts.insert(tr.language.clone(), TranslationDraft { + title: tr.title.clone(), + excerpt: tr.excerpt.clone().unwrap_or_default(), + content: tr.content.clone().unwrap_or_default(), + status: tr.status.clone(), + is_dirty: false, + }); + } + Self { post_id: post.id.clone(), slug: post.slug.clone(), @@ -82,7 +137,7 @@ impl PostEditorState { tags: post.tags.clone(), categories: post.categories.clone(), author: post.author.clone().unwrap_or_default(), - language: post.language.clone().unwrap_or_default(), + language: canonical_lang.clone(), template_slug: post.template_slug.clone().unwrap_or_default(), do_not_translate: post.do_not_translate, status: post.status.clone(), @@ -93,9 +148,112 @@ impl PostEditorState { excerpt_expanded: false, tags_input: String::new(), categories_input: String::new(), + active_language: canonical_lang.clone(), + canonical_language: canonical_lang, + blog_languages: blog_languages.to_vec(), + saved_canonical: None, + translation_drafts, title, } } + + /// Switch the editor to display a different language. + /// Saves current fields and loads the target language's draft. + 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 { + // Switching away from canonical — stash canonical fields + self.saved_canonical = Some(TranslationDraft { + title: self.title.clone(), + excerpt: self.excerpt.clone(), + content: self.content.clone(), + status: self.status.clone(), + is_dirty: self.is_dirty, + }); + } else { + // Switching away from a translation — save to drafts + self.translation_drafts.insert(self.active_language.clone(), TranslationDraft { + title: self.title.clone(), + excerpt: self.excerpt.clone(), + content: self.content.clone(), + status: PostStatus::Draft, + is_dirty: self.is_dirty, + }); + } + + // Load target fields + if target_lang == self.canonical_language { + // Restore canonical + if let Some(saved) = self.saved_canonical.take() { + self.title = saved.title; + self.excerpt = saved.excerpt; + self.content = saved.content.clone(); + self.editor_content = text_editor::Content::with_text(&saved.content); + self.status = saved.status; + self.is_dirty = saved.is_dirty; + } + } else if let Some(draft) = self.translation_drafts.get(target_lang) { + // Load existing translation + self.title = draft.title.clone(); + self.excerpt = draft.excerpt.clone(); + self.content = draft.content.clone(); + self.editor_content = text_editor::Content::with_text(&draft.content); + self.is_dirty = draft.is_dirty; + } else { + // No translation yet — blank fields + self.title = String::new(); + self.excerpt = String::new(); + self.content = String::new(); + self.editor_content = text_editor::Content::with_text(""); + self.is_dirty = false; + } + + self.active_language = target_lang.to_string(); + } + + /// Build the translation flags list for the view. + pub fn translation_flags(&self) -> Vec { + if self.blog_languages.len() <= 1 { + return Vec::new(); + } + let mut flags = Vec::new(); + + // Canonical language first + 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, + }); + + // Each other blog language + for lang in &self.blog_languages { + if lang == canon { + continue; + } + let locale = i18n::normalize_language(lang); + let status = self.translation_drafts.get(lang) + .map(|d| match d.status { + PostStatus::Published => "published", + _ => "draft", + }) + .unwrap_or("missing"); + flags.push(TranslationFlag { + language: lang.clone(), + flag_emoji: locale.flag_emoji().to_string(), + status: status.to_string(), + is_active: self.active_language == *lang, + }); + } + + flags + } } /// Post editor messages. @@ -110,6 +268,7 @@ pub enum PostEditorMsg { ToggleDoNotTranslate(bool), ToggleMetadata, ToggleExcerpt, + SwitchLanguage(String), TagsInputChanged(String), TagsInputSubmit, RemoveTag(String), @@ -182,7 +341,31 @@ pub fn view<'a>( ..button::Style::default() }); - let metadata_section: Element<'a, Message> = if state.metadata_expanded { + // ── Translation Flags Bar (inline with metadata toggle) ── + let flags = state.translation_flags(); + let on_translation = state.active_language != state.canonical_language; + let meta_toggle_row: Element<'a, Message> = if flags.is_empty() { + meta_toggle.into() + } else { + let mut flag_row = row![].spacing(2); + for flag in &flags { + let lang = flag.language.clone(); + let label = format!("{}", flag.flag_emoji); + let btn = button( + text(label).size(14).shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang))) + .padding([2, 4]) + .style(if flag.is_active { flag_active_style } else { flag_inactive_style }); + flag_row = flag_row.push(btn); + } + row![meta_toggle, Space::with_width(Length::Fill), flag_row] + .spacing(8) + .align_y(iced::Alignment::Center) + .into() + }; + + let metadata_section: Element<'a, Message> = if state.metadata_expanded && !on_translation { let title_input = inputs::labeled_input( &t(locale, "editor.title"), &t(locale, "editor.titlePlaceholder"), @@ -298,7 +481,7 @@ pub fn view<'a>( let top_pane = scrollable( column![ header, - meta_toggle, + meta_toggle_row, metadata_section, excerpt_toggle, excerpt_section, @@ -426,3 +609,34 @@ fn status_badge<'a>(status: &PostStatus) -> Element<'a, Message> { }) .into() } + +/// Active translation flag button style (highlighted). +fn flag_active_style(_theme: &Theme, _status: button::Status) -> button::Style { + button::Style { + background: Some(iced::Background::Color(Color::from_rgb(0.20, 0.35, 0.60))), + text_color: Color::WHITE, + border: iced::Border { + radius: 4.0.into(), + width: 1.0, + color: Color::from_rgb(0.30, 0.50, 0.80), + }, + ..button::Style::default() + } +} + +/// Inactive translation flag button style. +fn flag_inactive_style(_theme: &Theme, status: button::Status) -> button::Style { + let bg = match status { + button::Status::Hovered => Color::from_rgb(0.22, 0.24, 0.30), + _ => Color::TRANSPARENT, + }; + button::Style { + background: Some(iced::Background::Color(bg)), + text_color: Color::from_rgb(0.70, 0.72, 0.78), + border: iced::Border { + radius: 4.0.into(), + ..iced::Border::default() + }, + ..button::Style::default() + } +}