fix: more fixes on M3
This commit is contained in:
@@ -200,6 +200,8 @@ pub struct BdsApp {
|
|||||||
/// Content/render language — the blog's main_language from project.json.
|
/// Content/render language — the blog's main_language from project.json.
|
||||||
/// Separate from ui_locale per i18n.allium TwoLocaleAxes.
|
/// Separate from ui_locale per i18n.allium TwoLocaleAxes.
|
||||||
content_language: String,
|
content_language: String,
|
||||||
|
/// All blog languages from project.json (for translation flags).
|
||||||
|
blog_languages: Vec<String>,
|
||||||
|
|
||||||
// Flags
|
// Flags
|
||||||
offline_mode: bool,
|
offline_mode: bool,
|
||||||
@@ -330,6 +332,7 @@ impl BdsApp {
|
|||||||
menu_registry: registry,
|
menu_registry: registry,
|
||||||
ui_locale: locale,
|
ui_locale: locale,
|
||||||
content_language: "en".to_string(),
|
content_language: "en".to_string(),
|
||||||
|
blog_languages: Vec::new(),
|
||||||
offline_mode: false,
|
offline_mode: false,
|
||||||
locale_dropdown_open: false,
|
locale_dropdown_open: false,
|
||||||
project_dropdown_open: false,
|
project_dropdown_open: false,
|
||||||
@@ -462,6 +465,7 @@ impl BdsApp {
|
|||||||
// Extract content language from project metadata
|
// Extract content language from project metadata
|
||||||
if let Ok(meta) = engine::meta::read_project_json(&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.content_language = meta.main_language.unwrap_or_else(|| "en".to_string());
|
||||||
|
self.blog_languages = meta.blog_languages;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.refresh_counts();
|
self.refresh_counts();
|
||||||
@@ -484,6 +488,7 @@ impl BdsApp {
|
|||||||
let _ = engine::meta::startup_sync(&data_dir);
|
let _ = engine::meta::startup_sync(&data_dir);
|
||||||
if let Ok(meta) = engine::meta::read_project_json(&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.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();
|
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::ToggleDoNotTranslate(b) => { state.do_not_translate = b; state.is_dirty = true; }
|
||||||
PostEditorMsg::ToggleMetadata => { state.metadata_expanded = !state.metadata_expanded; }
|
PostEditorMsg::ToggleMetadata => { state.metadata_expanded = !state.metadata_expanded; }
|
||||||
PostEditorMsg::ToggleExcerpt => { state.excerpt_expanded = !state.excerpt_expanded; }
|
PostEditorMsg::ToggleExcerpt => { state.excerpt_expanded = !state.excerpt_expanded; }
|
||||||
|
PostEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); }
|
||||||
PostEditorMsg::TagsInputChanged(s) => { state.tags_input = s; }
|
PostEditorMsg::TagsInputChanged(s) => { state.tags_input = s; }
|
||||||
PostEditorMsg::TagsInputSubmit => {
|
PostEditorMsg::TagsInputSubmit => {
|
||||||
let tag = state.tags_input.trim().to_string();
|
let tag = state.tags_input.trim().to_string();
|
||||||
@@ -1122,7 +1128,12 @@ impl BdsApp {
|
|||||||
Message::PostLoaded(result) => {
|
Message::PostLoaded(result) => {
|
||||||
match result {
|
match result {
|
||||||
Ok(post) => {
|
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);
|
self.post_editors.insert(post.id.clone(), state);
|
||||||
}
|
}
|
||||||
Err(e) => self.notify(ToastLevel::Error, &e),
|
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) => {
|
Err(e) => {
|
||||||
self.notify(ToastLevel::Error, &format!("Failed to load post: {e}"));
|
self.notify(ToastLevel::Error, &format!("Failed to load post: {e}"));
|
||||||
|
|||||||
@@ -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::{button, column, container, row, scrollable, text, text_input, text_editor, Space};
|
||||||
use iced::widget::text::{Shaping, Wrapping};
|
use iced::widget::text::{Shaping, Wrapping};
|
||||||
use iced::{Color, Element, Length, Theme};
|
use iced::{Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::{self, UiLocale};
|
||||||
use bds_core::model::{Post, PostStatus};
|
use bds_core::model::{Post, PostStatus, PostTranslation};
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::components::inputs;
|
use crate::components::inputs;
|
||||||
use crate::i18n::t;
|
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.
|
/// State for an open post editor.
|
||||||
pub struct PostEditorState {
|
pub struct PostEditorState {
|
||||||
pub post_id: String,
|
pub post_id: String,
|
||||||
@@ -31,6 +52,17 @@ pub struct PostEditorState {
|
|||||||
pub excerpt_expanded: bool,
|
pub excerpt_expanded: bool,
|
||||||
pub tags_input: String,
|
pub tags_input: String,
|
||||||
pub categories_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<String>,
|
||||||
|
/// Saved canonical title/excerpt/content when viewing a translation.
|
||||||
|
pub saved_canonical: Option<TranslationDraft>,
|
||||||
|
/// Translation drafts keyed by language code.
|
||||||
|
pub translation_drafts: HashMap<String, TranslationDraft>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for PostEditorState {
|
impl std::fmt::Debug for PostEditorState {
|
||||||
@@ -38,6 +70,7 @@ impl std::fmt::Debug for PostEditorState {
|
|||||||
f.debug_struct("PostEditorState")
|
f.debug_struct("PostEditorState")
|
||||||
.field("post_id", &self.post_id)
|
.field("post_id", &self.post_id)
|
||||||
.field("title", &self.title)
|
.field("title", &self.title)
|
||||||
|
.field("active_language", &self.active_language)
|
||||||
.finish_non_exhaustive()
|
.finish_non_exhaustive()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,14 +98,36 @@ impl Clone for PostEditorState {
|
|||||||
excerpt_expanded: self.excerpt_expanded,
|
excerpt_expanded: self.excerpt_expanded,
|
||||||
tags_input: self.tags_input.clone(),
|
tags_input: self.tags_input.clone(),
|
||||||
categories_input: self.categories_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 {
|
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 title = post.title.clone();
|
||||||
let content = post.content.clone().unwrap_or_default();
|
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 {
|
Self {
|
||||||
post_id: post.id.clone(),
|
post_id: post.id.clone(),
|
||||||
slug: post.slug.clone(),
|
slug: post.slug.clone(),
|
||||||
@@ -82,7 +137,7 @@ impl PostEditorState {
|
|||||||
tags: post.tags.clone(),
|
tags: post.tags.clone(),
|
||||||
categories: post.categories.clone(),
|
categories: post.categories.clone(),
|
||||||
author: post.author.clone().unwrap_or_default(),
|
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(),
|
template_slug: post.template_slug.clone().unwrap_or_default(),
|
||||||
do_not_translate: post.do_not_translate,
|
do_not_translate: post.do_not_translate,
|
||||||
status: post.status.clone(),
|
status: post.status.clone(),
|
||||||
@@ -93,9 +148,112 @@ impl PostEditorState {
|
|||||||
excerpt_expanded: false,
|
excerpt_expanded: false,
|
||||||
tags_input: String::new(),
|
tags_input: String::new(),
|
||||||
categories_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,
|
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<TranslationFlag> {
|
||||||
|
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.
|
/// Post editor messages.
|
||||||
@@ -110,6 +268,7 @@ pub enum PostEditorMsg {
|
|||||||
ToggleDoNotTranslate(bool),
|
ToggleDoNotTranslate(bool),
|
||||||
ToggleMetadata,
|
ToggleMetadata,
|
||||||
ToggleExcerpt,
|
ToggleExcerpt,
|
||||||
|
SwitchLanguage(String),
|
||||||
TagsInputChanged(String),
|
TagsInputChanged(String),
|
||||||
TagsInputSubmit,
|
TagsInputSubmit,
|
||||||
RemoveTag(String),
|
RemoveTag(String),
|
||||||
@@ -182,7 +341,31 @@ pub fn view<'a>(
|
|||||||
..button::Style::default()
|
..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(
|
let title_input = inputs::labeled_input(
|
||||||
&t(locale, "editor.title"),
|
&t(locale, "editor.title"),
|
||||||
&t(locale, "editor.titlePlaceholder"),
|
&t(locale, "editor.titlePlaceholder"),
|
||||||
@@ -298,7 +481,7 @@ pub fn view<'a>(
|
|||||||
let top_pane = scrollable(
|
let top_pane = scrollable(
|
||||||
column![
|
column![
|
||||||
header,
|
header,
|
||||||
meta_toggle,
|
meta_toggle_row,
|
||||||
metadata_section,
|
metadata_section,
|
||||||
excerpt_toggle,
|
excerpt_toggle,
|
||||||
excerpt_section,
|
excerpt_section,
|
||||||
@@ -426,3 +609,34 @@ fn status_badge<'a>(status: &PostStatus) -> Element<'a, Message> {
|
|||||||
})
|
})
|
||||||
.into()
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user