fix: and even more fixes for M3

This commit is contained in:
2026-04-05 19:36:31 +02:00
parent 17c4c79fd6
commit eb80e8ef19
10 changed files with 568 additions and 152 deletions

View File

@@ -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<String>,
pub saved_canonical: Option<MediaTranslationDraft>,
pub translation_drafts: HashMap<String, MediaTranslationDraft>,
}
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<TranslationFlag> {
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<Element<'a, Message>> = 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")),

View File

@@ -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),

View File

@@ -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.

View File

@@ -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<SettingsSection>)] = &[
("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<Element<'static, Message>> = 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)

View File

@@ -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<Tag>,
pub tag_post_counts: HashMap<String, usize>,
pub search_query: String,
/// For "Manage": the currently editing tag
pub editing_tag: Option<EditingTag>,
@@ -40,10 +43,11 @@ pub struct EditingTag {
}
impl TagsViewState {
pub fn new(tags: Vec<Tag>) -> Self {
pub fn new(tags: Vec<Tag>, tag_post_counts: HashMap<String, usize>) -> 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<usize> = 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<Element<'a, Message>> = 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 {

View File

@@ -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)
}