fix: even more closing of M0/M1/M2 gaps against the spec

This commit is contained in:
2026-04-05 14:26:26 +02:00
parent 6e34f5de1c
commit 0cf59da467
24 changed files with 1979 additions and 29 deletions

View File

@@ -7,7 +7,7 @@ use bds_core::db::Database;
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
use bds_core::engine;
use bds_core::i18n::{detect_os_locale, UiLocale};
use bds_core::model::{Media, Post, Project};
use bds_core::model::{Media, Post, Project, Script, Template};
use crate::i18n::{t, tw};
use crate::platform::menu::{self, MenuAction, MenuRegistry};
@@ -16,7 +16,7 @@ use crate::state::navigation::{
};
use crate::state::tabs::{self, Tab, TabType};
use crate::state::toast::{Toast, ToastLevel};
use crate::views::workspace;
use crate::views::{modal, workspace};
// ───────────────────────────────────────────────────────────
// Message
@@ -79,11 +79,17 @@ pub enum Message {
DismissToast(u64),
ExpireToasts,
// Modal
ShowModal(modal::ModalState),
DismissModal,
ConfirmModal(modal::ConfirmAction),
// Blog actions (dispatched to engine)
RebuildDatabase,
ReindexText,
RegenerateCalendar,
ValidateTranslations,
ValidateMedia,
GenerateSite,
RunMetadataDiff,
EngineTaskDone { task_id: TaskId, label: String, result: Result<String, String> },
@@ -113,6 +119,8 @@ pub struct BdsApp {
// Sidebar data
sidebar_posts: Vec<Post>,
sidebar_media: Vec<Media>,
sidebar_scripts: Vec<Script>,
sidebar_templates: Vec<Template>,
// Navigation
sidebar_view: SidebarView,
@@ -139,6 +147,9 @@ pub struct BdsApp {
// i18n
ui_locale: UiLocale,
/// Content/render language — the blog's main_language from project.json.
/// Separate from ui_locale per i18n.allium TwoLocaleAxes.
content_language: String,
// Flags
offline_mode: bool,
@@ -148,6 +159,9 @@ pub struct BdsApp {
// Toasts
toasts: Vec<Toast>,
// Modal
active_modal: Option<modal::ModalState>,
}
// ───────────────────────────────────────────────────────────
@@ -236,6 +250,8 @@ impl BdsApp {
media_count: 0,
sidebar_posts: Vec::new(),
sidebar_media: Vec::new(),
sidebar_scripts: Vec::new(),
sidebar_templates: Vec::new(),
sidebar_view: SidebarView::Posts,
sidebar_visible: true,
sidebar_width: 280.0,
@@ -250,11 +266,13 @@ impl BdsApp {
_menu_bar: menu_bar,
menu_registry: registry,
ui_locale: locale,
content_language: "en".to_string(),
offline_mode: false,
locale_dropdown_open: false,
project_dropdown_open: false,
theme_badge: String::from("pico"),
toasts: Vec::new(),
active_modal: None,
},
init_task,
)
@@ -353,6 +371,16 @@ impl BdsApp {
.and_then(|p| p.data_path.as_ref())
.map(PathBuf::from);
}
// Per metadata.allium StartupSync: sync metadata from filesystem
if let Some(data_dir) = self.data_dir.clone() {
if let Err(e) = engine::meta::startup_sync(&data_dir) {
self.add_output(&format!("Metadata sync failed: {e}"));
}
// 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.refresh_counts();
self.sync_menu_state();
Task::none()
@@ -368,6 +396,13 @@ impl BdsApp {
.as_ref()
.and_then(|p| p.data_path.as_ref())
.map(PathBuf::from);
// Per metadata.allium StartupSync
if let Some(data_dir) = self.data_dir.clone() {
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());
}
}
let name = self.active_project.as_ref().map(|p| p.name.clone()).unwrap_or_default();
self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)]));
}
@@ -592,6 +627,23 @@ impl BdsApp {
},
)
}
Message::ValidateMedia => {
self.spawn_engine_task(
"engine.validateMediaStarted",
|db_path, project_id, _data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let on_item: engine::validate_media::ProgressFn = Box::new(move |current, total, name| {
let pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
let msg = format!("Checking: {current}/{total} \u{2014} {name}");
tm.report_progress(tid, Some(pct), Some(msg));
});
let report = engine::validate_media::validate_media(
db.conn(), &_data_dir, &project_id, Some(on_item),
).map_err(|e| e.to_string())?;
Ok(format!("checked={}, issues={}", report.total_checked, report.issues.len()))
},
)
}
Message::GenerateSite => {
self.spawn_engine_task(
"engine.generateSiteStarted",
@@ -639,6 +691,40 @@ impl BdsApp {
Task::none()
}
// ── Modal ──
Message::ShowModal(state) => {
self.active_modal = Some(state);
Task::none()
}
Message::DismissModal => {
self.active_modal = None;
Task::none()
}
Message::ConfirmModal(action) => {
self.active_modal = None;
match action {
modal::ConfirmAction::DeleteProject(id) => {
Task::done(Message::DeleteProject(id))
}
modal::ConfirmAction::DeletePost(_id) => {
// Post deletion will be implemented in M3 editors
Task::none()
}
modal::ConfirmAction::DeleteMedia(_id) => {
Task::none()
}
modal::ConfirmAction::DeleteScript(_id) => {
Task::none()
}
modal::ConfirmAction::DeleteTemplate(_id) => {
Task::none()
}
modal::ConfirmAction::MergeTags { .. } => {
Task::none()
}
}
}
Message::Noop => Task::none(),
Message::InitMenuBar => {
#[cfg(target_os = "macos")]
@@ -663,6 +749,8 @@ impl BdsApp {
&self.output_entries,
&self.sidebar_posts,
&self.sidebar_media,
&self.sidebar_scripts,
&self.sidebar_templates,
active_name,
&self.projects,
self.active_project.as_ref().map(|p| p.id.as_str()),
@@ -674,6 +762,7 @@ impl BdsApp {
&self.theme_badge,
self.ui_locale,
&self.toasts,
self.active_modal.as_ref(),
)
}
@@ -978,6 +1067,16 @@ impl BdsApp {
0,
)
.unwrap_or_default();
self.sidebar_scripts = bds_core::db::queries::script::list_scripts_by_project(
db.conn(),
&project.id,
)
.unwrap_or_default();
self.sidebar_templates = bds_core::db::queries::template::list_templates_by_project(
db.conn(),
&project.id,
)
.unwrap_or_default();
// Read pico theme from project metadata for status bar badge
if let Some(ref data_dir) = self.data_dir {
if let Ok(meta) = engine::meta::read_project_json(data_dir) {

View File

@@ -7,3 +7,4 @@ pub mod panel;
pub mod project_selector;
pub mod toast;
pub mod welcome;
pub mod modal;

View File

@@ -0,0 +1,252 @@
use iced::widget::{button, column, container, row, text, Space};
use iced::widget::text::Shaping;
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::i18n::t;
/// Active modal state. Only one modal at a time.
#[derive(Debug, Clone)]
pub enum ModalState {
/// Per modals.allium ConfirmDeleteModal: warning, entity name, references.
ConfirmDelete {
entity_name: String,
references: Vec<String>,
on_confirm: ConfirmAction,
},
/// Per modals.allium ConfirmDialog: generic confirmation.
Confirm {
title: String,
message: String,
on_confirm: ConfirmAction,
},
}
/// What action to perform when modal is confirmed.
#[derive(Debug, Clone)]
pub enum ConfirmAction {
DeleteProject(String),
DeletePost(String),
DeleteMedia(String),
DeleteScript(String),
DeleteTemplate(String),
MergeTags { source: String, target: String },
}
// ── Modal backdrop style ──
fn backdrop_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
..container::Style::default()
}
}
fn modal_box_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgb(0.18, 0.18, 0.22))),
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 8.0.into(),
},
..container::Style::default()
}
}
fn cancel_button_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.28, 0.28, 0.33),
_ => Color::from_rgb(0.22, 0.22, 0.27),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.80, 0.80, 0.85),
border: Border {
color: Color::from_rgb(0.35, 0.35, 0.40),
width: 1.0,
radius: 4.0.into(),
},
..button::Style::default()
}
}
fn danger_button_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.85, 0.20, 0.20),
_ => Color::from_rgb(0.75, 0.15, 0.15),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::WHITE,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 4.0.into(),
},
..button::Style::default()
}
}
fn confirm_button_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.20, 0.55, 0.85),
_ => Color::from_rgb(0.15, 0.45, 0.75),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::WHITE,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 4.0.into(),
},
..button::Style::default()
}
}
/// Render the modal overlay.
pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> {
let modal_content: Element<'static, Message> = match state {
ModalState::ConfirmDelete {
entity_name,
references,
on_confirm,
} => {
let title = text(t(locale, "modal.confirmDelete.title"))
.size(16)
.shaping(Shaping::Advanced)
.color(Color::WHITE);
let warning_icon = text("\u{26A0}")
.size(20)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(1.0, 0.80, 0.0));
let entity_label = text(entity_name.clone())
.size(14)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.90, 0.90, 0.95));
let warning_text = text(t(locale, "modal.confirmDelete.warning"))
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.70, 0.70, 0.75));
let mut content_col = column![
row![warning_icon, Space::with_width(8.0), title]
.align_y(Alignment::Center),
Space::with_height(12.0),
entity_label,
warning_text,
]
.spacing(4);
// Reference section
if !references.is_empty() {
content_col = content_col.push(Space::with_height(8.0));
for r in references {
content_col = content_col.push(
text(format!("\u{2022} {r}"))
.size(11)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.60, 0.60, 0.65)),
);
}
}
let on_confirm_clone = on_confirm.clone();
let buttons = row![
button(
text(t(locale, "modal.confirmDelete.cancel"))
.size(13)
.shaping(Shaping::Advanced)
)
.on_press(Message::DismissModal)
.padding([6, 16])
.style(cancel_button_style),
Space::with_width(Length::Fill),
button(
text(t(locale, "modal.confirmDelete.delete"))
.size(13)
.shaping(Shaping::Advanced)
)
.on_press(Message::ConfirmModal(on_confirm_clone))
.padding([6, 16])
.style(danger_button_style),
];
content_col = content_col.push(Space::with_height(16.0));
content_col = content_col.push(buttons);
container(content_col.padding(20))
.width(Length::Fixed(380.0))
.style(modal_box_style)
.into()
}
ModalState::Confirm {
title: dialog_title,
message: dialog_message,
on_confirm,
} => {
let title = text(dialog_title.clone())
.size(16)
.shaping(Shaping::Advanced)
.color(Color::WHITE);
let msg = text(dialog_message.clone())
.size(13)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.80, 0.80, 0.85));
let on_confirm_clone = on_confirm.clone();
let buttons = row![
button(
text(t(locale, "modal.confirm.cancel"))
.size(13)
.shaping(Shaping::Advanced)
)
.on_press(Message::DismissModal)
.padding([6, 16])
.style(cancel_button_style),
Space::with_width(Length::Fill),
button(
text(t(locale, "modal.confirm.confirm"))
.size(13)
.shaping(Shaping::Advanced)
)
.on_press(Message::ConfirmModal(on_confirm_clone))
.padding([6, 16])
.style(confirm_button_style),
];
let content_col = column![
title,
Space::with_height(12.0),
msg,
Space::with_height(16.0),
buttons,
]
.spacing(4);
container(content_col.padding(20))
.width(Length::Fixed(380.0))
.style(modal_box_style)
.into()
}
};
// Center the modal in a full-screen backdrop
container(
container(modal_content)
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.style(backdrop_style)
.into()
}

