chore: source formatting and spec allignment
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use iced::widget::{button, column, container, svg, text, tooltip, Column, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Column, Space, button, column, container, svg, text, tooltip};
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -40,10 +40,7 @@ const TOP_ACTIVITIES: &[SidebarView] = &[
|
||||
];
|
||||
|
||||
/// Bottom group of activity items.
|
||||
const BOTTOM_ACTIVITIES: &[SidebarView] = &[
|
||||
SidebarView::Git,
|
||||
SidebarView::Settings,
|
||||
];
|
||||
const BOTTOM_ACTIVITIES: &[SidebarView] = &[SidebarView::Git, SidebarView::Settings];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — matching bDS ActivityBar.css
|
||||
@@ -108,7 +105,7 @@ pub fn view(
|
||||
let icon = svg(handle)
|
||||
.width(Length::Fixed(24.0))
|
||||
.height(Length::Fixed(24.0))
|
||||
.opacity(if is_active { 1.0 } else { 0.4 });
|
||||
.opacity(if is_active { 1.0_f32 } else { 0.4_f32 });
|
||||
|
||||
let btn = button(
|
||||
container(icon)
|
||||
@@ -119,7 +116,11 @@ pub fn view(
|
||||
.height(Length::Fixed(48.0))
|
||||
.padding(0)
|
||||
.on_press(Message::SetActiveView(view))
|
||||
.style(if is_active { active_button_style } else { inactive_button_style });
|
||||
.style(if is_active {
|
||||
active_button_style
|
||||
} else {
|
||||
inactive_button_style
|
||||
});
|
||||
|
||||
// Active indicator: 2px left border (like bDS/VS Code)
|
||||
let btn_row: Element<'static, Message> = if is_active {
|
||||
@@ -138,33 +139,29 @@ pub fn view(
|
||||
|
||||
// Wrap in tooltip per layout.allium ActivityButton.label_key
|
||||
let tip_text = t(locale, view.i18n_key());
|
||||
tooltip(btn_row, text(tip_text).size(12).shaping(Shaping::Advanced), tooltip::Position::Right)
|
||||
.gap(4)
|
||||
.into()
|
||||
tooltip(
|
||||
btn_row,
|
||||
text(tip_text).size(12).shaping(Shaping::Advanced),
|
||||
tooltip::Position::Right,
|
||||
)
|
||||
.gap(4)
|
||||
.into()
|
||||
};
|
||||
|
||||
let top_items: Vec<Element<'static, Message>> = TOP_ACTIVITIES
|
||||
.iter()
|
||||
.map(|v| make_btn(*v))
|
||||
.collect();
|
||||
let top_items: Vec<Element<'static, Message>> =
|
||||
TOP_ACTIVITIES.iter().map(|v| make_btn(*v)).collect();
|
||||
|
||||
let bottom_items: Vec<Element<'static, Message>> = BOTTOM_ACTIVITIES
|
||||
.iter()
|
||||
.map(|v| make_btn(*v))
|
||||
.collect();
|
||||
let bottom_items: Vec<Element<'static, Message>> =
|
||||
BOTTOM_ACTIVITIES.iter().map(|v| make_btn(*v)).collect();
|
||||
|
||||
let top = Column::with_children(top_items).spacing(0);
|
||||
let bottom = Column::with_children(bottom_items).spacing(0);
|
||||
|
||||
container(
|
||||
column![
|
||||
top,
|
||||
Space::with_height(Length::Fill),
|
||||
bottom,
|
||||
]
|
||||
.width(Length::Fixed(50.0))
|
||||
.height(Length::Fill)
|
||||
.padding([4, 0]),
|
||||
column![top, Space::with_height(Length::Fill), bottom,]
|
||||
.width(Length::Fixed(50.0))
|
||||
.height(Length::Fill)
|
||||
.padding([4, 0]),
|
||||
)
|
||||
.width(Length::Fixed(50.0))
|
||||
.height(Length::Fill)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use iced::widget::{button, column, container, row, scrollable, text, tooltip, Space};
|
||||
use iced::widget::{Space, button, column, container, row, scrollable, text, tooltip};
|
||||
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -88,13 +88,8 @@ impl DashboardState {
|
||||
}
|
||||
|
||||
/// Render the dashboard overview.
|
||||
pub fn view<'a>(
|
||||
state: &'a DashboardState,
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
let header = text(state.title.clone())
|
||||
.size(24)
|
||||
.color(Color::WHITE);
|
||||
pub fn view<'a>(state: &'a DashboardState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let header = text(state.title.clone()).size(24).color(Color::WHITE);
|
||||
|
||||
let project_label = text(state.subtitle.clone())
|
||||
.size(14)
|
||||
@@ -106,11 +101,23 @@ pub fn view<'a>(
|
||||
state.stats.total_posts.to_string(),
|
||||
{
|
||||
let mut details = vec![
|
||||
format!("{} {}", state.stats.published_count, t(locale, "dashboard.published")),
|
||||
format!("{} {}", state.stats.draft_count, t(locale, "dashboard.drafts")),
|
||||
format!(
|
||||
"{} {}",
|
||||
state.stats.published_count,
|
||||
t(locale, "dashboard.published")
|
||||
),
|
||||
format!(
|
||||
"{} {}",
|
||||
state.stats.draft_count,
|
||||
t(locale, "dashboard.drafts")
|
||||
),
|
||||
];
|
||||
if state.stats.archived_count > 0 {
|
||||
details.push(format!("{} {}", state.stats.archived_count, t(locale, "dashboard.archived")));
|
||||
details.push(format!(
|
||||
"{} {}",
|
||||
state.stats.archived_count,
|
||||
t(locale, "dashboard.archived")
|
||||
));
|
||||
}
|
||||
details
|
||||
},
|
||||
@@ -119,14 +126,22 @@ pub fn view<'a>(
|
||||
&t(locale, "dashboard.media"),
|
||||
state.stats.media_count.to_string(),
|
||||
vec![
|
||||
format!("{} {}", state.stats.image_count, t(locale, "dashboard.images")),
|
||||
format!(
|
||||
"{} {}",
|
||||
state.stats.image_count,
|
||||
t(locale, "dashboard.images")
|
||||
),
|
||||
state.stats.total_media_size.clone(),
|
||||
],
|
||||
),
|
||||
stat_card(
|
||||
&t(locale, "dashboard.tags"),
|
||||
state.stats.tag_count.to_string(),
|
||||
vec![format!("{} {}", state.stats.category_count, t(locale, "dashboard.categories"))],
|
||||
vec![format!(
|
||||
"{} {}",
|
||||
state.stats.category_count,
|
||||
t(locale, "dashboard.categories")
|
||||
)],
|
||||
),
|
||||
]
|
||||
.spacing(16);
|
||||
@@ -166,7 +181,12 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
|
||||
container(
|
||||
column(
|
||||
std::iter::once(text(value).size(28).color(Color::WHITE).into())
|
||||
.chain(std::iter::once(text(label.to_string()).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)).into()))
|
||||
.chain(std::iter::once(
|
||||
text(label.to_string())
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.55, 0.58, 0.65))
|
||||
.into(),
|
||||
))
|
||||
.chain(details.into_iter().map(|detail| {
|
||||
text(detail)
|
||||
.size(12)
|
||||
@@ -191,65 +211,84 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
|
||||
.into()
|
||||
}
|
||||
|
||||
fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], _locale: UiLocale) -> Element<'a, Message> {
|
||||
let max_count = months.iter().map(|month| month.count).max().unwrap_or(1).max(1);
|
||||
row(
|
||||
months
|
||||
.iter()
|
||||
.map(|month| {
|
||||
let height = if month.count == 0 {
|
||||
8.0
|
||||
} else {
|
||||
24.0 + (month.count as f32 / max_count as f32) * 96.0
|
||||
};
|
||||
container(
|
||||
column![
|
||||
text(month.count.to_string()).size(12).color(Color::WHITE),
|
||||
container(Space::with_height(height))
|
||||
.width(Length::Fill)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))),
|
||||
border: iced::Border { radius: 6.0.into(), ..iced::Border::default() },
|
||||
..container::Style::default()
|
||||
}),
|
||||
text(format!("{} {}", month.label, month.year)).size(11).color(Color::from_rgb(0.7, 0.72, 0.78)),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.width(Length::FillPortion(1))
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
fn timeline_chart<'a>(
|
||||
months: &'a [DashboardTimelineMonth],
|
||||
_locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
let max_count = months
|
||||
.iter()
|
||||
.map(|month| month.count)
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
row(months
|
||||
.iter()
|
||||
.map(|month| {
|
||||
let height = if month.count == 0 {
|
||||
8.0
|
||||
} else {
|
||||
24.0 + (month.count as f32 / max_count as f32) * 96.0
|
||||
};
|
||||
container(
|
||||
column![
|
||||
text(month.count.to_string()).size(12).color(Color::WHITE),
|
||||
container(Space::with_height(height))
|
||||
.width(Length::Fill)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))),
|
||||
border: iced::Border {
|
||||
radius: 6.0.into(),
|
||||
..iced::Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
}),
|
||||
text(format!("{} {}", month.label, month.year))
|
||||
.size(11)
|
||||
.color(Color::from_rgb(0.7, 0.72, 0.78)),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.width(Length::FillPortion(1))
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(10)
|
||||
.align_y(Alignment::End)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLocale) -> Element<'a, Message> {
|
||||
fn tag_cloud<'a>(
|
||||
tags: &'a [DashboardTag],
|
||||
overflow_count: usize,
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
let cloud = if tags.is_empty() {
|
||||
row![text(t(locale, "dashboard.noTags")).size(13).color(Color::from_rgb(0.6, 0.62, 0.68))]
|
||||
.spacing(8)
|
||||
.wrap()
|
||||
row![
|
||||
text(t(locale, "dashboard.noTags"))
|
||||
.size(13)
|
||||
.color(Color::from_rgb(0.6, 0.62, 0.68))
|
||||
]
|
||||
.spacing(8)
|
||||
.wrap()
|
||||
} else {
|
||||
let words = tags
|
||||
.iter()
|
||||
.map(|tag| {
|
||||
let bg = parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
|
||||
let bg =
|
||||
parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
|
||||
let fg = contrast_color(bg);
|
||||
tooltip(
|
||||
container(
|
||||
text(tag.name.clone())
|
||||
.size(scale_font(tag.count))
|
||||
.color(fg),
|
||||
)
|
||||
.padding([6, 10])
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
|
||||
..container::Style::default()
|
||||
}),
|
||||
container(text(tag.name.clone()).size(scale_font(tag.count)).color(fg))
|
||||
.padding([6, 10])
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
border: iced::Border {
|
||||
radius: 999.0.into(),
|
||||
..iced::Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
}),
|
||||
text(format!("{} {}", tag.name, tag.count)).size(12),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
@@ -269,30 +308,49 @@ fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLoca
|
||||
]
|
||||
.spacing(8)
|
||||
} else {
|
||||
column![inputs::section_header(&t(locale, "dashboard.tagCloud")), cloud].spacing(8)
|
||||
column![
|
||||
inputs::section_header(&t(locale, "dashboard.tagCloud")),
|
||||
cloud
|
||||
]
|
||||
.spacing(8)
|
||||
};
|
||||
|
||||
container(content).width(Length::FillPortion(1)).into()
|
||||
}
|
||||
|
||||
fn category_cloud<'a>(categories: &'a [DashboardCategory], locale: UiLocale) -> Element<'a, Message> {
|
||||
let items = categories.iter().fold(column![inputs::section_header(&t(locale, "dashboard.categoryCloud"))].spacing(8), |column, category| {
|
||||
column.push(
|
||||
container(
|
||||
row![
|
||||
text(category.name.clone()).size(13).color(Color::WHITE),
|
||||
text(category.count.to_string()).size(12).color(Color::from_rgb(0.72, 0.74, 0.80)),
|
||||
]
|
||||
.spacing(8),
|
||||
fn category_cloud<'a>(
|
||||
categories: &'a [DashboardCategory],
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
let items = categories.iter().fold(
|
||||
column![inputs::section_header(&t(
|
||||
locale,
|
||||
"dashboard.categoryCloud"
|
||||
))]
|
||||
.spacing(8),
|
||||
|column, category| {
|
||||
column.push(
|
||||
container(
|
||||
row![
|
||||
text(category.name.clone()).size(13).color(Color::WHITE),
|
||||
text(category.count.to_string())
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.72, 0.74, 0.80)),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding([6, 10])
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
||||
border: iced::Border {
|
||||
radius: 999.0.into(),
|
||||
..iced::Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
}),
|
||||
)
|
||||
.padding([6, 10])
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
||||
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
|
||||
..container::Style::default()
|
||||
}),
|
||||
)
|
||||
});
|
||||
},
|
||||
);
|
||||
container(items).width(Length::FillPortion(1)).into()
|
||||
}
|
||||
|
||||
@@ -313,7 +371,9 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
|
||||
button(
|
||||
column![
|
||||
text(post.title.clone()).size(14).color(Color::WHITE),
|
||||
text(post.date.clone()).size(12).color(Color::from_rgb(0.6, 0.62, 0.68)),
|
||||
text(post.date.clone())
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.6, 0.62, 0.68)),
|
||||
]
|
||||
.spacing(4),
|
||||
)
|
||||
@@ -342,7 +402,10 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
|
||||
.padding(12)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.13, 0.14, 0.18))),
|
||||
border: iced::Border { radius: 8.0.into(), ..iced::Border::default() },
|
||||
border: iced::Border {
|
||||
radius: 8.0.into(),
|
||||
..iced::Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
@@ -363,7 +426,10 @@ fn status_badge<'a>(status: &str) -> Element<'a, Message> {
|
||||
.padding([4, 8])
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
|
||||
border: iced::Border {
|
||||
radius: 999.0.into(),
|
||||
..iced::Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
@@ -387,5 +453,9 @@ fn parse_color(value: Option<&str>) -> Option<Color> {
|
||||
|
||||
fn contrast_color(background: Color) -> Color {
|
||||
let luma = 0.299 * background.r + 0.587 * background.g + 0.114 * background.b;
|
||||
if luma > 0.55 { Color::BLACK } else { Color::WHITE }
|
||||
if luma > 0.55 {
|
||||
Color::BLACK
|
||||
} else {
|
||||
Color::WHITE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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::widget::{Space, button, column, container, image, row, scrollable, text};
|
||||
use iced::{Color, Element, Length};
|
||||
|
||||
use bds_core::i18n::{self, UiLocale};
|
||||
@@ -72,12 +72,15 @@ impl MediaEditorState {
|
||||
|
||||
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,
|
||||
});
|
||||
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 {
|
||||
@@ -125,12 +128,15 @@ impl MediaEditorState {
|
||||
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,
|
||||
});
|
||||
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 {
|
||||
@@ -225,24 +231,28 @@ pub fn view<'a>(
|
||||
ai_enabled: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let header = inputs::toolbar(
|
||||
vec![
|
||||
text(state.original_name.clone()).size(18).into(),
|
||||
],
|
||||
vec![text(state.original_name.clone()).size(18).into()],
|
||||
vec![
|
||||
if state.mime_type.starts_with("image/") {
|
||||
button(text(t(locale, "editor.aiAnalyze")).size(13))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)))
|
||||
.on_press_maybe(
|
||||
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)),
|
||||
)
|
||||
.padding([6, 16])
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
},
|
||||
button(text(t(locale, "editor.detectLanguage")).size(13))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)))
|
||||
.on_press_maybe(
|
||||
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)),
|
||||
)
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "editor.translate")).size(13))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)))
|
||||
.on_press_maybe(
|
||||
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)),
|
||||
)
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "common.save")).size(13))
|
||||
@@ -273,7 +283,9 @@ pub fn view<'a>(
|
||||
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())))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage(
|
||||
flag.language.clone(),
|
||||
)))
|
||||
.padding([4, 8])
|
||||
.style(|_: &iced::Theme, _| button::Style::default())
|
||||
.into()
|
||||
@@ -295,13 +307,13 @@ pub fn view<'a>(
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
no_preview()
|
||||
no_preview(locale)
|
||||
}
|
||||
} else {
|
||||
no_preview()
|
||||
no_preview(locale)
|
||||
}
|
||||
} else {
|
||||
no_preview()
|
||||
no_preview(locale)
|
||||
};
|
||||
|
||||
// File info
|
||||
@@ -311,9 +323,12 @@ pub fn view<'a>(
|
||||
};
|
||||
let size_str = format_file_size(state.size);
|
||||
let info = row![
|
||||
text(format!("{} • {} • {}", state.mime_type, size_str, dimensions))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.55, 0.58, 0.65)),
|
||||
text(format!(
|
||||
"{} • {} • {}",
|
||||
state.mime_type, size_str, dimensions
|
||||
))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.55, 0.58, 0.65)),
|
||||
]
|
||||
.padding(8);
|
||||
|
||||
@@ -332,18 +347,13 @@ pub fn view<'a>(
|
||||
);
|
||||
let meta_row1 = row![title_input, alt_input].spacing(16).width(Length::Fill);
|
||||
|
||||
let caption_input = inputs::labeled_input(
|
||||
&t(locale, "editor.caption"),
|
||||
"",
|
||||
&state.caption,
|
||||
|s| Message::MediaEditor(MediaEditorMsg::CaptionChanged(s)),
|
||||
);
|
||||
let author_input = inputs::labeled_input(
|
||||
&t(locale, "editor.author"),
|
||||
"",
|
||||
&state.author,
|
||||
|s| Message::MediaEditor(MediaEditorMsg::AuthorChanged(s)),
|
||||
);
|
||||
let caption_input =
|
||||
inputs::labeled_input(&t(locale, "editor.caption"), "", &state.caption, |s| {
|
||||
Message::MediaEditor(MediaEditorMsg::CaptionChanged(s))
|
||||
});
|
||||
let author_input = inputs::labeled_input(&t(locale, "editor.author"), "", &state.author, |s| {
|
||||
Message::MediaEditor(MediaEditorMsg::AuthorChanged(s))
|
||||
});
|
||||
let tags_input = inputs::labeled_input(
|
||||
&t(locale, "editor.tags"),
|
||||
&t(locale, "editor.tagsPlaceholder"),
|
||||
@@ -361,8 +371,12 @@ pub fn view<'a>(
|
||||
Some(&state.language),
|
||||
|lang| Message::MediaEditor(MediaEditorMsg::LanguageChanged(lang)),
|
||||
);
|
||||
let meta_row2 = row![caption_input, author_input].spacing(16).width(Length::Fill);
|
||||
let meta_row3 = row![tags_input, language_input].spacing(16).width(Length::Fill);
|
||||
let meta_row2 = row![caption_input, author_input]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
let meta_row3 = row![tags_input, language_input]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
let linked_posts_header = row![
|
||||
text(t(locale, "editor.linkedPosts"))
|
||||
@@ -396,24 +410,30 @@ pub fn view<'a>(
|
||||
.iter()
|
||||
.map(|post| {
|
||||
button(text(post.title.clone()).size(12))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::LinkPost(post.post_id.clone())))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::LinkPost(
|
||||
post.post_id.clone(),
|
||||
)))
|
||||
.padding([4, 10])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
container(column![search, column(results).spacing(4)].spacing(8).padding(8))
|
||||
.style(|_: &iced::Theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
||||
border: iced::Border {
|
||||
color: Color::from_rgb(0.28, 0.28, 0.32),
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..iced::widget::container::Style::default()
|
||||
})
|
||||
.into()
|
||||
container(
|
||||
column![search, column(results).spacing(4)]
|
||||
.spacing(8)
|
||||
.padding(8),
|
||||
)
|
||||
.style(|_: &iced::Theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
||||
border: iced::Border {
|
||||
color: Color::from_rgb(0.28, 0.28, 0.32),
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..iced::widget::container::Style::default()
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
@@ -430,11 +450,15 @@ pub fn view<'a>(
|
||||
.map(|post| {
|
||||
row![
|
||||
button(text(post.title.clone()).size(12))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost(post.post_id.clone())))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost(
|
||||
post.post_id.clone()
|
||||
)))
|
||||
.padding([4, 0]),
|
||||
Space::with_width(Length::Fill),
|
||||
button(text(t(locale, "editor.unlinkMedia")).size(11))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost(post.post_id.clone())))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost(
|
||||
post.post_id.clone()
|
||||
)))
|
||||
.padding([3, 8])
|
||||
.style(inputs::danger_button),
|
||||
]
|
||||
@@ -471,7 +495,7 @@ pub fn view<'a>(
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
|
||||
container(body)
|
||||
@@ -480,6 +504,29 @@ pub fn view<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
fn no_preview<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||
container(
|
||||
text(t(locale, "editor.noPreviewAvailable"))
|
||||
.size(14)
|
||||
.color(Color::from_rgb(0.5, 0.5, 0.5)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(200.0))
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fixed(200.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn format_file_size(bytes: i64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{bytes} B")
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MediaEditorState;
|
||||
@@ -519,30 +566,10 @@ mod tests {
|
||||
);
|
||||
|
||||
let flags = state.translation_flags();
|
||||
let languages = flags.into_iter().map(|flag| flag.language).collect::<Vec<_>>();
|
||||
let languages = flags
|
||||
.into_iter()
|
||||
.map(|flag| flag.language)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(languages, vec!["en".to_string(), "de".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn no_preview<'a>() -> Element<'a, Message> {
|
||||
container(
|
||||
text("No preview available")
|
||||
.size(14)
|
||||
.color(Color::from_rgb(0.5, 0.5, 0.5)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(200.0))
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fixed(200.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn format_file_size(bytes: i64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{bytes} B")
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
pub mod workspace;
|
||||
pub mod activity_bar;
|
||||
pub mod sidebar;
|
||||
pub mod tab_bar;
|
||||
pub mod status_bar;
|
||||
pub mod dashboard;
|
||||
pub mod media_editor;
|
||||
pub mod modal;
|
||||
pub mod panel;
|
||||
pub mod post_editor;
|
||||
pub mod project_selector;
|
||||
pub mod script_editor;
|
||||
pub mod settings_view;
|
||||
pub mod sidebar;
|
||||
pub mod site_validation;
|
||||
pub mod status_bar;
|
||||
pub mod tab_bar;
|
||||
pub mod tags_view;
|
||||
pub mod template_editor;
|
||||
pub mod toast;
|
||||
pub mod welcome;
|
||||
pub mod modal;
|
||||
pub mod post_editor;
|
||||
pub mod media_editor;
|
||||
pub mod template_editor;
|
||||
pub mod script_editor;
|
||||
pub mod tags_view;
|
||||
pub mod settings_view;
|
||||
pub mod dashboard;
|
||||
pub mod site_validation;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::path::Path;
|
||||
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{button, checkbox, column, container, image, row, scrollable, text, text_input, Space};
|
||||
use iced::widget::{
|
||||
Space, button, checkbox, column, container, image, row, scrollable, text, text_input,
|
||||
};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -112,7 +114,10 @@ pub enum ConfirmAction {
|
||||
DeleteTemplate(String),
|
||||
ForceDeleteTemplate(String),
|
||||
DeleteTag(String),
|
||||
MergeTags { sources: Vec<String>, target: String },
|
||||
MergeTags {
|
||||
sources: Vec<String>,
|
||||
target: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Modal backdrop style ──
|
||||
@@ -231,7 +236,11 @@ fn gallery_step(current: usize, len: usize, delta: isize) -> usize {
|
||||
}
|
||||
|
||||
/// Render the modal overlay.
|
||||
pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Element<'static, Message> {
|
||||
pub fn view(
|
||||
state: ModalState,
|
||||
locale: UiLocale,
|
||||
data_dir: Option<&Path>,
|
||||
) -> Element<'static, Message> {
|
||||
let modal_content: Element<'static, Message> = match state {
|
||||
ModalState::ConfirmDelete {
|
||||
entity_name,
|
||||
@@ -452,14 +461,18 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
}),
|
||||
)
|
||||
.padding([3, 8])
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
width: 1.0,
|
||||
radius: 999.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
.style(|_: &Theme| {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(
|
||||
0.18, 0.20, 0.25,
|
||||
))),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
width: 1.0,
|
||||
radius: 999.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
}
|
||||
}),
|
||||
]
|
||||
.spacing(12)
|
||||
@@ -505,7 +518,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
button(text(t(locale, "modal.postInsertLink.insert")))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkExternalInsert))
|
||||
.on_press(Message::PostEditor(
|
||||
PostEditorMsg::PostInsertLinkExternalInsert
|
||||
))
|
||||
.padding([6, 16])
|
||||
.style(confirm_button_style),
|
||||
]
|
||||
@@ -537,11 +552,12 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced);
|
||||
|
||||
let trailing_button: Element<'static, Message> = if active_tab == PostInsertLinkTab::Internal {
|
||||
create_post_btn
|
||||
} else {
|
||||
Space::with_width(0.0).into()
|
||||
};
|
||||
let trailing_button: Element<'static, Message> =
|
||||
if active_tab == PostInsertLinkTab::Internal {
|
||||
create_post_btn
|
||||
} else {
|
||||
Space::with_width(0.0).into()
|
||||
};
|
||||
|
||||
let buttons = row![
|
||||
button(cancel_text)
|
||||
@@ -597,31 +613,27 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let is_image = m.mime_type.starts_with("image/");
|
||||
let title = m
|
||||
.title
|
||||
.as_ref()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let title = m.title.as_deref().unwrap_or("").to_string();
|
||||
let original_name = m.original_name.clone();
|
||||
let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
||||
image(path)
|
||||
.width(Length::Fixed(120.0))
|
||||
.height(Length::Fixed(90.0))
|
||||
.into()
|
||||
} else if is_image {
|
||||
text("\u{1F5BC}")
|
||||
.size(32)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.40, 0.40, 0.45))
|
||||
.into()
|
||||
} else {
|
||||
text("\u{1F4C4}")
|
||||
.size(32)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.40, 0.40, 0.45))
|
||||
.into()
|
||||
};
|
||||
let thumb: Element<'static, Message> =
|
||||
if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
||||
image(path)
|
||||
.width(Length::Fixed(120.0))
|
||||
.height(Length::Fixed(90.0))
|
||||
.into()
|
||||
} else if is_image {
|
||||
text("\u{1F5BC}")
|
||||
.size(32)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.40, 0.40, 0.45))
|
||||
.into()
|
||||
} else {
|
||||
text("\u{1F4C4}")
|
||||
.size(32)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.40, 0.40, 0.45))
|
||||
.into()
|
||||
};
|
||||
|
||||
let media_title = text(title.clone())
|
||||
.size(12)
|
||||
@@ -716,10 +728,12 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced);
|
||||
|
||||
let buttons = row![button(cancel_text)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),];
|
||||
let buttons = row![
|
||||
button(cancel_text)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
];
|
||||
|
||||
let content = column![
|
||||
title,
|
||||
@@ -754,24 +768,20 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, m)| {
|
||||
let title = m
|
||||
.title
|
||||
.as_ref()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
||||
image(path)
|
||||
.width(Length::Fixed(180.0))
|
||||
.height(Length::Fixed(120.0))
|
||||
.into()
|
||||
} else {
|
||||
text("\u{1F5BC}")
|
||||
.size(32)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.40, 0.40, 0.45))
|
||||
.into()
|
||||
};
|
||||
let title = m.title.as_deref().unwrap_or("").to_string();
|
||||
let thumb: Element<'static, Message> =
|
||||
if let Some(path) = resolve_thumbnail_file(data_dir, m) {
|
||||
image(path)
|
||||
.width(Length::Fixed(180.0))
|
||||
.height(Length::Fixed(120.0))
|
||||
.into()
|
||||
} else {
|
||||
text("\u{1F5BC}")
|
||||
.size(32)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.40, 0.40, 0.45))
|
||||
.into()
|
||||
};
|
||||
|
||||
let media_title = text(title)
|
||||
.size(12)
|
||||
@@ -845,17 +855,18 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
|
||||
let lightbox: Element<'static, Message> = if let Some(index) = selected_index {
|
||||
if let Some(selected) = image_media.get(index) {
|
||||
let preview: Element<'static, Message> = if let Some(path) = resolve_media_file(data_dir, selected) {
|
||||
image(path)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(420.0))
|
||||
.into()
|
||||
} else {
|
||||
text(t(locale, "modal.postGallery.unavailable"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into()
|
||||
};
|
||||
let preview: Element<'static, Message> =
|
||||
if let Some(path) = resolve_media_file(data_dir, selected) {
|
||||
image(path)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(420.0))
|
||||
.into()
|
||||
} else {
|
||||
text(t(locale, "modal.postGallery.unavailable"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into()
|
||||
};
|
||||
column![
|
||||
container(preview)
|
||||
.width(Length::Fill)
|
||||
@@ -876,7 +887,9 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(12.0),
|
||||
button(text(t(locale, "modal.postGallery.backToGrid")))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostGalleryCloseLightbox))
|
||||
.on_press(Message::PostEditor(
|
||||
PostEditorMsg::PostGalleryCloseLightbox
|
||||
))
|
||||
.padding([6, 12])
|
||||
.style(cancel_button_style),
|
||||
]
|
||||
@@ -915,35 +928,53 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
|
||||
let rows = fields.iter().enumerate().map(|(index, field)| {
|
||||
let toggle = checkbox(field.label.clone(), field.accepted)
|
||||
.on_toggle_maybe((!field.locked)
|
||||
.then_some(move |value| Message::ToggleAiSuggestionField(index, value)))
|
||||
.size(16)
|
||||
.text_size(13);
|
||||
container(column![
|
||||
toggle,
|
||||
row![
|
||||
container(text(field.current_value.clone()).size(12).color(Color::from_rgb(0.58, 0.58, 0.64))).width(Length::FillPortion(1)),
|
||||
text("→").size(14).color(Color::from_rgb(0.62, 0.66, 0.74)),
|
||||
container(text(field.suggested_value.clone()).size(12).color(Color::WHITE)).width(Length::FillPortion(1)),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(8))
|
||||
.padding(10)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
let rows = fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, field)| {
|
||||
let toggle =
|
||||
checkbox(field.label.clone(), field.accepted)
|
||||
.on_toggle_maybe((!field.locked).then_some(move |value| {
|
||||
Message::ToggleAiSuggestionField(index, value)
|
||||
}))
|
||||
.size(16)
|
||||
.text_size(13);
|
||||
container(
|
||||
column![
|
||||
toggle,
|
||||
row![
|
||||
container(
|
||||
text(field.current_value.clone())
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.58, 0.58, 0.64))
|
||||
)
|
||||
.width(Length::FillPortion(1)),
|
||||
text("→").size(14).color(Color::from_rgb(0.62, 0.66, 0.74)),
|
||||
container(
|
||||
text(field.suggested_value.clone())
|
||||
.size(12)
|
||||
.color(Color::WHITE)
|
||||
)
|
||||
.width(Length::FillPortion(1)),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(10)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
})
|
||||
.into()
|
||||
}).collect::<Vec<Element<'static, Message>>>();
|
||||
.collect::<Vec<Element<'static, Message>>>();
|
||||
|
||||
let buttons = row![
|
||||
button(text(t(locale, "common.cancel")).size(13))
|
||||
@@ -972,35 +1003,59 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::LanguagePicker { target, source_language, available_targets } => {
|
||||
ModalState::LanguagePicker {
|
||||
target,
|
||||
source_language,
|
||||
available_targets,
|
||||
} => {
|
||||
let title = text(t(locale, "modal.languagePicker.title"))
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
let subtitle = text(format!("{}: {}", t(locale, "editor.language"), source_language))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.68));
|
||||
let subtitle = text(format!(
|
||||
"{}: {}",
|
||||
t(locale, "editor.language"),
|
||||
source_language
|
||||
))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.68));
|
||||
|
||||
let rows = if available_targets.is_empty() {
|
||||
vec![text(t(locale, "common.noResults")).size(12).into()]
|
||||
} else {
|
||||
available_targets.into_iter().map(|language| {
|
||||
let message = match &target {
|
||||
AiEntityTarget::Post(_) => Message::PostEditor(PostEditorMsg::TranslateTo(language.code.clone())),
|
||||
AiEntityTarget::Media(_) => Message::MediaEditor(crate::views::media_editor::MediaEditorMsg::TranslateTo(language.code.clone())),
|
||||
};
|
||||
let status = language.existing_status.clone().unwrap_or_default();
|
||||
button(row![
|
||||
text(format!("{} {}", language.flag_emoji, language.name)).size(13).color(Color::WHITE),
|
||||
Space::with_width(Length::Fill),
|
||||
text(status).size(11).color(Color::from_rgb(0.60, 0.60, 0.68)),
|
||||
].align_y(Alignment::Center))
|
||||
.on_press(message)
|
||||
.padding([8, 12])
|
||||
.style(cancel_button_style)
|
||||
.into()
|
||||
}).collect::<Vec<Element<'static, Message>>>()
|
||||
available_targets
|
||||
.into_iter()
|
||||
.map(|language| {
|
||||
let message = match &target {
|
||||
AiEntityTarget::Post(_) => Message::PostEditor(
|
||||
PostEditorMsg::TranslateTo(language.code.clone()),
|
||||
),
|
||||
AiEntityTarget::Media(_) => Message::MediaEditor(
|
||||
crate::views::media_editor::MediaEditorMsg::TranslateTo(
|
||||
language.code.clone(),
|
||||
),
|
||||
),
|
||||
};
|
||||
let status = language.existing_status.clone().unwrap_or_default();
|
||||
button(
|
||||
row![
|
||||
text(format!("{} {}", language.flag_emoji, language.name))
|
||||
.size(13)
|
||||
.color(Color::WHITE),
|
||||
Space::with_width(Length::Fill),
|
||||
text(status)
|
||||
.size(11)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.68)),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.on_press(message)
|
||||
.padding([8, 12])
|
||||
.style(cancel_button_style)
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<Element<'static, Message>>>()
|
||||
};
|
||||
|
||||
let content = column![
|
||||
@@ -1042,7 +1097,10 @@ mod tests {
|
||||
#[test]
|
||||
fn external_link_markdown_requires_url() {
|
||||
assert_eq!(external_link_markdown("", "text"), None);
|
||||
assert_eq!(external_link_markdown("https://example.com", ""), Some("https://example.com".to_string()));
|
||||
assert_eq!(
|
||||
external_link_markdown("https://example.com", ""),
|
||||
Some("https://example.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
external_link_markdown("https://example.com", "Example"),
|
||||
Some("[Example](https://example.com)".to_string())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use iced::widget::{button, column, container, scrollable, text, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Space, button, column, container, scrollable, text};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -64,6 +64,10 @@ fn close_btn_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "arguments are independent panel state slices"
|
||||
)]
|
||||
pub fn view(
|
||||
panel_tab: PanelTab,
|
||||
task_snapshots: &[TaskSnapshot],
|
||||
@@ -79,39 +83,68 @@ pub fn view(
|
||||
// Tab header — per layout.allium: tasks, output, post_links (only when
|
||||
// active editor tab is a post), git_log (only when active tab is post or
|
||||
// media).
|
||||
let tasks_btn = button(text(t(locale, "common.tasks")).size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::SetPanelTab(PanelTab::Tasks))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::Tasks { tab_active } else { tab_inactive });
|
||||
let tasks_btn = button(
|
||||
text(t(locale, "common.tasks"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::SetPanelTab(PanelTab::Tasks))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::Tasks {
|
||||
tab_active
|
||||
} else {
|
||||
tab_inactive
|
||||
});
|
||||
|
||||
let output_btn = button(text(t(locale, "panel.output")).size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::SetPanelTab(PanelTab::Output))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::Output { tab_active } else { tab_inactive });
|
||||
let output_btn = button(
|
||||
text(t(locale, "panel.output"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::SetPanelTab(PanelTab::Output))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::Output {
|
||||
tab_active
|
||||
} else {
|
||||
tab_inactive
|
||||
});
|
||||
|
||||
let close_btn = button(text("\u{2715}").size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::TogglePanel)
|
||||
.padding([4, 6])
|
||||
.style(close_btn_style);
|
||||
|
||||
let mut tab_row: Vec<Element<'static, Message>> = vec![
|
||||
tasks_btn.into(),
|
||||
output_btn.into(),
|
||||
];
|
||||
let mut tab_row: Vec<Element<'static, Message>> = vec![tasks_btn.into(), output_btn.into()];
|
||||
|
||||
if active_tab_is_post {
|
||||
let post_links_btn = button(text(t(locale, "panel.postLinks")).size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::SetPanelTab(PanelTab::PostLinks))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::PostLinks { tab_active } else { tab_inactive });
|
||||
let post_links_btn = button(
|
||||
text(t(locale, "panel.postLinks"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::SetPanelTab(PanelTab::PostLinks))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::PostLinks {
|
||||
tab_active
|
||||
} else {
|
||||
tab_inactive
|
||||
});
|
||||
tab_row.push(post_links_btn.into());
|
||||
}
|
||||
|
||||
if active_tab_is_post_or_media {
|
||||
let git_log_btn = button(text(t(locale, "panel.gitLog")).size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::SetPanelTab(PanelTab::GitLog))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::GitLog { tab_active } else { tab_inactive });
|
||||
let git_log_btn = button(
|
||||
text(t(locale, "panel.gitLog"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::SetPanelTab(PanelTab::GitLog))
|
||||
.padding([4, 8])
|
||||
.style(if panel_tab == PanelTab::GitLog {
|
||||
tab_active
|
||||
} else {
|
||||
tab_inactive
|
||||
});
|
||||
tab_row.push(git_log_btn.into());
|
||||
}
|
||||
|
||||
@@ -127,9 +160,14 @@ pub fn view(
|
||||
let content: Element<'static, Message> = match panel_tab {
|
||||
PanelTab::Tasks => {
|
||||
if task_snapshots.is_empty() {
|
||||
container(text(t(locale, "tasks.noActive")).size(12).shaping(Shaping::Advanced).color(muted))
|
||||
.padding(8)
|
||||
.into()
|
||||
container(
|
||||
text(t(locale, "tasks.noActive"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted),
|
||||
)
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
// Per layout.allium: last 10 tasks, newest first
|
||||
let items: Vec<Element<'static, Message>> = task_snapshots
|
||||
@@ -137,21 +175,24 @@ pub fn view(
|
||||
.rev()
|
||||
.take(10)
|
||||
.map(|snap| {
|
||||
let progress_str = snap.progress
|
||||
let progress_str = snap
|
||||
.progress
|
||||
.map(|p| format!(" ({:.0}%)", p * 100.0))
|
||||
.unwrap_or_default();
|
||||
let phase_str = snap.message
|
||||
let phase_str = snap
|
||||
.message
|
||||
.as_deref()
|
||||
.map(|m| format!(" \u{2014} {m}"))
|
||||
.unwrap_or_default();
|
||||
let status_text = format!(
|
||||
"{} \u{2014} {}{}{}",
|
||||
snap.label,
|
||||
snap.status,
|
||||
progress_str,
|
||||
phase_str,
|
||||
snap.label, snap.status, progress_str, phase_str,
|
||||
);
|
||||
text(status_text).size(11).shaping(Shaping::Advanced).color(Color::from_rgb(0.70, 0.70, 0.75)).into()
|
||||
text(status_text)
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.70, 0.70, 0.75))
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
scrollable(
|
||||
@@ -164,9 +205,14 @@ pub fn view(
|
||||
}
|
||||
PanelTab::Output => {
|
||||
if output_entries.is_empty() {
|
||||
container(text(t(locale, "panel.noOutput")).size(12).shaping(Shaping::Advanced).color(muted))
|
||||
.padding(8)
|
||||
.into()
|
||||
container(
|
||||
text(t(locale, "panel.noOutput"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted),
|
||||
)
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
let items: Vec<Element<'static, Message>> = output_entries
|
||||
.iter()
|
||||
@@ -188,9 +234,14 @@ pub fn view(
|
||||
}
|
||||
PanelTab::PostLinks => {
|
||||
if post_outlinks.is_empty() && post_backlinks.is_empty() {
|
||||
container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
|
||||
.padding(8)
|
||||
.into()
|
||||
container(
|
||||
text(t(locale, "panel.postLinksPlaceholder"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted),
|
||||
)
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
let mut items: Vec<Element<'static, Message>> = vec![
|
||||
text(t(locale, "editor.outlinks"))
|
||||
@@ -201,7 +252,12 @@ pub fn view(
|
||||
];
|
||||
|
||||
if post_outlinks.is_empty() {
|
||||
items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into());
|
||||
items.push(
|
||||
text(t(locale, "panel.postLinksPlaceholder"))
|
||||
.size(11)
|
||||
.color(muted)
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
for link in post_outlinks {
|
||||
items.push(post_link_button(locale, link));
|
||||
@@ -218,7 +274,12 @@ pub fn view(
|
||||
);
|
||||
|
||||
if post_backlinks.is_empty() {
|
||||
items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into());
|
||||
items.push(
|
||||
text(t(locale, "panel.postLinksPlaceholder"))
|
||||
.size(11)
|
||||
.color(muted)
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
for link in post_backlinks {
|
||||
items.push(post_link_button(locale, link));
|
||||
@@ -235,19 +296,22 @@ pub fn view(
|
||||
}
|
||||
PanelTab::GitLog => {
|
||||
// Git Log content populated in extension bucket A (git integration)
|
||||
container(text(t(locale, "panel.gitLogPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
|
||||
.padding(8)
|
||||
.into()
|
||||
container(
|
||||
text(t(locale, "panel.gitLogPlaceholder"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted),
|
||||
)
|
||||
.padding(8)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
container(
|
||||
column![tab_header, content].spacing(0),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(200.0))
|
||||
.style(panel_style)
|
||||
.into()
|
||||
container(column![tab_header, content].spacing(0))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(200.0))
|
||||
.style(panel_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn post_link_button(locale: UiLocale, link: &ResolvedPostLink) -> Element<'static, Message> {
|
||||
|
||||
@@ -2,12 +2,12 @@ use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use iced::widget::text::{Shaping, Wrapping};
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space};
|
||||
use iced::widget::{Column, Space, button, column, container, row, scrollable, text, text_input};
|
||||
use iced::{Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::{self, UiLocale};
|
||||
use bds_core::model::{Post, PostStatus, PostTranslation};
|
||||
use bds_editor::{highlighter, CodeEditor, EditorBuffer, EditorMessage};
|
||||
use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
@@ -297,7 +297,11 @@ impl PostEditorState {
|
||||
langs.sort();
|
||||
for lang in langs {
|
||||
let locale = i18n::normalize_language(&lang);
|
||||
let status = match self.translation_drafts.get(&lang).map(|draft| &draft.status) {
|
||||
let status = match self
|
||||
.translation_drafts
|
||||
.get(&lang)
|
||||
.map(|draft| &draft.status)
|
||||
{
|
||||
Some(PostStatus::Published) => "published",
|
||||
Some(_) => "draft",
|
||||
None => "missing",
|
||||
@@ -480,10 +484,30 @@ pub fn view<'a>(
|
||||
Space::with_width(Length::Fill),
|
||||
container(
|
||||
column![
|
||||
quick_action_item(locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi, ai_enabled),
|
||||
quick_action_item(locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy, ai_enabled),
|
||||
quick_action_item(locale, t(locale, "editor.translate"), PostEditorMsg::Translate, ai_enabled),
|
||||
quick_action_item(locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage, ai_enabled),
|
||||
quick_action_item(
|
||||
locale,
|
||||
t(locale, "editor.aiAnalyze"),
|
||||
PostEditorMsg::AnalyzeWithAi,
|
||||
ai_enabled
|
||||
),
|
||||
quick_action_item(
|
||||
locale,
|
||||
t(locale, "editor.suggestTaxonomy"),
|
||||
PostEditorMsg::AnalyzeTaxonomy,
|
||||
ai_enabled
|
||||
),
|
||||
quick_action_item(
|
||||
locale,
|
||||
t(locale, "editor.translate"),
|
||||
PostEditorMsg::Translate,
|
||||
ai_enabled
|
||||
),
|
||||
quick_action_item(
|
||||
locale,
|
||||
t(locale, "editor.detectLanguage"),
|
||||
PostEditorMsg::DetectLanguage,
|
||||
ai_enabled
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
)
|
||||
@@ -525,7 +549,7 @@ pub fn view<'a>(
|
||||
let mut flag_row = row![].spacing(2);
|
||||
for flag in &flags {
|
||||
let lang = flag.language.clone();
|
||||
let label = format!("{}", flag.flag_emoji);
|
||||
let label = flag.flag_emoji.to_string();
|
||||
let btn = button(text(label).size(14).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang)))
|
||||
.padding([2, 4])
|
||||
@@ -574,9 +598,13 @@ pub fn view<'a>(
|
||||
Some(&state.language),
|
||||
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
|
||||
);
|
||||
let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
|
||||
.padding([6, 12]);
|
||||
let detect_language = button(
|
||||
text(t(locale, "editor.detectLanguage"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
|
||||
.padding([6, 12]);
|
||||
let template_input = inputs::labeled_input(
|
||||
&t(locale, "editor.templateSlug"),
|
||||
"",
|
||||
@@ -619,11 +647,13 @@ pub fn view<'a>(
|
||||
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
|
||||
Space::new(0, 0).into()
|
||||
} else {
|
||||
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.outlinks"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into()];
|
||||
let mut items: Vec<Element<'a, Message>> = vec![
|
||||
text(t(locale, "editor.outlinks"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into(),
|
||||
];
|
||||
for link in &state.outlinks {
|
||||
items.push(
|
||||
text(format!("\u{2192} {}", link.title))
|
||||
@@ -639,11 +669,13 @@ pub fn view<'a>(
|
||||
let backlinks_section: Element<'a, Message> = if state.backlinks.is_empty() {
|
||||
Space::new(0, 0).into()
|
||||
} else {
|
||||
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.backlinks"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into()];
|
||||
let mut items: Vec<Element<'a, Message>> = vec![
|
||||
text(t(locale, "editor.backlinks"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into(),
|
||||
];
|
||||
for link in &state.backlinks {
|
||||
items.push(
|
||||
text(format!("\u{2190} {}", link.title))
|
||||
@@ -712,20 +744,32 @@ pub fn view<'a>(
|
||||
]
|
||||
.spacing(2)
|
||||
.width(Length::Fill),
|
||||
button(text(t(locale, "common.open")).size(11).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id)))
|
||||
.padding([4, 10]),
|
||||
button(text(t(locale, "editor.unlinkMedia")).size(11).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::UnlinkLinkedMedia(media_id)))
|
||||
.padding([4, 10])
|
||||
.style(inputs::danger_button),
|
||||
button(
|
||||
text(t(locale, "common.open"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id)))
|
||||
.padding([4, 10]),
|
||||
button(
|
||||
text(t(locale, "editor.unlinkMedia"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::UnlinkLinkedMedia(
|
||||
media_id
|
||||
)))
|
||||
.padding([4, 10])
|
||||
.style(inputs::danger_button),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::Center),
|
||||
)
|
||||
.padding(8)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
||||
background: Some(iced::Background::Color(Color::from_rgb(
|
||||
0.16, 0.18, 0.22,
|
||||
))),
|
||||
border: iced::Border {
|
||||
color: Color::from_rgb(0.28, 0.28, 0.32),
|
||||
width: 1.0,
|
||||
@@ -788,13 +832,31 @@ pub fn view<'a>(
|
||||
// ── Content section (fills remaining space) ──
|
||||
let content_label = inputs::section_header(&t(locale, "editor.content"));
|
||||
let mode_toggle = row![
|
||||
mode_button(locale, &state.editor_mode, "markdown", Message::PostEditor(PostEditorMsg::SwitchEditorMode("markdown".to_string()))),
|
||||
mode_button(locale, &state.editor_mode, "preview", Message::PostEditor(PostEditorMsg::SwitchEditorMode("preview".to_string()))),
|
||||
mode_button(
|
||||
locale,
|
||||
&state.editor_mode,
|
||||
"markdown",
|
||||
Message::PostEditor(PostEditorMsg::SwitchEditorMode("markdown".to_string()))
|
||||
),
|
||||
mode_button(
|
||||
locale,
|
||||
&state.editor_mode,
|
||||
"preview",
|
||||
Message::PostEditor(PostEditorMsg::SwitchEditorMode("preview".to_string()))
|
||||
),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Alignment::Center);
|
||||
let body_toolbar = inputs::toolbar(
|
||||
vec![row![content_label, Space::with_width(Length::Fixed(12.0)), mode_toggle].align_y(iced::Alignment::Center).into()],
|
||||
vec![
|
||||
row![
|
||||
content_label,
|
||||
Space::with_width(Length::Fixed(12.0)),
|
||||
mode_toggle
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into(),
|
||||
],
|
||||
vec![
|
||||
if state.editor_mode == "markdown" {
|
||||
button(
|
||||
@@ -980,10 +1042,15 @@ fn mode_button<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
fn quick_action_item<'a>(locale: UiLocale, label: String, msg: PostEditorMsg, enabled: bool) -> Element<'a, Message> {
|
||||
fn quick_action_item<'a>(
|
||||
locale: UiLocale,
|
||||
label: String,
|
||||
msg: PostEditorMsg,
|
||||
enabled: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let _ = locale;
|
||||
button(text(label).size(12).shaping(Shaping::Advanced))
|
||||
.on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
|
||||
.on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
|
||||
.padding([6, 12])
|
||||
.style(status_bar::dropdown_item)
|
||||
.width(Length::Fixed(220.0))
|
||||
@@ -1138,5 +1205,4 @@ mod tests {
|
||||
state.set_editor_mode("preview");
|
||||
assert_eq!(state.editor_mode, "preview");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use iced::widget::{button, container, row, svg, text, Column, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Column, Space, button, container, row, svg, text};
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -120,19 +120,32 @@ pub fn view(
|
||||
|
||||
let label = if is_active {
|
||||
row![
|
||||
text("\u{2713}").size(12).shaping(Shaping::Advanced).color(Color::from_rgb(0.40, 0.80, 0.40)),
|
||||
text(name).size(12).shaping(Shaping::Advanced).color(Color::WHITE),
|
||||
text("\u{2713}")
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.40, 0.80, 0.40)),
|
||||
text(name)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE),
|
||||
]
|
||||
.spacing(6)
|
||||
} else {
|
||||
row![
|
||||
Space::with_width(Length::Fixed(14.0)),
|
||||
text(name).size(12).shaping(Shaping::Advanced).color(Color::from_rgb(0.80, 0.80, 0.85)),
|
||||
text(name)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.80, 0.80, 0.85)),
|
||||
]
|
||||
.spacing(6)
|
||||
};
|
||||
|
||||
let style_fn = if is_active { project_item_active } else { project_item };
|
||||
let style_fn = if is_active {
|
||||
project_item_active
|
||||
} else {
|
||||
project_item
|
||||
};
|
||||
|
||||
items.push(
|
||||
button(label)
|
||||
@@ -159,8 +172,13 @@ pub fn view(
|
||||
items.push(
|
||||
button(
|
||||
row![
|
||||
text("+").size(14).shaping(Shaping::Advanced).color(Color::from_rgb(0.55, 0.75, 0.95)),
|
||||
text(t(locale, "projectSelector.newProject")).size(12).shaping(Shaping::Advanced),
|
||||
text("+")
|
||||
.size(14)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.55, 0.75, 0.95)),
|
||||
text(t(locale, "projectSelector.newProject"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use iced::widget::{button, column, container, row, scrollable, text, Space};
|
||||
use iced::widget::{Space, button, column, container, row, scrollable, text};
|
||||
use iced::{Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -118,10 +118,7 @@ pub enum ScriptEditorMsg {
|
||||
}
|
||||
|
||||
/// Render the script editor view.
|
||||
pub fn view<'a>(
|
||||
state: &'a ScriptEditorState,
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let header = inputs::toolbar(
|
||||
vec![
|
||||
text(state.title.clone()).size(18).into(),
|
||||
@@ -162,7 +159,9 @@ pub fn view<'a>(
|
||||
&state.slug,
|
||||
|s| Message::ScriptEditor(ScriptEditorMsg::SlugChanged(s)),
|
||||
);
|
||||
let meta_row1 = row![title_input, slug_input].spacing(16).width(Length::Fill);
|
||||
let meta_row1 = row![title_input, slug_input]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Metadata row 2: kind, entrypoint, enabled
|
||||
let kind_options = vec![
|
||||
@@ -179,7 +178,10 @@ pub fn view<'a>(
|
||||
);
|
||||
|
||||
let mut entrypoint_options = state.discovered_entrypoints.clone();
|
||||
if !entrypoint_options.iter().any(|entrypoint| entrypoint == &state.entrypoint) {
|
||||
if !entrypoint_options
|
||||
.iter()
|
||||
.any(|entrypoint| entrypoint == &state.entrypoint)
|
||||
{
|
||||
entrypoint_options.push(state.entrypoint.clone());
|
||||
}
|
||||
let selected_entrypoint = entrypoint_options
|
||||
@@ -192,28 +194,30 @@ pub fn view<'a>(
|
||||
|entrypoint| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(entrypoint)),
|
||||
);
|
||||
|
||||
let enabled_check = inputs::labeled_checkbox(
|
||||
&t(locale, "editor.enabled"),
|
||||
state.enabled,
|
||||
|b| Message::ScriptEditor(ScriptEditorMsg::EnabledChanged(b)),
|
||||
);
|
||||
let enabled_check =
|
||||
inputs::labeled_checkbox(&t(locale, "editor.enabled"), state.enabled, |b| {
|
||||
Message::ScriptEditor(ScriptEditorMsg::EnabledChanged(b))
|
||||
});
|
||||
let meta_row2 = row![kind_select, entrypoint_input, enabled_check]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Content editor (CodeEditor with syntax highlighting based on file extension)
|
||||
let syntax_ext = if state.file_path.ends_with(".py") { "py" } else { "lua" };
|
||||
let syntax_ext = if state.file_path.ends_with(".py") {
|
||||
"py"
|
||||
} else {
|
||||
"lua"
|
||||
};
|
||||
let content_section: Element<'a, Message> = column![
|
||||
inputs::section_header(&t(locale, "editor.content")),
|
||||
Element::from(
|
||||
CodeEditor::new(
|
||||
&state.editor_buffer,
|
||||
highlighter(),
|
||||
syntax_ext,
|
||||
)
|
||||
.on_change(|msg| match msg {
|
||||
EditorMessage::ContentChanged(s) => Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s)),
|
||||
EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save),
|
||||
CodeEditor::new(&state.editor_buffer, highlighter(), syntax_ext,).on_change(|msg| {
|
||||
match msg {
|
||||
EditorMessage::ContentChanged(s) => {
|
||||
Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s))
|
||||
}
|
||||
EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save),
|
||||
}
|
||||
})
|
||||
),
|
||||
]
|
||||
@@ -245,29 +249,20 @@ pub fn view<'a>(
|
||||
|
||||
// Top pane: header + metadata (scrollable for overflow)
|
||||
let top_pane = scrollable(
|
||||
column![
|
||||
header,
|
||||
meta_row1,
|
||||
meta_row2,
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
column![header, meta_row1, meta_row2,]
|
||||
.spacing(12)
|
||||
.padding(16)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.height(Length::Shrink);
|
||||
|
||||
// Full layout: top pane (shrink), content (fill), validation + footer (shrink)
|
||||
column![
|
||||
top_pane,
|
||||
content_section,
|
||||
validation,
|
||||
footer,
|
||||
]
|
||||
.spacing(4)
|
||||
.padding([0, 16])
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
column![top_pane, content_section, validation, footer,]
|
||||
.spacing(4)
|
||||
.padding([0, 16])
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn status_badge<'a>(status: &ScriptStatus) -> Element<'a, Message> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};
|
||||
use iced::{Alignment, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -209,7 +209,13 @@ impl Default for SettingsViewState {
|
||||
public_url: String::new(),
|
||||
main_language: "en".to_string(),
|
||||
blog_languages: vec!["en".to_string()],
|
||||
available_languages: vec!["en".to_string(), "de".to_string(), "fr".to_string(), "it".to_string(), "es".to_string()],
|
||||
available_languages: vec![
|
||||
"en".to_string(),
|
||||
"de".to_string(),
|
||||
"fr".to_string(),
|
||||
"it".to_string(),
|
||||
"es".to_string(),
|
||||
],
|
||||
default_author: String::new(),
|
||||
max_posts_per_page: "50".to_string(),
|
||||
blogmark_category: String::new(),
|
||||
@@ -255,11 +261,11 @@ impl SettingsViewState {
|
||||
|
||||
fn ordered_sections(&self) -> Vec<SettingsSection> {
|
||||
let mut sections = SettingsSection::all().to_vec();
|
||||
if let Some(active) = &self.active_section {
|
||||
if let Some(index) = sections.iter().position(|section| section == active) {
|
||||
let focused = sections.remove(index);
|
||||
sections.insert(0, focused);
|
||||
}
|
||||
if let Some(active) = &self.active_section
|
||||
&& let Some(index) = sections.iter().position(|section| section == active)
|
||||
{
|
||||
let focused = sections.remove(index);
|
||||
sections.insert(0, focused);
|
||||
}
|
||||
sections
|
||||
}
|
||||
@@ -337,10 +343,7 @@ pub enum SettingsMsg {
|
||||
}
|
||||
|
||||
/// Render the settings view.
|
||||
pub fn view<'a>(
|
||||
state: &'a SettingsViewState,
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query)
|
||||
.on_input(|s| Message::Settings(SettingsMsg::SearchChanged(s)))
|
||||
.size(14);
|
||||
@@ -388,38 +391,6 @@ pub fn view<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SettingsSection, SettingsViewState};
|
||||
|
||||
#[test]
|
||||
fn focus_section_collapses_other_sections_and_clears_search() {
|
||||
let mut state = SettingsViewState {
|
||||
search_query: "publish".to_string(),
|
||||
..SettingsViewState::default()
|
||||
};
|
||||
|
||||
state.focus_section(SettingsSection::Publishing);
|
||||
|
||||
assert_eq!(state.active_section, Some(SettingsSection::Publishing));
|
||||
assert!(state.search_query.is_empty());
|
||||
assert!(!state.collapsed.contains(&SettingsSection::Publishing));
|
||||
assert!(state.collapsed.contains(&SettingsSection::Project));
|
||||
assert!(state.collapsed.contains(&SettingsSection::MCP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focused_section_is_rendered_first() {
|
||||
let mut state = SettingsViewState::default();
|
||||
state.focus_section(SettingsSection::Technology);
|
||||
|
||||
let ordered = state.ordered_sections();
|
||||
|
||||
assert_eq!(ordered.first(), Some(&SettingsSection::Technology));
|
||||
assert_eq!(ordered.len(), SettingsSection::all().len());
|
||||
}
|
||||
}
|
||||
|
||||
fn render_section<'a>(
|
||||
state: &'a SettingsViewState,
|
||||
section: &SettingsSection,
|
||||
@@ -436,7 +407,9 @@ fn render_section<'a>(
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.on_press(Message::Settings(SettingsMsg::ToggleSection(section.clone())))
|
||||
.on_press(Message::Settings(SettingsMsg::ToggleSection(
|
||||
section.clone(),
|
||||
)))
|
||||
.padding([6, 8])
|
||||
.width(Length::Fill)
|
||||
.style(|_: &Theme, _| button::Style::default());
|
||||
@@ -456,9 +429,7 @@ fn render_section<'a>(
|
||||
SettingsSection::MCP => section_mcp(locale),
|
||||
};
|
||||
|
||||
column![header, content]
|
||||
.spacing(4)
|
||||
.into()
|
||||
column![header, content].spacing(4).into()
|
||||
}
|
||||
|
||||
fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
@@ -469,7 +440,10 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
|s| Message::Settings(SettingsMsg::ProjectNameChanged(s)),
|
||||
);
|
||||
let desc = column![
|
||||
text(t(locale, "settings.projectDescription")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced),
|
||||
text(t(locale, "settings.projectDescription"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced),
|
||||
text_editor(&state.project_description)
|
||||
.on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a)))
|
||||
.height(Length::Fixed(80.0))
|
||||
@@ -477,12 +451,9 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
]
|
||||
.spacing(4);
|
||||
let data_path = row![
|
||||
inputs::labeled_input(
|
||||
&t(locale, "settings.dataPath"),
|
||||
"",
|
||||
&state.data_path,
|
||||
|s| Message::Settings(SettingsMsg::DataPathChanged(s)),
|
||||
),
|
||||
inputs::labeled_input(&t(locale, "settings.dataPath"), "", &state.data_path, |s| {
|
||||
Message::Settings(SettingsMsg::DataPathChanged(s))
|
||||
},),
|
||||
button(text(t(locale, "settings.browse")).size(12))
|
||||
.on_press(Message::Settings(SettingsMsg::BrowseDataPath))
|
||||
.padding([6, 12]),
|
||||
@@ -512,14 +483,24 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
.iter()
|
||||
.map(|language| {
|
||||
let label = if *language == state.main_language {
|
||||
format!("{} ({})", language, t(locale, "settings.mainLanguageRequired"))
|
||||
format!(
|
||||
"{} ({})",
|
||||
language,
|
||||
t(locale, "settings.mainLanguageRequired")
|
||||
)
|
||||
} else {
|
||||
language.clone()
|
||||
};
|
||||
inputs::labeled_checkbox(&label, state.blog_languages.iter().any(|item| item == language), {
|
||||
let language = language.clone();
|
||||
move |_| Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone()))
|
||||
})
|
||||
inputs::labeled_checkbox(
|
||||
&label,
|
||||
state.blog_languages.iter().any(|item| item == language),
|
||||
{
|
||||
let language = language.clone();
|
||||
move |_| {
|
||||
Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone()))
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
@@ -539,7 +520,10 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
let blogmark_category = inputs::labeled_select(
|
||||
&t(locale, "settings.blogmarkCategory"),
|
||||
&state.categories,
|
||||
state.categories.iter().find(|row| row.name == state.blogmark_category),
|
||||
state
|
||||
.categories
|
||||
.iter()
|
||||
.find(|row| row.name == state.blogmark_category),
|
||||
|row| Message::Settings(SettingsMsg::BlogmarkCategoryChanged(row.name)),
|
||||
);
|
||||
let save = button(text(t(locale, "common.save")).size(13))
|
||||
@@ -566,9 +550,9 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
blogmark_category,
|
||||
save,
|
||||
]
|
||||
.spacing(8)
|
||||
.padding([0, 16])
|
||||
.into()
|
||||
.spacing(8)
|
||||
.padding([0, 16])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
@@ -616,79 +600,112 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
.chain(state.template_options.iter().cloned())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let category_rows = state.categories.iter().fold(column![].spacing(8), |column, category| {
|
||||
let title = inputs::labeled_input(
|
||||
&tw(locale, "settings.categoryTitle", &[("category", &category.name)]),
|
||||
"",
|
||||
&category.title,
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value))
|
||||
},
|
||||
);
|
||||
let post_template = inputs::labeled_select(
|
||||
&t(locale, "settings.categoryPostTemplate"),
|
||||
&template_options,
|
||||
Some(&category.post_template_slug),
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| Message::Settings(SettingsMsg::CategoryPostTemplateChanged(name.clone(), value))
|
||||
},
|
||||
);
|
||||
let list_template = inputs::labeled_select(
|
||||
&t(locale, "settings.categoryListTemplate"),
|
||||
&template_options,
|
||||
Some(&category.list_template_slug),
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| Message::Settings(SettingsMsg::CategoryListTemplateChanged(name.clone(), value))
|
||||
},
|
||||
);
|
||||
let toggles = column![
|
||||
inputs::labeled_checkbox(
|
||||
&t(locale, "settings.categoryRenderInLists"),
|
||||
category.render_in_lists,
|
||||
let category_rows = state
|
||||
.categories
|
||||
.iter()
|
||||
.fold(column![].spacing(8), |column, category| {
|
||||
let title = inputs::labeled_input(
|
||||
&tw(
|
||||
locale,
|
||||
"settings.categoryTitle",
|
||||
&[("category", &category.name)],
|
||||
),
|
||||
"",
|
||||
&category.title,
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| Message::Settings(SettingsMsg::CategoryRenderInListsChanged(name.clone(), value))
|
||||
move |value| {
|
||||
Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value))
|
||||
}
|
||||
},
|
||||
),
|
||||
inputs::labeled_checkbox(
|
||||
&t(locale, "settings.categoryShowTitles"),
|
||||
category.show_title,
|
||||
);
|
||||
let post_template = inputs::labeled_select(
|
||||
&t(locale, "settings.categoryPostTemplate"),
|
||||
&template_options,
|
||||
Some(&category.post_template_slug),
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| Message::Settings(SettingsMsg::CategoryShowTitleChanged(name.clone(), value))
|
||||
move |value| {
|
||||
Message::Settings(SettingsMsg::CategoryPostTemplateChanged(
|
||||
name.clone(),
|
||||
value,
|
||||
))
|
||||
}
|
||||
},
|
||||
),
|
||||
]
|
||||
.spacing(6);
|
||||
let actions = row![
|
||||
button(text(t(locale, "common.save")).size(13))
|
||||
.on_press(Message::Settings(SettingsMsg::SaveCategory(category.name.clone())))
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 12]),
|
||||
button(text(t(locale, "common.remove")).size(13))
|
||||
.on_press_maybe((!category.is_protected).then(|| Message::Settings(SettingsMsg::RemoveCategory(category.name.clone()))))
|
||||
.style(inputs::danger_button)
|
||||
.padding([6, 12]),
|
||||
]
|
||||
.spacing(8);
|
||||
);
|
||||
let list_template = inputs::labeled_select(
|
||||
&t(locale, "settings.categoryListTemplate"),
|
||||
&template_options,
|
||||
Some(&category.list_template_slug),
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| {
|
||||
Message::Settings(SettingsMsg::CategoryListTemplateChanged(
|
||||
name.clone(),
|
||||
value,
|
||||
))
|
||||
}
|
||||
},
|
||||
);
|
||||
let toggles = column![
|
||||
inputs::labeled_checkbox(
|
||||
&t(locale, "settings.categoryRenderInLists"),
|
||||
category.render_in_lists,
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| {
|
||||
Message::Settings(SettingsMsg::CategoryRenderInListsChanged(
|
||||
name.clone(),
|
||||
value,
|
||||
))
|
||||
}
|
||||
},
|
||||
),
|
||||
inputs::labeled_checkbox(
|
||||
&t(locale, "settings.categoryShowTitles"),
|
||||
category.show_title,
|
||||
{
|
||||
let name = category.name.clone();
|
||||
move |value| {
|
||||
Message::Settings(SettingsMsg::CategoryShowTitleChanged(
|
||||
name.clone(),
|
||||
value,
|
||||
))
|
||||
}
|
||||
},
|
||||
),
|
||||
]
|
||||
.spacing(6);
|
||||
let actions = row![
|
||||
button(text(t(locale, "common.save")).size(13))
|
||||
.on_press(Message::Settings(SettingsMsg::SaveCategory(
|
||||
category.name.clone()
|
||||
)))
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 12]),
|
||||
button(text(t(locale, "common.remove")).size(13))
|
||||
.on_press_maybe((!category.is_protected).then(|| Message::Settings(
|
||||
SettingsMsg::RemoveCategory(category.name.clone())
|
||||
)))
|
||||
.style(inputs::danger_button)
|
||||
.padding([6, 12]),
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
column.push(
|
||||
container(
|
||||
column![
|
||||
text(category.name.clone()).size(15).color(Color::WHITE),
|
||||
title,
|
||||
toggles,
|
||||
row![post_template, list_template].spacing(12),
|
||||
actions,
|
||||
]
|
||||
.spacing(8),
|
||||
column.push(
|
||||
container(
|
||||
column![
|
||||
text(category.name.clone()).size(15).color(Color::WHITE),
|
||||
title,
|
||||
toggles,
|
||||
row![post_template, list_template].spacing(12),
|
||||
actions,
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(12),
|
||||
)
|
||||
.padding(12),
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
let add_row = row![
|
||||
inputs::labeled_input(
|
||||
@@ -732,7 +749,12 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
label: t(locale, "tags.noTemplate"),
|
||||
supports_vision: false,
|
||||
})
|
||||
.chain(active_model_options.iter().filter(|option| option.supports_vision).cloned())
|
||||
.chain(
|
||||
active_model_options
|
||||
.iter()
|
||||
.filter(|option| option.supports_vision)
|
||||
.cloned(),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let offline = inputs::labeled_checkbox(
|
||||
@@ -786,23 +808,32 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
let default_model = inputs::labeled_select(
|
||||
&t(locale, "settings.defaultModel"),
|
||||
&model_options,
|
||||
model_options.iter().find(|option| option.id == state.default_model),
|
||||
model_options
|
||||
.iter()
|
||||
.find(|option| option.id == state.default_model),
|
||||
|option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)),
|
||||
);
|
||||
let title_model = inputs::labeled_select(
|
||||
&t(locale, "settings.titleModel"),
|
||||
&model_options,
|
||||
model_options.iter().find(|option| option.id == state.title_model),
|
||||
model_options
|
||||
.iter()
|
||||
.find(|option| option.id == state.title_model),
|
||||
|option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)),
|
||||
);
|
||||
let image_model = inputs::labeled_select(
|
||||
&t(locale, "settings.imageAnalysisModel"),
|
||||
&image_model_options,
|
||||
image_model_options.iter().find(|option| option.id == state.image_model),
|
||||
image_model_options
|
||||
.iter()
|
||||
.find(|option| option.id == state.image_model),
|
||||
|option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)),
|
||||
);
|
||||
let prompt = column![
|
||||
text(t(locale, "settings.systemPrompt")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced),
|
||||
text(t(locale, "settings.systemPrompt"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced),
|
||||
text_editor(&state.system_prompt)
|
||||
.on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a)))
|
||||
.height(Length::Fixed(200.0))
|
||||
@@ -822,11 +853,15 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
|
||||
column![
|
||||
offline,
|
||||
text(t(locale, "settings.onlineEndpointSection")).size(12).color(inputs::LABEL_COLOR),
|
||||
text(t(locale, "settings.onlineEndpointSection"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR),
|
||||
online_url,
|
||||
row![online_model, online_api_key].spacing(12),
|
||||
online_refresh,
|
||||
text(t(locale, "settings.airplaneEndpointSection")).size(12).color(inputs::LABEL_COLOR),
|
||||
text(t(locale, "settings.airplaneEndpointSection"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR),
|
||||
airplane_url,
|
||||
row![airplane_model, airplane_refresh].spacing(12),
|
||||
default_model,
|
||||
@@ -835,9 +870,9 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
prompt,
|
||||
btns,
|
||||
]
|
||||
.spacing(8)
|
||||
.padding([0, 16])
|
||||
.into()
|
||||
.spacing(8)
|
||||
.padding([0, 16])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
@@ -857,12 +892,9 @@ fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Ele
|
||||
}
|
||||
|
||||
fn section_publishing<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let host = inputs::labeled_input(
|
||||
&t(locale, "settings.sshHost"),
|
||||
"",
|
||||
&state.ssh_host,
|
||||
|s| Message::Settings(SettingsMsg::SshHostChanged(s)),
|
||||
);
|
||||
let host = inputs::labeled_input(&t(locale, "settings.sshHost"), "", &state.ssh_host, |s| {
|
||||
Message::Settings(SettingsMsg::SshHostChanged(s))
|
||||
});
|
||||
let user = inputs::labeled_input(
|
||||
&t(locale, "settings.sshUsername"),
|
||||
"",
|
||||
@@ -939,3 +971,35 @@ fn section_mcp<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||
.color(Color::from_rgb(0.5, 0.5, 0.5))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SettingsSection, SettingsViewState};
|
||||
|
||||
#[test]
|
||||
fn focus_section_collapses_other_sections_and_clears_search() {
|
||||
let mut state = SettingsViewState {
|
||||
search_query: "publish".to_string(),
|
||||
..SettingsViewState::default()
|
||||
};
|
||||
|
||||
state.focus_section(SettingsSection::Publishing);
|
||||
|
||||
assert_eq!(state.active_section, Some(SettingsSection::Publishing));
|
||||
assert!(state.search_query.is_empty());
|
||||
assert!(!state.collapsed.contains(&SettingsSection::Publishing));
|
||||
assert!(state.collapsed.contains(&SettingsSection::Project));
|
||||
assert!(state.collapsed.contains(&SettingsSection::MCP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focused_section_is_rendered_first() {
|
||||
let mut state = SettingsViewState::default();
|
||||
state.focus_section(SettingsSection::Technology);
|
||||
|
||||
let ordered = state.ordered_sections();
|
||||
|
||||
assert_eq!(ordered.first(), Some(&SettingsSection::Technology));
|
||||
assert_eq!(ordered.len(), SettingsSection::all().len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use iced::widget::{button, column, container, image, row, scrollable, text, text_input, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Space, button, column, container, image, row, scrollable, text, text_input};
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -125,11 +125,11 @@ fn truncate_media_title(title: &str) -> String {
|
||||
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
|
||||
"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
|
||||
"video" => return "\u{1F3AC}", // 🎬 film
|
||||
"podcast" => return "\u{1F4AC}", // 💬 speech bubble
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -269,9 +269,18 @@ fn clear_filters_style(_theme: &Theme, status: button::Status) -> button::Style
|
||||
/// Month name abbreviation for calendar display.
|
||||
fn month_abbr(month: u32) -> &'static str {
|
||||
match month {
|
||||
1 => "Jan", 2 => "Feb", 3 => "Mar", 4 => "Apr",
|
||||
5 => "May", 6 => "Jun", 7 => "Jul", 8 => "Aug",
|
||||
9 => "Sep", 10 => "Oct", 11 => "Nov", 12 => "Dec",
|
||||
1 => "Jan",
|
||||
2 => "Feb",
|
||||
3 => "Mar",
|
||||
4 => "Apr",
|
||||
5 => "May",
|
||||
6 => "Jun",
|
||||
7 => "Jul",
|
||||
8 => "Aug",
|
||||
9 => "Sep",
|
||||
10 => "Oct",
|
||||
11 => "Nov",
|
||||
12 => "Dec",
|
||||
_ => "???",
|
||||
}
|
||||
}
|
||||
@@ -300,10 +309,12 @@ fn calendar_widget(
|
||||
let label = format!("{} ({})", cy.year, total);
|
||||
let year_val = cy.year;
|
||||
let on_year_clone = on_year.clone();
|
||||
let style_fn = if year_selected { calendar_selected_style } else { calendar_style };
|
||||
let year_btn = button(
|
||||
text(label).size(11).shaping(Shaping::Advanced)
|
||||
)
|
||||
let style_fn = if year_selected {
|
||||
calendar_selected_style
|
||||
} else {
|
||||
calendar_style
|
||||
};
|
||||
let year_btn = button(text(label).size(11).shaping(Shaping::Advanced))
|
||||
.on_press(if year_selected {
|
||||
on_year_clone(None)
|
||||
} else {
|
||||
@@ -320,10 +331,12 @@ fn calendar_widget(
|
||||
let label = format!(" {} ({})", month_abbr(cm.month), cm.count);
|
||||
let month_val = cm.month;
|
||||
let on_month_clone = on_month.clone();
|
||||
let style_fn = if month_selected { calendar_selected_style } else { calendar_style };
|
||||
let month_btn = button(
|
||||
text(label).size(10).shaping(Shaping::Advanced)
|
||||
)
|
||||
let style_fn = if month_selected {
|
||||
calendar_selected_style
|
||||
} else {
|
||||
calendar_style
|
||||
};
|
||||
let month_btn = button(text(label).size(10).shaping(Shaping::Advanced))
|
||||
.on_press(if month_selected {
|
||||
on_month_clone(None)
|
||||
} else {
|
||||
@@ -362,10 +375,12 @@ fn chip_selector(
|
||||
let is_selected = selected.contains(tag);
|
||||
let tag_clone = tag.clone();
|
||||
let on_toggle_clone = on_toggle.clone();
|
||||
let style_fn = if is_selected { chip_selected_style } else { chip_style };
|
||||
let chip = button(
|
||||
text(tag.clone()).size(10).shaping(Shaping::Advanced)
|
||||
)
|
||||
let style_fn = if is_selected {
|
||||
chip_selected_style
|
||||
} else {
|
||||
chip_style
|
||||
};
|
||||
let chip = button(text(tag.clone()).size(10).shaping(Shaping::Advanced))
|
||||
.on_press(on_toggle_clone(tag_clone))
|
||||
.padding([2, 6])
|
||||
.style(style_fn);
|
||||
@@ -377,9 +392,7 @@ fn chip_selector(
|
||||
while !collected.is_empty() {
|
||||
let chunk_size = 3.min(collected.len());
|
||||
let chunk: Vec<Element<'static, Message>> = collected.drain(..chunk_size).collect();
|
||||
items.push(
|
||||
iced::widget::Row::with_children(chunk).spacing(4).into()
|
||||
);
|
||||
items.push(iced::widget::Row::with_children(chunk).spacing(4).into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +421,11 @@ fn single_select_chip_selector(
|
||||
.iter()
|
||||
.map(|(value, display)| {
|
||||
let is_selected = selected == Some(value.as_str());
|
||||
let style_fn = if is_selected { chip_selected_style } else { chip_style };
|
||||
let style_fn = if is_selected {
|
||||
chip_selected_style
|
||||
} else {
|
||||
chip_style
|
||||
};
|
||||
let value = value.clone();
|
||||
let on_toggle = on_toggle.clone();
|
||||
button(text(display.clone()).size(10).shaping(Shaping::Advanced))
|
||||
@@ -473,8 +490,8 @@ fn post_filter_panel(
|
||||
&filter.calendar_years,
|
||||
filter.calendar.selected_year,
|
||||
filter.calendar.selected_month,
|
||||
|y| Message::SetPostCalendarYear(y),
|
||||
|m| Message::SetPostCalendarMonth(m),
|
||||
Message::SetPostCalendarYear,
|
||||
Message::SetPostCalendarMonth,
|
||||
locale,
|
||||
));
|
||||
sections.push(Space::with_height(4.0).into());
|
||||
@@ -486,7 +503,7 @@ fn post_filter_panel(
|
||||
"sidebar.filter.tags",
|
||||
&filter.available_tags,
|
||||
&filter.tag_filter,
|
||||
|tag| Message::TogglePostTagFilter(tag),
|
||||
Message::TogglePostTagFilter,
|
||||
locale,
|
||||
));
|
||||
sections.push(Space::with_height(4.0).into());
|
||||
@@ -498,7 +515,7 @@ fn post_filter_panel(
|
||||
"sidebar.filter.categories",
|
||||
&filter.available_categories,
|
||||
&filter.category_filter,
|
||||
|cat| Message::TogglePostCategoryFilter(cat),
|
||||
Message::TogglePostCategoryFilter,
|
||||
locale,
|
||||
));
|
||||
sections.push(Space::with_height(4.0).into());
|
||||
@@ -537,23 +554,22 @@ fn post_filter_panel(
|
||||
button(
|
||||
text(t(locale, "sidebar.filter.clearAll"))
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::ClearPostFilters)
|
||||
.padding([2, 4])
|
||||
.style(clear_filters_style)
|
||||
.into()
|
||||
.on_press(Message::ClearPostFilters)
|
||||
.padding([2, 4])
|
||||
.style(clear_filters_style)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
iced::widget::Column::with_children(sections).spacing(2).into()
|
||||
iced::widget::Column::with_children(sections)
|
||||
.spacing(2)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build the filter panel for Media view.
|
||||
fn media_filter_panel(
|
||||
filter: &MediaFilter,
|
||||
locale: UiLocale,
|
||||
) -> Element<'static, Message> {
|
||||
fn media_filter_panel(filter: &MediaFilter, locale: UiLocale) -> Element<'static, Message> {
|
||||
let mut sections: Vec<Element<'static, Message>> = Vec::new();
|
||||
|
||||
if !filter.calendar_years.is_empty() {
|
||||
@@ -561,8 +577,8 @@ fn media_filter_panel(
|
||||
&filter.calendar_years,
|
||||
filter.calendar.selected_year,
|
||||
filter.calendar.selected_month,
|
||||
|y| Message::SetMediaCalendarYear(y),
|
||||
|m| Message::SetMediaCalendarMonth(m),
|
||||
Message::SetMediaCalendarYear,
|
||||
Message::SetMediaCalendarMonth,
|
||||
locale,
|
||||
));
|
||||
sections.push(Space::with_height(4.0).into());
|
||||
@@ -573,7 +589,7 @@ fn media_filter_panel(
|
||||
"sidebar.filter.tags",
|
||||
&filter.available_tags,
|
||||
&filter.tag_filter,
|
||||
|tag| Message::ToggleMediaTagFilter(tag),
|
||||
Message::ToggleMediaTagFilter,
|
||||
locale,
|
||||
));
|
||||
sections.push(Space::with_height(4.0).into());
|
||||
@@ -584,18 +600,24 @@ fn media_filter_panel(
|
||||
button(
|
||||
text(t(locale, "sidebar.filter.clearAll"))
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::ClearMediaFilters)
|
||||
.padding([2, 4])
|
||||
.style(clear_filters_style)
|
||||
.into()
|
||||
.on_press(Message::ClearMediaFilters)
|
||||
.padding([2, 4])
|
||||
.style(clear_filters_style)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
iced::widget::Column::with_children(sections).spacing(2).into()
|
||||
iced::widget::Column::with_children(sections)
|
||||
.spacing(2)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "arguments are independent sidebar state slices"
|
||||
)]
|
||||
pub fn view(
|
||||
sidebar_view: SidebarView,
|
||||
posts: &[Post],
|
||||
@@ -633,10 +655,14 @@ pub fn view(
|
||||
row![
|
||||
header,
|
||||
Space::with_width(Length::Fill),
|
||||
button(text(t(locale, "common.add")).size(11).shaping(Shaping::Advanced))
|
||||
.on_press(action)
|
||||
.padding([3, 8])
|
||||
.style(filter_button_style),
|
||||
button(
|
||||
text(t(locale, "common.add"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(action)
|
||||
.padding([3, 8])
|
||||
.style(filter_button_style),
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into()
|
||||
@@ -652,10 +678,7 @@ pub fn view(
|
||||
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
|
||||
|
||||
// Search input + filter toggle row
|
||||
let search = text_input(
|
||||
&t(locale, "sidebar.filter.search"),
|
||||
&filter.search_query,
|
||||
)
|
||||
let search = text_input(&t(locale, "sidebar.filter.search"), &filter.search_query)
|
||||
.on_input(Message::PostSearchChanged)
|
||||
.size(11)
|
||||
.padding([4, 6])
|
||||
@@ -673,16 +696,12 @@ pub fn view(
|
||||
} else {
|
||||
"\u{25BC}" // ▼ toggle icon
|
||||
};
|
||||
let filter_toggle = button(
|
||||
text(toggle_label).size(11).shaping(Shaping::Advanced)
|
||||
)
|
||||
let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced))
|
||||
.on_press(Message::TogglePostFilterPanel)
|
||||
.padding([4, 6])
|
||||
.style(toggle_style);
|
||||
|
||||
top_items.push(
|
||||
row![search, filter_toggle].spacing(4).into()
|
||||
);
|
||||
top_items.push(row![search, filter_toggle].spacing(4).into());
|
||||
|
||||
// Filter panel (collapsible)
|
||||
if filter.filter_panel_visible {
|
||||
@@ -703,7 +722,7 @@ pub fn view(
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
let section_header = |label: &str| -> Element<'static, Message> {
|
||||
@@ -724,7 +743,8 @@ pub fn view(
|
||||
};
|
||||
let date = format_post_date(p.created_at);
|
||||
let prefix_chars: usize = 5;
|
||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX
|
||||
let text_px = width
|
||||
- SIDEBAR_TEXT_OVERHEAD_PX
|
||||
- (prefix_chars as f32 * AVG_CHAR_WIDTH_PX);
|
||||
let display_title = truncate_to_fit(&p.title, text_px);
|
||||
let label = format!("{icon} {status_indicator} {display_title}");
|
||||
@@ -736,27 +756,34 @@ pub fn view(
|
||||
.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 };
|
||||
let style_fn = if is_active {
|
||||
item_active_style
|
||||
} else {
|
||||
item_style
|
||||
};
|
||||
button(
|
||||
container(column![label_text, date_text].spacing(1))
|
||||
.width(Length::Fill)
|
||||
.clip(true)
|
||||
.clip(true),
|
||||
)
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: p.id.clone(),
|
||||
tab_type: TabType::Post,
|
||||
title: p.title.clone(),
|
||||
is_transient: true,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(style_fn)
|
||||
.into()
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: p.id.clone(),
|
||||
tab_type: TabType::Post,
|
||||
title: p.title.clone(),
|
||||
is_transient: true,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(style_fn)
|
||||
.into()
|
||||
};
|
||||
|
||||
// Draft section
|
||||
let drafts: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Draft).collect();
|
||||
let drafts: Vec<&Post> = posts
|
||||
.iter()
|
||||
.filter(|p| p.status == bds_core::model::PostStatus::Draft)
|
||||
.collect();
|
||||
if !drafts.is_empty() {
|
||||
top_items.push(section_header(&t(locale, "sidebar.drafts")));
|
||||
for p in &drafts {
|
||||
@@ -766,7 +793,10 @@ pub fn view(
|
||||
}
|
||||
|
||||
// Published section
|
||||
let published: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Published).collect();
|
||||
let published: Vec<&Post> = posts
|
||||
.iter()
|
||||
.filter(|p| p.status == bds_core::model::PostStatus::Published)
|
||||
.collect();
|
||||
if !published.is_empty() {
|
||||
top_items.push(section_header(&t(locale, "sidebar.published")));
|
||||
for p in &published {
|
||||
@@ -776,7 +806,10 @@ pub fn view(
|
||||
}
|
||||
|
||||
// Archived section
|
||||
let archived: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Archived).collect();
|
||||
let archived: Vec<&Post> = posts
|
||||
.iter()
|
||||
.filter(|p| p.status == bds_core::model::PostStatus::Archived)
|
||||
.collect();
|
||||
if !archived.is_empty() {
|
||||
top_items.push(section_header(&t(locale, "sidebar.archived")));
|
||||
for p in &archived {
|
||||
@@ -791,13 +824,13 @@ pub fn view(
|
||||
button(
|
||||
text(t(locale, "sidebar.loadMore"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::LoadMorePosts)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill)
|
||||
.style(filter_button_style)
|
||||
.into()
|
||||
.on_press(Message::LoadMorePosts)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill)
|
||||
.style(filter_button_style)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -811,10 +844,7 @@ pub fn view(
|
||||
let mut top_items: Vec<Element<'static, Message>> = Vec::new();
|
||||
|
||||
// Search input + filter toggle row
|
||||
let search = text_input(
|
||||
&t(locale, "sidebar.filter.search"),
|
||||
&filter.search_query,
|
||||
)
|
||||
let search = text_input(&t(locale, "sidebar.filter.search"), &filter.search_query)
|
||||
.on_input(Message::MediaSearchChanged)
|
||||
.size(11)
|
||||
.padding([4, 6])
|
||||
@@ -832,16 +862,12 @@ pub fn view(
|
||||
} else {
|
||||
"\u{25BC}" // ▼ toggle icon
|
||||
};
|
||||
let filter_toggle = button(
|
||||
text(toggle_label).size(11).shaping(Shaping::Advanced)
|
||||
)
|
||||
let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced))
|
||||
.on_press(Message::ToggleMediaFilterPanel)
|
||||
.padding([4, 6])
|
||||
.style(toggle_style);
|
||||
|
||||
top_items.push(
|
||||
row![search, filter_toggle].spacing(4).into()
|
||||
);
|
||||
top_items.push(row![search, filter_toggle].spacing(4).into());
|
||||
|
||||
// Filter panel (collapsible)
|
||||
if filter.filter_panel_visible {
|
||||
@@ -861,7 +887,7 @@ pub fn view(
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
let items: Vec<Element<'static, Message>> = media
|
||||
@@ -874,36 +900,38 @@ pub fn view(
|
||||
};
|
||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 48.0;
|
||||
let display_name = truncate_to_fit(&display_name, text_px);
|
||||
let style_fn = if is_active { item_active_style } else { item_style };
|
||||
let style_fn = if is_active {
|
||||
item_active_style
|
||||
} else {
|
||||
item_style
|
||||
};
|
||||
|
||||
// Left: 40x40 thumbnail or file icon (pre-resolved, no filesystem I/O)
|
||||
let thumb_path = media_thumbs
|
||||
.get(&m.id)
|
||||
.and_then(|opt| opt.as_ref());
|
||||
let thumb_path = media_thumbs.get(&m.id).and_then(|opt| opt.as_ref());
|
||||
let left: Element<'static, Message> = if let Some(path) = thumb_path {
|
||||
container(
|
||||
image(path.to_string_lossy().to_string())
|
||||
.width(Length::Fixed(40.0))
|
||||
.height(Length::Fixed(40.0))
|
||||
.content_fit(iced::ContentFit::Cover)
|
||||
.content_fit(iced::ContentFit::Cover),
|
||||
)
|
||||
.width(Length::Fixed(40.0))
|
||||
.height(Length::Fixed(40.0))
|
||||
.clip(true)
|
||||
.style(thumbnail_container_style)
|
||||
.into()
|
||||
.width(Length::Fixed(40.0))
|
||||
.height(Length::Fixed(40.0))
|
||||
.clip(true)
|
||||
.style(thumbnail_container_style)
|
||||
.into()
|
||||
} else {
|
||||
container(
|
||||
text("\u{1F4C4}") // 📄 generic file icon
|
||||
.size(20)
|
||||
.shaping(Shaping::Advanced)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.width(Length::Fixed(40.0))
|
||||
.height(Length::Fixed(40.0))
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.style(thumbnail_container_style)
|
||||
.into()
|
||||
.width(Length::Fixed(40.0))
|
||||
.height(Length::Fixed(40.0))
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.style(thumbnail_container_style)
|
||||
.into()
|
||||
};
|
||||
|
||||
// Right column: name + metadata line
|
||||
@@ -924,13 +952,11 @@ pub fn view(
|
||||
|
||||
let right = column![name_text, meta_text].spacing(1);
|
||||
|
||||
let content = row![left, right].spacing(8).align_y(iced::Alignment::Center);
|
||||
let content = row![left, right]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
button(
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.clip(true)
|
||||
)
|
||||
button(container(content).width(Length::Fill).clip(true))
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: m.id.clone(),
|
||||
tab_type: TabType::Media,
|
||||
@@ -953,13 +979,13 @@ pub fn view(
|
||||
button(
|
||||
text(t(locale, "sidebar.loadMore"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::LoadMoreMedia)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill)
|
||||
.style(filter_button_style)
|
||||
.into()
|
||||
.on_press(Message::LoadMoreMedia)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill)
|
||||
.style(filter_button_style)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -986,12 +1012,12 @@ pub fn view(
|
||||
.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)
|
||||
)
|
||||
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,
|
||||
@@ -1005,9 +1031,7 @@ pub fn view(
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
iced::widget::Column::with_children(items).spacing(1).into()
|
||||
}
|
||||
}
|
||||
SidebarView::Templates => {
|
||||
@@ -1028,12 +1052,12 @@ pub fn view(
|
||||
.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)
|
||||
)
|
||||
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,
|
||||
@@ -1047,9 +1071,7 @@ pub fn view(
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
iced::widget::Column::with_children(items).spacing(1).into()
|
||||
}
|
||||
}
|
||||
SidebarView::Settings => {
|
||||
@@ -1070,9 +1092,7 @@ pub fn view(
|
||||
.iter()
|
||||
.map(|(key, section_opt)| {
|
||||
let label = t(locale, key);
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced);
|
||||
let label_text = text(label).size(12).shaping(Shaping::Advanced);
|
||||
let msg = if let Some(section) = section_opt {
|
||||
Message::OpenSettingsSection(section.clone())
|
||||
} else {
|
||||
@@ -1084,10 +1104,7 @@ pub fn view(
|
||||
is_dirty: false,
|
||||
})
|
||||
};
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
)
|
||||
button(container(label_text).width(Length::Fill))
|
||||
.on_press(msg)
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
@@ -1095,28 +1112,17 @@ pub fn view(
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
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 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)
|
||||
)
|
||||
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,
|
||||
@@ -1130,26 +1136,18 @@ pub fn view(
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
}
|
||||
_ => {
|
||||
text(t(locale, placeholder_key(sidebar_view)))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into()
|
||||
iced::widget::Column::with_children(items).spacing(1).into()
|
||||
}
|
||||
_ => text(t(locale, placeholder_key(sidebar_view)))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into(),
|
||||
};
|
||||
|
||||
let content = column![
|
||||
header_row,
|
||||
Space::with_height(8.0),
|
||||
body,
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(12);
|
||||
let content = column![header_row, Space::with_height(8.0), body,]
|
||||
.spacing(4)
|
||||
.padding(12);
|
||||
|
||||
// layout.allium: sidebar width is resizable, passed as parameter
|
||||
container(scrollable(content))
|
||||
|
||||
@@ -20,19 +20,41 @@ pub struct SiteValidationState {
|
||||
|
||||
pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let run_button = if state.is_running {
|
||||
button(text(t(locale, "siteValidation.running")).size(13).shaping(Shaping::Advanced))
|
||||
button(
|
||||
text(t(locale, "siteValidation.running"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
} else {
|
||||
button(text(t(locale, "siteValidation.run")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::RunSiteValidation)
|
||||
button(
|
||||
text(t(locale, "siteValidation.run"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::RunSiteValidation)
|
||||
};
|
||||
let has_issues = !state.missing_files.is_empty() || !state.extra_files.is_empty() || !state.stale_files.is_empty();
|
||||
let has_issues = !state.missing_files.is_empty()
|
||||
|| !state.extra_files.is_empty()
|
||||
|| !state.stale_files.is_empty();
|
||||
let apply_button = if state.is_applying {
|
||||
button(text(t(locale, "siteValidation.applying")).size(13).shaping(Shaping::Advanced))
|
||||
button(
|
||||
text(t(locale, "siteValidation.applying"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
} else if !state.is_running && state.error_message.is_none() && has_issues {
|
||||
button(text(t(locale, "siteValidation.apply")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::ApplySiteValidation)
|
||||
button(
|
||||
text(t(locale, "siteValidation.apply"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::ApplySiteValidation)
|
||||
} else {
|
||||
button(text(t(locale, "siteValidation.apply")).size(13).shaping(Shaping::Advanced))
|
||||
button(
|
||||
text(t(locale, "siteValidation.apply"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
};
|
||||
|
||||
let mut content = column![
|
||||
@@ -57,26 +79,41 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
|
||||
));
|
||||
} else if !state.has_run {
|
||||
content = content.push(help_text(t(locale, "siteValidation.idle")));
|
||||
} else if state.missing_files.is_empty() && state.extra_files.is_empty() && state.stale_files.is_empty() {
|
||||
} else if state.missing_files.is_empty()
|
||||
&& state.extra_files.is_empty()
|
||||
&& state.stale_files.is_empty()
|
||||
{
|
||||
content = content.push(help_text(t(locale, "siteValidation.clean")));
|
||||
} else {
|
||||
if !state.missing_files.is_empty() {
|
||||
content = content.push(section(
|
||||
format!("{} ({})", t(locale, "siteValidation.missing"), state.missing_files.len()),
|
||||
format!(
|
||||
"{} ({})",
|
||||
t(locale, "siteValidation.missing"),
|
||||
state.missing_files.len()
|
||||
),
|
||||
&state.missing_files,
|
||||
Color::from_rgb(0.70, 0.25, 0.25),
|
||||
));
|
||||
}
|
||||
if !state.extra_files.is_empty() {
|
||||
content = content.push(section(
|
||||
format!("{} ({})", t(locale, "siteValidation.extra"), state.extra_files.len()),
|
||||
format!(
|
||||
"{} ({})",
|
||||
t(locale, "siteValidation.extra"),
|
||||
state.extra_files.len()
|
||||
),
|
||||
&state.extra_files,
|
||||
Color::from_rgb(0.68, 0.48, 0.18),
|
||||
));
|
||||
}
|
||||
if !state.stale_files.is_empty() {
|
||||
content = content.push(section(
|
||||
format!("{} ({})", t(locale, "siteValidation.stale"), state.stale_files.len()),
|
||||
format!(
|
||||
"{} ({})",
|
||||
t(locale, "siteValidation.stale"),
|
||||
state.stale_files.len()
|
||||
),
|
||||
&state.stale_files,
|
||||
Color::from_rgb(0.62, 0.32, 0.18),
|
||||
));
|
||||
@@ -135,4 +172,4 @@ fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<'
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use iced::widget::{button, container, row, text, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Space, button, container, row, text};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -110,6 +110,10 @@ pub fn dropdown_bg(_theme: &Theme) -> container::Style {
|
||||
// View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "arguments are independent status values"
|
||||
)]
|
||||
pub fn view(
|
||||
active_project_name: Option<&str>,
|
||||
post_count: usize,
|
||||
@@ -132,12 +136,11 @@ pub fn view(
|
||||
.collect();
|
||||
let task_indicator: Element<'static, Message> = if !running.is_empty() {
|
||||
let first = &running[0];
|
||||
let progress_str = first.progress
|
||||
let progress_str = first
|
||||
.progress
|
||||
.map(|p| format!(" {:.0}%", p * 100.0))
|
||||
.unwrap_or_default();
|
||||
let phase_str = first.message
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let phase_str = first.message.as_deref().unwrap_or("");
|
||||
let extra = if running.len() > 1 {
|
||||
format!(" (+{})", running.len() - 1)
|
||||
} else {
|
||||
@@ -148,7 +151,11 @@ pub fn view(
|
||||
} else {
|
||||
format!("{phase_str}{progress_str}{extra}")
|
||||
};
|
||||
text(display).size(11).shaping(Shaping::Advanced).color(label_color).into()
|
||||
text(display)
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(label_color)
|
||||
.into()
|
||||
} else {
|
||||
Space::with_width(0).into()
|
||||
};
|
||||
@@ -162,27 +169,41 @@ pub fn view(
|
||||
// Post status indicator dot (per layout.allium StatusBarRight.post_status)
|
||||
let post_status_el: Element<'static, Message> = if let Some(status) = active_post_status {
|
||||
let (dot, color) = match status {
|
||||
"draft" => ("\u{25CB}", Color::from_rgb(0.60, 0.60, 0.65)), // hollow circle
|
||||
"published" => ("\u{25CF}", Color::from_rgb(0.30, 0.75, 0.40)), // green filled
|
||||
"archived" => ("\u{25A0}", Color::from_rgb(0.60, 0.60, 0.65)), // square
|
||||
"draft" => ("\u{25CB}", Color::from_rgb(0.60, 0.60, 0.65)), // hollow circle
|
||||
"published" => ("\u{25CF}", Color::from_rgb(0.30, 0.75, 0.40)), // green filled
|
||||
"archived" => ("\u{25A0}", Color::from_rgb(0.60, 0.60, 0.65)), // square
|
||||
_ => ("\u{25CF}", Color::from_rgb(0.60, 0.60, 0.65)),
|
||||
};
|
||||
text(dot).size(11).shaping(Shaping::Advanced).color(color).into()
|
||||
text(dot)
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(color)
|
||||
.into()
|
||||
} else {
|
||||
Space::with_width(0).into()
|
||||
};
|
||||
|
||||
// Post + media counts
|
||||
let posts_label = tw(locale, "statusBar.posts", &[("count", &post_count.to_string())]);
|
||||
let media_label = tw(locale, "statusBar.media", &[("count", &media_count.to_string())]);
|
||||
let posts_label = tw(
|
||||
locale,
|
||||
"statusBar.posts",
|
||||
&[("count", &post_count.to_string())],
|
||||
);
|
||||
let media_label = tw(
|
||||
locale,
|
||||
"statusBar.media",
|
||||
&[("count", &media_count.to_string())],
|
||||
);
|
||||
|
||||
// Airplane mode toggle — ✈ icon
|
||||
let airplane_btn = button(
|
||||
text("\u{2708}").size(13).shaping(Shaping::Advanced),
|
||||
)
|
||||
let airplane_btn = button(text("\u{2708}").size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::SetOfflineMode(!offline_mode))
|
||||
.padding([2, 4])
|
||||
.style(if offline_mode { airplane_active } else { airplane_inactive });
|
||||
.style(if offline_mode {
|
||||
airplane_active
|
||||
} else {
|
||||
airplane_inactive
|
||||
});
|
||||
|
||||
// Language selector — current flag as trigger
|
||||
let trigger_flag = text(locale.flag_emoji())
|
||||
@@ -196,24 +217,32 @@ pub fn view(
|
||||
|
||||
let right = row![
|
||||
post_status_el,
|
||||
text(posts_label).size(11).shaping(Shaping::Advanced).color(label_color),
|
||||
text(media_label).size(11).shaping(Shaping::Advanced).color(label_color),
|
||||
text(theme_badge.to_string()).size(11).shaping(Shaping::Advanced).color(label_color),
|
||||
text(posts_label)
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(label_color),
|
||||
text(media_label)
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(label_color),
|
||||
text(theme_badge.to_string())
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(label_color),
|
||||
airplane_btn,
|
||||
locale_trigger,
|
||||
text("bDS").size(11).shaping(Shaping::Advanced).color(Color::from_rgb(0.45, 0.45, 0.50)),
|
||||
text("bDS")
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.45, 0.45, 0.50)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
container(
|
||||
row![
|
||||
left,
|
||||
Space::with_width(Length::Fill),
|
||||
right,
|
||||
]
|
||||
.align_y(Alignment::Center)
|
||||
.padding([0, 8]),
|
||||
row![left, Space::with_width(Length::Fill), right,]
|
||||
.align_y(Alignment::Center)
|
||||
.padding([0, 8]),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(24.0))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use iced::widget::{button, container, row, scrollable, text, tooltip, Space};
|
||||
use iced::widget::scrollable::Direction;
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::tooltip::Position;
|
||||
use iced::widget::{Space, button, container, row, scrollable, text, tooltip};
|
||||
use iced::{Background, Border, Color, Element, Font, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -106,7 +106,10 @@ fn truncate_chat_title(title: &str) -> String {
|
||||
/// Uses "…" (Unicode ellipsis) as the truncation indicator.
|
||||
fn truncate_tab_title(title: &str) -> String {
|
||||
if title.chars().count() > TAB_TITLE_MAX_LEN {
|
||||
let truncated: String = title.chars().take(TAB_TITLE_MAX_LEN.saturating_sub(1)).collect();
|
||||
let truncated: String = title
|
||||
.chars()
|
||||
.take(TAB_TITLE_MAX_LEN.saturating_sub(1))
|
||||
.collect();
|
||||
format!("{truncated}\u{2026}")
|
||||
} else {
|
||||
title.to_string()
|
||||
@@ -129,11 +132,7 @@ fn build_tooltip_text(tab: &Tab, locale: UiLocale) -> String {
|
||||
tip
|
||||
}
|
||||
|
||||
pub fn view(
|
||||
tabs: &[Tab],
|
||||
active_tab: Option<&str>,
|
||||
locale: UiLocale,
|
||||
) -> Element<'static, Message> {
|
||||
pub fn view(tabs: &[Tab], active_tab: Option<&str>, locale: UiLocale) -> Element<'static, Message> {
|
||||
// Per tabs.allium: "Hidden when no tabs exist."
|
||||
if tabs.is_empty() {
|
||||
return Space::with_height(0).into();
|
||||
@@ -159,7 +158,10 @@ pub fn view(
|
||||
text(display_title)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT })
|
||||
.font(Font {
|
||||
style: iced::font::Style::Italic,
|
||||
..Font::DEFAULT
|
||||
})
|
||||
.wrapping(iced::widget::text::Wrapping::None)
|
||||
} else {
|
||||
text(display_title)
|
||||
@@ -170,7 +172,9 @@ pub fn view(
|
||||
|
||||
// Per tabs.allium DirtyIndicator: dot for dirty post tabs
|
||||
let dirty_indicator = if tab.is_dirty {
|
||||
text(" \u{25CF}").size(10).shaping(Shaping::Advanced)
|
||||
text(" \u{25CF}")
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.90, 0.70, 0.30))
|
||||
} else {
|
||||
text("").size(10)
|
||||
@@ -180,7 +184,7 @@ pub fn view(
|
||||
let title_area = container(
|
||||
row![title_label, dirty_indicator]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.align_y(iced::Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.clip(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
|
||||
use iced::widget::{Space, button, column, container, row, scrollable, text, text_input};
|
||||
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -12,9 +12,9 @@ use crate::i18n::{t, tw};
|
||||
|
||||
const DEFAULT_TAG_COLOR: &str = "#6495ed";
|
||||
const COLOR_PRESETS: [&str; 17] = [
|
||||
"#e63946", "#f77f00", "#fcbf49", "#90be6d", "#43aa8b", "#4d908e",
|
||||
"#577590", "#277da1", "#4361ee", "#7209b7", "#b5179e", "#f72585",
|
||||
"#9c6644", "#6c757d", "#adb5bd", "#2a9d8f", "#264653",
|
||||
"#e63946", "#f77f00", "#fcbf49", "#90be6d", "#43aa8b", "#4d908e", "#577590", "#277da1",
|
||||
"#4361ee", "#7209b7", "#b5179e", "#f72585", "#9c6644", "#6c757d", "#adb5bd", "#2a9d8f",
|
||||
"#264653",
|
||||
];
|
||||
|
||||
/// Active view within the Tags tab.
|
||||
@@ -118,10 +118,26 @@ pub enum TagsMsg {
|
||||
/// Render the tags management view.
|
||||
pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let section_nav = row![
|
||||
section_tab(&t(locale, "tags.nav.cloud"), state.section == TagsSection::Cloud, TagsSection::Cloud),
|
||||
section_tab(&t(locale, "tags.nav.manage"), state.section == TagsSection::Manage, TagsSection::Manage),
|
||||
section_tab(&t(locale, "tags.nav.merge"), state.section == TagsSection::Merge, TagsSection::Merge),
|
||||
section_tab(&t(locale, "tags.nav.discover"), state.section == TagsSection::Discover, TagsSection::Discover),
|
||||
section_tab(
|
||||
&t(locale, "tags.nav.cloud"),
|
||||
state.section == TagsSection::Cloud,
|
||||
TagsSection::Cloud
|
||||
),
|
||||
section_tab(
|
||||
&t(locale, "tags.nav.manage"),
|
||||
state.section == TagsSection::Manage,
|
||||
TagsSection::Manage
|
||||
),
|
||||
section_tab(
|
||||
&t(locale, "tags.nav.merge"),
|
||||
state.section == TagsSection::Merge,
|
||||
TagsSection::Merge
|
||||
),
|
||||
section_tab(
|
||||
&t(locale, "tags.nav.discover"),
|
||||
state.section == TagsSection::Discover,
|
||||
TagsSection::Discover
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(8);
|
||||
@@ -168,7 +184,9 @@ fn section_tab<'a>(label: &str, active: bool, section: TagsSection) -> Element<'
|
||||
fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
if state.tags.is_empty() {
|
||||
return container(
|
||||
text(t(locale, "tags.noTags")).size(14).color(Color::from_rgb(0.5, 0.5, 0.5)),
|
||||
text(t(locale, "tags.noTags"))
|
||||
.size(14)
|
||||
.color(Color::from_rgb(0.5, 0.5, 0.5)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
@@ -180,7 +198,12 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
|
||||
let counts: Vec<usize> = state
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| *state.tag_post_counts.get(&tag.name.to_lowercase()).unwrap_or(&0))
|
||||
.map(|tag| {
|
||||
*state
|
||||
.tag_post_counts
|
||||
.get(&tag.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);
|
||||
@@ -197,14 +220,23 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
|
||||
.iter()
|
||||
.map(|tag| {
|
||||
let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR));
|
||||
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 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;
|
||||
let selected = state.selected_tags.iter().any(|selected_id| selected_id == &tag.id);
|
||||
let selected = state
|
||||
.selected_tags
|
||||
.iter()
|
||||
.any(|selected_id| selected_id == &tag.id);
|
||||
button(
|
||||
row![
|
||||
text(&tag.name).size(font_size).color(Color::WHITE),
|
||||
text(post_count.to_string()).size(11).color(Color::from_rgba(1.0, 1.0, 1.0, 0.85)),
|
||||
text(post_count.to_string())
|
||||
.size(11)
|
||||
.color(Color::from_rgba(1.0, 1.0, 1.0, 0.85)),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center),
|
||||
@@ -216,7 +248,11 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
|
||||
border: iced::Border {
|
||||
radius: 12.0.into(),
|
||||
width: if selected { 2.0 } else { 0.0 },
|
||||
color: if selected { Color::WHITE } else { Color::TRANSPARENT },
|
||||
color: if selected {
|
||||
Color::WHITE
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
},
|
||||
},
|
||||
text_color: Color::WHITE,
|
||||
..button::Style::default()
|
||||
@@ -226,12 +262,19 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
|
||||
.collect();
|
||||
|
||||
let selection_summary: Element<'a, Message> = if state.selected_tags.is_empty() {
|
||||
text(t(locale, "tags.cloudHelp")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)).into()
|
||||
text(t(locale, "tags.cloudHelp"))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.65))
|
||||
.into()
|
||||
} else {
|
||||
row![
|
||||
text(tw(locale, "tags.selectedCount", &[("count", &state.selected_tags.len().to_string())]))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.75, 0.77, 0.82)),
|
||||
text(tw(
|
||||
locale,
|
||||
"tags.selectedCount",
|
||||
&[("count", &state.selected_tags.len().to_string())]
|
||||
))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.75, 0.77, 0.82)),
|
||||
button(text(t(locale, "tags.clearSelection")).size(12))
|
||||
.on_press(Message::Tags(TagsMsg::ClearSelection))
|
||||
.padding([4, 8]),
|
||||
@@ -273,7 +316,9 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
|
||||
),
|
||||
color_swatches(locale, true),
|
||||
button(text(t(locale, "tags.createButton")).size(13))
|
||||
.on_press_maybe((!state.create_name.trim().is_empty()).then_some(Message::Tags(TagsMsg::CreateTag)))
|
||||
.on_press_maybe(
|
||||
(!state.create_name.trim().is_empty()).then_some(Message::Tags(TagsMsg::CreateTag))
|
||||
)
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 16]),
|
||||
]
|
||||
@@ -288,7 +333,10 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
|
||||
.iter()
|
||||
.filter(|tag| {
|
||||
state.search_query.is_empty()
|
||||
|| tag.name.to_lowercase().contains(&state.search_query.to_lowercase())
|
||||
|| tag
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&state.search_query.to_lowercase())
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -296,8 +344,15 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
|
||||
.iter()
|
||||
.map(|tag| {
|
||||
let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR));
|
||||
let selected = state.selected_tags.iter().any(|selected_id| selected_id == &tag.id);
|
||||
let count = state.tag_post_counts.get(&tag.name.to_lowercase()).copied().unwrap_or(0);
|
||||
let selected = state
|
||||
.selected_tags
|
||||
.iter()
|
||||
.any(|selected_id| selected_id == &tag.id);
|
||||
let count = state
|
||||
.tag_post_counts
|
||||
.get(&tag.name.to_lowercase())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
button(
|
||||
row![
|
||||
container(Space::new(12, 12)).style(move |_: &Theme| container::Style {
|
||||
@@ -309,7 +364,9 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
|
||||
..container::Style::default()
|
||||
}),
|
||||
text(&tag.name).size(14),
|
||||
text(format!("({count})")).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)),
|
||||
text(format!("({count})"))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.55, 0.58, 0.65)),
|
||||
Space::with_width(Length::Fill),
|
||||
]
|
||||
.spacing(8)
|
||||
@@ -338,21 +395,21 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
|
||||
|
||||
let edit_panel: Element<'a, Message> = if let Some(ref editing) = state.editing_tag {
|
||||
let template_options = template_options(state, locale);
|
||||
let selected_template = template_options.iter().find(|option| option.slug == editing.template_slug);
|
||||
let delete_button: Element<'a, Message> = button(text(t(locale, "modal.confirmDelete.delete")).size(13))
|
||||
.on_press(Message::Tags(TagsMsg::DeleteTag(editing.id.clone())))
|
||||
.style(inputs::danger_button)
|
||||
.padding([6, 16])
|
||||
.into();
|
||||
let selected_template = template_options
|
||||
.iter()
|
||||
.find(|option| option.slug == editing.template_slug);
|
||||
let delete_button: Element<'a, Message> =
|
||||
button(text(t(locale, "modal.confirmDelete.delete")).size(13))
|
||||
.on_press(Message::Tags(TagsMsg::DeleteTag(editing.id.clone())))
|
||||
.style(inputs::danger_button)
|
||||
.padding([6, 16])
|
||||
.into();
|
||||
|
||||
column![
|
||||
inputs::section_header(&t(locale, "tags.editTag")),
|
||||
inputs::labeled_input(
|
||||
&t(locale, "tags.name"),
|
||||
"",
|
||||
&editing.name,
|
||||
|value| Message::Tags(TagsMsg::EditTagName(value)),
|
||||
),
|
||||
inputs::labeled_input(&t(locale, "tags.name"), "", &editing.name, |value| {
|
||||
Message::Tags(TagsMsg::EditTagName(value))
|
||||
},),
|
||||
inputs::labeled_input(
|
||||
&t(locale, "tags.color"),
|
||||
DEFAULT_TAG_COLOR,
|
||||
@@ -378,9 +435,13 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
|
||||
.spacing(8)
|
||||
.into()
|
||||
} else {
|
||||
container(text(t(locale, "tags.selectTag")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)))
|
||||
.padding([8, 0])
|
||||
.into()
|
||||
container(
|
||||
text(t(locale, "tags.selectTag"))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.65)),
|
||||
)
|
||||
.padding([8, 0])
|
||||
.into()
|
||||
};
|
||||
|
||||
scrollable(
|
||||
@@ -402,9 +463,13 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
|
||||
|
||||
fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
if state.selected_tags.len() < 2 {
|
||||
return container(text(t(locale, "tags.mergeHelp")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)))
|
||||
.padding(16)
|
||||
.into();
|
||||
return container(
|
||||
text(t(locale, "tags.mergeHelp"))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.65)),
|
||||
)
|
||||
.padding(16)
|
||||
.into();
|
||||
}
|
||||
|
||||
let tag_options = selected_tag_options(state);
|
||||
@@ -428,9 +493,13 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
|
||||
|
||||
column![
|
||||
inputs::section_header(&t(locale, "tags.mergeSection")),
|
||||
text(tw(locale, "tags.selectedCount", &[("count", &state.selected_tags.len().to_string())]))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.75, 0.77, 0.82)),
|
||||
text(tw(
|
||||
locale,
|
||||
"tags.selectedCount",
|
||||
&[("count", &state.selected_tags.len().to_string())]
|
||||
))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.75, 0.77, 0.82)),
|
||||
inputs::labeled_select(
|
||||
&t(locale, "tags.mergeTarget"),
|
||||
&tag_options,
|
||||
@@ -445,7 +514,12 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
|
||||
.into()
|
||||
},
|
||||
button(text(t(locale, "tags.merge")).size(13))
|
||||
.on_press_maybe(state.merge_target.is_some().then_some(Message::Tags(TagsMsg::MergeTags)))
|
||||
.on_press_maybe(
|
||||
state
|
||||
.merge_target
|
||||
.is_some()
|
||||
.then_some(Message::Tags(TagsMsg::MergeTags))
|
||||
)
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 16]),
|
||||
]
|
||||
@@ -489,10 +563,14 @@ fn selected_tag_options(state: &TagsViewState) -> Vec<TagOption> {
|
||||
.selected_tags
|
||||
.iter()
|
||||
.filter_map(|tag_id| {
|
||||
state.tags.iter().find(|tag| &tag.id == tag_id).map(|tag| TagOption {
|
||||
id: tag.id.clone(),
|
||||
name: tag.name.clone(),
|
||||
})
|
||||
state
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| &tag.id == tag_id)
|
||||
.map(|tag| TagOption {
|
||||
id: tag.id.clone(),
|
||||
name: tag.name.clone(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -523,7 +601,9 @@ fn color_swatches<'a>(locale: UiLocale, create_mode: bool) -> Element<'a, Messag
|
||||
.collect();
|
||||
|
||||
column![
|
||||
text(t(locale, "tags.colorPresets")).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)),
|
||||
text(t(locale, "tags.colorPresets"))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.55, 0.58, 0.65)),
|
||||
row(buttons).spacing(6).wrap(),
|
||||
]
|
||||
.spacing(6)
|
||||
@@ -540,4 +620,4 @@ fn parse_tag_color(hex: &str) -> Color {
|
||||
} else {
|
||||
Color::from_rgb(0.4, 0.5, 0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use iced::widget::{button, column, container, row, scrollable, text, Space};
|
||||
use iced::widget::{Space, button, column, container, row, scrollable, text};
|
||||
use iced::{Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -107,10 +107,7 @@ pub enum TemplateEditorMsg {
|
||||
}
|
||||
|
||||
/// Render the template editor view.
|
||||
pub fn view<'a>(
|
||||
state: &'a TemplateEditorState,
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
pub fn view<'a>(state: &'a TemplateEditorState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let header = inputs::toolbar(
|
||||
vec![
|
||||
text(state.title.clone()).size(18).into(),
|
||||
@@ -147,7 +144,9 @@ pub fn view<'a>(
|
||||
&state.slug,
|
||||
|s| Message::TemplateEditor(TemplateEditorMsg::SlugChanged(s)),
|
||||
);
|
||||
let meta_row1 = row![title_input, slug_input].spacing(16).width(Length::Fill);
|
||||
let meta_row1 = row![title_input, slug_input]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Metadata row 2: kind (select), enabled (checkbox)
|
||||
let kind_options = vec![
|
||||
@@ -163,25 +162,27 @@ pub fn view<'a>(
|
||||
selected_kind.as_ref(),
|
||||
|k| Message::TemplateEditor(TemplateEditorMsg::KindChanged(k)),
|
||||
);
|
||||
let enabled_check = inputs::labeled_checkbox(
|
||||
&t(locale, "editor.enabled"),
|
||||
state.enabled,
|
||||
|b| Message::TemplateEditor(TemplateEditorMsg::EnabledChanged(b)),
|
||||
);
|
||||
let meta_row2 = row![kind_select, enabled_check].spacing(16).width(Length::Fill);
|
||||
let enabled_check =
|
||||
inputs::labeled_checkbox(&t(locale, "editor.enabled"), state.enabled, |b| {
|
||||
Message::TemplateEditor(TemplateEditorMsg::EnabledChanged(b))
|
||||
});
|
||||
let meta_row2 = row![kind_select, enabled_check]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Content editor (CodeEditor with Liquid/HTML syntax highlighting)
|
||||
let content_section: Element<'a, Message> = column![
|
||||
inputs::section_header(&t(locale, "editor.content")),
|
||||
Element::from(
|
||||
CodeEditor::new(
|
||||
&state.editor_buffer,
|
||||
highlighter(),
|
||||
"liquid",
|
||||
)
|
||||
.on_change(|msg| match msg {
|
||||
EditorMessage::ContentChanged(s) => Message::TemplateEditor(TemplateEditorMsg::ContentChanged(s)),
|
||||
EditorMessage::SaveRequested => Message::TemplateEditor(TemplateEditorMsg::Save),
|
||||
CodeEditor::new(&state.editor_buffer, highlighter(), "liquid",).on_change(|msg| {
|
||||
match msg {
|
||||
EditorMessage::ContentChanged(s) => {
|
||||
Message::TemplateEditor(TemplateEditorMsg::ContentChanged(s))
|
||||
}
|
||||
EditorMessage::SaveRequested => {
|
||||
Message::TemplateEditor(TemplateEditorMsg::Save)
|
||||
}
|
||||
}
|
||||
})
|
||||
),
|
||||
]
|
||||
@@ -213,29 +214,20 @@ pub fn view<'a>(
|
||||
|
||||
// Top pane: header + metadata (scrollable for overflow)
|
||||
let top_pane = scrollable(
|
||||
column![
|
||||
header,
|
||||
meta_row1,
|
||||
meta_row2,
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
column![header, meta_row1, meta_row2,]
|
||||
.spacing(12)
|
||||
.padding(16)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.height(Length::Shrink);
|
||||
|
||||
// Full layout: top pane (shrink), content (fill), validation + footer (shrink)
|
||||
column![
|
||||
top_pane,
|
||||
content_section,
|
||||
validation,
|
||||
footer,
|
||||
]
|
||||
.spacing(4)
|
||||
.padding([0, 16])
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
column![top_pane, content_section, validation, footer,]
|
||||
.spacing(4)
|
||||
.padding([0, 16])
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn status_badge<'a>(status: &TemplateStatus) -> Element<'a, Message> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use iced::widget::{button, container, row, text, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Space, button, container, row, text};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme};
|
||||
|
||||
use crate::app::Message;
|
||||
@@ -95,7 +95,12 @@ pub fn view(toasts: &[Toast]) -> Option<Element<'static, Message>> {
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.padding(Padding { top: 8.0, right: 0.0, bottom: 0.0, left: 0.0 }),
|
||||
.padding(Padding {
|
||||
top: 8.0,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
}),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Shrink)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use iced::widget::{column, container, text};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{column, container, text};
|
||||
use iced::{Background, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -37,11 +37,12 @@ pub fn view(locale: UiLocale) -> Element<'static, Message> {
|
||||
}
|
||||
|
||||
pub fn tab_placeholder(
|
||||
locale: UiLocale,
|
||||
title: impl Into<String>,
|
||||
subtitle: Option<String>,
|
||||
) -> Element<'static, Message> {
|
||||
let title = title.into();
|
||||
let subtitle = subtitle.unwrap_or_else(|| "This tab is routed correctly, but its content surface is not implemented yet.".to_string());
|
||||
let subtitle = subtitle.unwrap_or_else(|| t(locale, "common.tabNotImplemented"));
|
||||
|
||||
container(
|
||||
column![
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
|
||||
use iced::widget::{Space, button, column, container, mouse_area, row, stack, text};
|
||||
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -20,10 +20,11 @@ use crate::views::{
|
||||
modal, panel,
|
||||
post_editor::{self, PostEditorState},
|
||||
project_selector,
|
||||
site_validation::{self, SiteValidationState},
|
||||
script_editor::{self, ScriptEditorState},
|
||||
settings_view::{self, SettingsViewState},
|
||||
sidebar, status_bar, tab_bar,
|
||||
sidebar,
|
||||
site_validation::{self, SiteValidationState},
|
||||
status_bar, tab_bar,
|
||||
tags_view::{self, TagsViewState},
|
||||
template_editor::{self, TemplateEditorState},
|
||||
toast, welcome,
|
||||
@@ -58,6 +59,10 @@ fn separator_h<'a>() -> Element<'a, Message> {
|
||||
}
|
||||
|
||||
/// Compose the full workspace layout.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "root Iced view receives the application state explicitly"
|
||||
)]
|
||||
pub fn view<'a>(
|
||||
// Navigation
|
||||
sidebar_view: SidebarView,
|
||||
@@ -344,6 +349,10 @@ pub fn view<'a>(
|
||||
}
|
||||
|
||||
/// Route the content area based on the active tab type.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "router receives the state required by each routed view"
|
||||
)]
|
||||
fn route_content_area<'a>(
|
||||
tabs: &'a [Tab],
|
||||
active_tab: Option<&'a str>,
|
||||
@@ -378,14 +387,25 @@ fn route_content_area<'a>(
|
||||
ContentRoute::Post(tab_id) => {
|
||||
if let Some(state) = post_editors.get(tab_id) {
|
||||
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(true);
|
||||
post_editor::view(state, locale, wrap, is_ai_enabled(settings_state, offline_mode), post_preview_widget)
|
||||
post_editor::view(
|
||||
state,
|
||||
locale,
|
||||
wrap,
|
||||
is_ai_enabled(settings_state, offline_mode),
|
||||
post_preview_widget,
|
||||
)
|
||||
} else {
|
||||
loading_view(locale)
|
||||
}
|
||||
}
|
||||
ContentRoute::Media(tab_id) => {
|
||||
if let Some(state) = media_editors.get(tab_id) {
|
||||
media_editor::view(state, locale, data_dir, is_ai_enabled(settings_state, offline_mode))
|
||||
media_editor::view(
|
||||
state,
|
||||
locale,
|
||||
data_dir,
|
||||
is_ai_enabled(settings_state, offline_mode),
|
||||
)
|
||||
} else {
|
||||
loading_view(locale)
|
||||
}
|
||||
@@ -419,7 +439,7 @@ fn route_content_area<'a>(
|
||||
}
|
||||
}
|
||||
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
|
||||
ContentRoute::Placeholder(title) => welcome::tab_placeholder(title, None),
|
||||
ContentRoute::Placeholder(title) => welcome::tab_placeholder(locale, title, None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,6 +458,10 @@ enum ContentRoute<'a> {
|
||||
Placeholder(&'a str),
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "route selection checks independent loaded-state collections"
|
||||
)]
|
||||
fn route_kind<'a>(
|
||||
tabs: &'a [Tab],
|
||||
active_tab: Option<&'a str>,
|
||||
@@ -451,30 +475,58 @@ fn route_kind<'a>(
|
||||
_site_validation_state: &'a SiteValidationState,
|
||||
) -> ContentRoute<'a> {
|
||||
let Some(tab_id) = active_tab else {
|
||||
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
|
||||
return dashboard_state
|
||||
.map(ContentRoute::Dashboard)
|
||||
.unwrap_or(ContentRoute::Welcome);
|
||||
};
|
||||
let Some(tab) = tabs.iter().find(|t| t.id == tab_id) else {
|
||||
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
|
||||
return dashboard_state
|
||||
.map(ContentRoute::Dashboard)
|
||||
.unwrap_or(ContentRoute::Welcome);
|
||||
};
|
||||
|
||||
match tab.tab_type {
|
||||
TabType::Post => {
|
||||
if post_editors.contains_key(tab_id) { ContentRoute::Post(tab_id) } else { ContentRoute::Loading }
|
||||
if post_editors.contains_key(tab_id) {
|
||||
ContentRoute::Post(tab_id)
|
||||
} else {
|
||||
ContentRoute::Loading
|
||||
}
|
||||
}
|
||||
TabType::Media => {
|
||||
if media_editors.contains_key(tab_id) { ContentRoute::Media(tab_id) } else { ContentRoute::Loading }
|
||||
if media_editors.contains_key(tab_id) {
|
||||
ContentRoute::Media(tab_id)
|
||||
} else {
|
||||
ContentRoute::Loading
|
||||
}
|
||||
}
|
||||
TabType::Templates => {
|
||||
if template_editors.contains_key(tab_id) { ContentRoute::Templates(tab_id) } else { ContentRoute::Loading }
|
||||
if template_editors.contains_key(tab_id) {
|
||||
ContentRoute::Templates(tab_id)
|
||||
} else {
|
||||
ContentRoute::Loading
|
||||
}
|
||||
}
|
||||
TabType::Scripts => {
|
||||
if script_editors.contains_key(tab_id) { ContentRoute::Scripts(tab_id) } else { ContentRoute::Loading }
|
||||
if script_editors.contains_key(tab_id) {
|
||||
ContentRoute::Scripts(tab_id)
|
||||
} else {
|
||||
ContentRoute::Loading
|
||||
}
|
||||
}
|
||||
TabType::Tags => {
|
||||
if tags_view_state.is_some() { ContentRoute::Tags } else { ContentRoute::Loading }
|
||||
if tags_view_state.is_some() {
|
||||
ContentRoute::Tags
|
||||
} else {
|
||||
ContentRoute::Loading
|
||||
}
|
||||
}
|
||||
TabType::Settings => {
|
||||
if settings_state.is_some() { ContentRoute::Settings } else { ContentRoute::Loading }
|
||||
if settings_state.is_some() {
|
||||
ContentRoute::Settings
|
||||
} else {
|
||||
ContentRoute::Loading
|
||||
}
|
||||
}
|
||||
TabType::SiteValidation => ContentRoute::SiteValidation,
|
||||
TabType::Style
|
||||
@@ -496,7 +548,8 @@ fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool)
|
||||
};
|
||||
|
||||
if offline_mode {
|
||||
!state.airplane_endpoint_url.trim().is_empty() && !state.airplane_endpoint_model.trim().is_empty()
|
||||
!state.airplane_endpoint_url.trim().is_empty()
|
||||
&& !state.airplane_endpoint_model.trim().is_empty()
|
||||
} else {
|
||||
!state.online_endpoint_url.trim().is_empty()
|
||||
&& !state.online_endpoint_model.trim().is_empty()
|
||||
@@ -504,6 +557,20 @@ fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool)
|
||||
}
|
||||
}
|
||||
|
||||
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||
use crate::i18n::t;
|
||||
container(
|
||||
text(t(locale, "tabBar.loading"))
|
||||
.size(14)
|
||||
.color(Color::from_rgb(0.5, 0.5, 0.5)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -593,7 +660,11 @@ mod tests {
|
||||
let empty_templates = HashMap::new();
|
||||
let empty_scripts = HashMap::new();
|
||||
let site_validation_state = SiteValidationState::default();
|
||||
let tabs = vec![tab("site_validation", TabType::SiteValidation, "Validation")];
|
||||
let tabs = vec![tab(
|
||||
"site_validation",
|
||||
TabType::SiteValidation,
|
||||
"Validation",
|
||||
)];
|
||||
|
||||
let route = route_kind(
|
||||
&tabs,
|
||||
@@ -631,17 +702,3 @@ mod tests {
|
||||
assert!(is_ai_enabled(Some(&settings), true));
|
||||
}
|
||||
}
|
||||
|
||||
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||
use crate::i18n::t;
|
||||
container(
|
||||
text(t(locale, "tabBar.loading"))
|
||||
.size(14)
|
||||
.color(Color::from_rgb(0.5, 0.5, 0.5)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user