View File

@@ -3,7 +3,7 @@ use iced::widget::text::Shaping;
use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
use bds_core::model::{Media, Post};
use bds_core::model::{Media, Post, Script, Template};
use crate::app::Message;
use crate::i18n::t;
@@ -72,9 +72,6 @@ const AVG_CHAR_WIDTH_PX: f32 = 6.8;
/// Sidebar padding on each side (12px) plus item padding (6px each side).
const SIDEBAR_TEXT_OVERHEAD_PX: f32 = 36.0;
/// Extra space used by the post status indicator prefix ("○ " etc.).
const STATUS_INDICATOR_CHARS: usize = 2;
/// Truncate a string to fit approximately within `available_px` pixels,
/// appending "…" if truncation occurs.
fn truncate_to_fit(s: &str, available_px: f32) -> String {
@@ -98,10 +95,35 @@ fn truncate_media_title(title: &str) -> String {
}
}
/// Per sidebar_views.allium PostTypeIcon: map first category to emoji.
fn post_type_icon(categories: &[String]) -> &'static str {
for cat in categories {
match cat.to_lowercase().as_str() {
"picture" => return "\u{1F4F7}", // 📷 camera
"article" => return "\u{1F5D2}", // 🗒 notepad
"aside" | "blogmark" => return "\u{1F517}", // 🔗 link
"video" => return "\u{1F3AC}", // 🎬 film
"podcast" => return "\u{1F4AC}", // 💬 speech bubble
_ => {}
}
}
"\u{1F4C4}" // 📄 document (default)
}
/// Per sidebar_views.allium PostDateFormat: "Feb 10, 2026".
fn format_post_date(unix_ms: i64) -> String {
let secs = unix_ms / 1000;
let dt = chrono::DateTime::from_timestamp(secs, 0)
.unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
dt.format("%b %d, %Y").to_string()
}
pub fn view(
sidebar_view: SidebarView,
posts: &[Post],
media: &[Media],
scripts: &[Script],
templates: &[Template],
width: f32,
active_tab: Option<&str>,
locale: UiLocale,
@@ -133,24 +155,33 @@ pub fn view(
let make_post_item = |p: &Post| -> Element<'static, Message> {
let is_active = active_tab == Some(p.id.as_str());
// Per sidebar_views.allium PostTypeIcon
let icon = post_type_icon(&p.categories);
let status_indicator = match p.status {
bds_core::model::PostStatus::Draft => "\u{25CB} ",
bds_core::model::PostStatus::Published => "\u{25CF} ",
bds_core::model::PostStatus::Archived => "\u{25A1} ",
bds_core::model::PostStatus::Draft => "\u{25CB}",
bds_core::model::PostStatus::Published => "\u{25CF}",
bds_core::model::PostStatus::Archived => "\u{25A1}",
};
// Per sidebar_views.allium PostDateFormat
let date = format_post_date(p.created_at);
// Truncate title to fit sidebar width, accounting for
// padding and the status indicator prefix.
// padding and the status/icon prefix.
let prefix_chars: usize = 5; // icon + space + status + space
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX
- (STATUS_INDICATOR_CHARS as f32 * AVG_CHAR_WIDTH_PX);
- (prefix_chars as f32 * AVG_CHAR_WIDTH_PX);
let display_title = truncate_to_fit(&p.title, text_px);
let label = format!("{status_indicator}{display_title}");
let label = format!("{icon} {status_indicator} {display_title}");
let label_text = text(label)
.size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None);
let date_text = text(date)
.size(10)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.50, 0.50, 0.55));
let style_fn = if is_active { item_active_style } else { item_style };
button(
container(label_text)
container(column![label_text, date_text].spacing(1))
.width(Length::Fill)
.clip(true)
)
@@ -254,6 +285,166 @@ pub fn view(
.into()
}
}
SidebarView::Scripts => {
if scripts.is_empty() {
text(t(locale, placeholder_key(sidebar_view)))
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
} else {
let items: Vec<Element<'static, Message>> = scripts
.iter()
.map(|s| {
let is_active = active_tab == Some(s.id.as_str());
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX;
let display_title = truncate_to_fit(&s.title, text_px);
let label_text = text(display_title.clone())
.size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None);
let style_fn = if is_active { item_active_style } else { item_style };
button(
container(label_text)
.width(Length::Fill)
.clip(true)
)
.on_press(Message::OpenTab(Tab {
id: s.id.clone(),
tab_type: TabType::Scripts,
title: display_title,
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(style_fn)
.into()
})
.collect();
iced::widget::Column::with_children(items)
.spacing(1)
.into()
}
}
SidebarView::Templates => {
if templates.is_empty() {
text(t(locale, placeholder_key(sidebar_view)))
.size(12)
.shaping(Shaping::Advanced)
.color(muted)
.into()
} else {
let items: Vec<Element<'static, Message>> = templates
.iter()
.map(|tmpl| {
let is_active = active_tab == Some(tmpl.id.as_str());
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX;
let display_title = truncate_to_fit(&tmpl.title, text_px);
let label_text = text(display_title.clone())
.size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None);
let style_fn = if is_active { item_active_style } else { item_style };
button(
container(label_text)
.width(Length::Fill)
.clip(true)
)
.on_press(Message::OpenTab(Tab {
id: tmpl.id.clone(),
tab_type: TabType::Templates,
title: display_title,
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(style_fn)
.into()
})
.collect();
iced::widget::Column::with_children(items)
.spacing(1)
.into()
}
}
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",
];
let items: Vec<Element<'static, Message>> = sections
.iter()
.map(|key| {
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 {
id: "settings".to_string(),
tab_type: TabType::Settings,
title: t(locale, "common.settings"),
is_transient: false,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(item_style)
.into()
})
.collect();
iced::widget::Column::with_children(items)
.spacing(1)
.into()
}
SidebarView::Tags => {
// Per sidebar_views.allium TagsNav: 3 fixed-order sections
let sections = [
"tags.nav.cloud",
"tags.nav.manage",
"tags.nav.merge",
];
let items: Vec<Element<'static, Message>> = sections
.iter()
.map(|key| {
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 {
id: "tags".to_string(),
tab_type: TabType::Tags,
title: t(locale, "tabBar.tags"),
is_transient: false,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(item_style)
.into()
})
.collect();
iced::widget::Column::with_children(items)
.spacing(1)
.into()
}
_ => {
text(t(locale, placeholder_key(sidebar_view)))
.size(12)

View File

@@ -3,13 +3,13 @@ use iced::widget::text::Shaping;
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
use bds_core::i18n::UiLocale;
use bds_core::model::{Media, Post, Project};
use bds_core::model::{Media, Post, Project, Script, Template};
use crate::app::Message;
use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
use crate::state::tabs::{Tab, TabType};
use crate::state::toast::Toast;
use crate::views::{activity_bar, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome};
use crate::views::{activity_bar, modal, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome};
/// Main content area background.
fn content_bg(_theme: &Theme) -> container::Style {
@@ -55,6 +55,8 @@ pub fn view(
// Sidebar data
sidebar_posts: &[Post],
sidebar_media: &[Media],
sidebar_scripts: &[Script],
sidebar_templates: &[Template],
// Status bar
active_project_name: Option<&str>,
projects: &[Project],
@@ -69,6 +71,8 @@ pub fn view(
locale: UiLocale,
// Toasts
toasts: &[Toast],
// Modal
active_modal: Option<&modal::ModalState>,
) -> Element<'static, Message> {
// Activity bar (leftmost column)
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
@@ -106,7 +110,7 @@ pub fn view(
let mut main_row = row![activity];
if sidebar_visible {
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_width, active_tab, locale));
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, sidebar_width, active_tab, locale));
// Resize drag handle: 4px wide strip between sidebar and content
let handle = container(Space::new(0, 0))
@@ -234,6 +238,11 @@ pub fn view(
overlays.push(overlay);
}
// Modal overlay (highest z-index)
if let Some(modal_state) = active_modal {
overlays.push(modal::view(modal_state, locale));
}
if overlays.is_empty() {
base_layout
} else {