fix: more finishing work for M3
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ pub struct MediaEditorState {
|
||||
pub language: String,
|
||||
pub file_path: String,
|
||||
pub tags: Vec<String>,
|
||||
pub tags_input: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub is_dirty: bool,
|
||||
@@ -79,6 +80,7 @@ impl MediaEditorState {
|
||||
language: canonical_lang.clone(),
|
||||
file_path: media.file_path.clone(),
|
||||
tags: media.tags.clone(),
|
||||
tags_input: media.tags.join(", "),
|
||||
created_at: media.created_at,
|
||||
updated_at: media.updated_at,
|
||||
is_dirty: false,
|
||||
@@ -169,6 +171,8 @@ pub enum MediaEditorMsg {
|
||||
AltChanged(String),
|
||||
CaptionChanged(String),
|
||||
AuthorChanged(String),
|
||||
LanguageChanged(String),
|
||||
TagsChanged(String),
|
||||
SwitchLanguage(String),
|
||||
Save,
|
||||
Delete,
|
||||
@@ -284,7 +288,25 @@ pub fn view<'a>(
|
||||
&state.author,
|
||||
|s| Message::MediaEditor(MediaEditorMsg::AuthorChanged(s)),
|
||||
);
|
||||
let tags_input = inputs::labeled_input(
|
||||
&t(locale, "editor.tags"),
|
||||
&t(locale, "editor.tagsPlaceholder"),
|
||||
&state.tags_input,
|
||||
|s| Message::MediaEditor(MediaEditorMsg::TagsChanged(s)),
|
||||
);
|
||||
let language_options = if state.blog_languages.is_empty() {
|
||||
vec![state.language.clone()]
|
||||
} else {
|
||||
state.blog_languages.clone()
|
||||
};
|
||||
let language_input = inputs::labeled_select(
|
||||
&t(locale, "editor.language"),
|
||||
&language_options,
|
||||
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);
|
||||
|
||||
// Footer
|
||||
let footer = row![
|
||||
@@ -303,6 +325,7 @@ pub fn view<'a>(
|
||||
inputs::section_header(&t(locale, "editor.metadata")),
|
||||
meta_row1,
|
||||
meta_row2,
|
||||
meta_row3,
|
||||
footer,
|
||||
]
|
||||
.spacing(12)
|
||||
|
||||
@@ -15,4 +15,3 @@ pub mod script_editor;
|
||||
pub mod tags_view;
|
||||
pub mod settings_view;
|
||||
pub mod dashboard;
|
||||
pub mod translation_editor;
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
use iced::widget::{button, column, container, row, text, Space};
|
||||
use std::path::Path;
|
||||
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{button, column, container, image, row, text, text_input, Space};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
use bds_core::model::Media;
|
||||
use bds_core::util::paths::thumbnail_path;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
use crate::views::post_editor::PostEditorMsg;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InsertLinkResult {
|
||||
pub post_id: String,
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub canonical_url: String,
|
||||
}
|
||||
|
||||
/// Active modal state. Only one modal at a time.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -22,6 +35,37 @@ pub enum ModalState {
|
||||
message: String,
|
||||
on_confirm: ConfirmAction,
|
||||
},
|
||||
/// Per modals.allium InsertPostLinkModal: Ctrl+K link insertion.
|
||||
PostInsertLink {
|
||||
post_id: String,
|
||||
title: String,
|
||||
results: Vec<InsertLinkResult>,
|
||||
search_query: String,
|
||||
active_tab: PostInsertLinkTab,
|
||||
external_url: String,
|
||||
external_text: String,
|
||||
},
|
||||
/// Per modals.allium InsertMediaModal: grid for inserting media.
|
||||
InsertMedia {
|
||||
post_id: String,
|
||||
title: String,
|
||||
media_list: Vec<bds_core::model::Media>,
|
||||
search_query: String,
|
||||
link_only: bool,
|
||||
},
|
||||
/// Per modals.allium GalleryOverlay: full-screen media gallery.
|
||||
PostGallery {
|
||||
post_id: String,
|
||||
title: String,
|
||||
media_list: Vec<bds_core::model::Media>,
|
||||
selected_index: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PostInsertLinkTab {
|
||||
Internal,
|
||||
External,
|
||||
}
|
||||
|
||||
/// What action to perform when modal is confirmed.
|
||||
@@ -32,6 +76,7 @@ pub enum ConfirmAction {
|
||||
DeleteMedia(String),
|
||||
DeleteScript(String),
|
||||
DeleteTemplate(String),
|
||||
DeleteTag(String),
|
||||
MergeTags { source: String, target: String },
|
||||
}
|
||||
|
||||
@@ -107,8 +152,51 @@ fn confirm_button_style(_theme: &Theme, status: button::Status) -> button::Style
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_thumbnail_file(data_dir: Option<&Path>, media: &Media) -> Option<String> {
|
||||
let data_dir = data_dir?;
|
||||
if !media.mime_type.starts_with("image/") {
|
||||
return None;
|
||||
}
|
||||
let thumb = data_dir.join(thumbnail_path(&media.id, "medium", "webp"));
|
||||
if thumb.exists() {
|
||||
Some(thumb.to_string_lossy().to_string())
|
||||
} else {
|
||||
let original = data_dir.join(&media.file_path);
|
||||
original
|
||||
.exists()
|
||||
.then(|| original.to_string_lossy().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_media_file(data_dir: Option<&Path>, media: &Media) -> Option<String> {
|
||||
let data_dir = data_dir?;
|
||||
let path = data_dir.join(&media.file_path);
|
||||
path.exists().then(|| path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn external_link_markdown(url: &str, display_text: &str) -> Option<String> {
|
||||
let trimmed_url = url.trim();
|
||||
if trimmed_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let trimmed_text = display_text.trim();
|
||||
if trimmed_text.is_empty() {
|
||||
Some(trimmed_url.to_string())
|
||||
} else {
|
||||
Some(format!("[{trimmed_text}]({trimmed_url})"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn gallery_step(current: usize, len: usize, delta: isize) -> usize {
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
((current as isize + delta).rem_euclid(len as isize)) as usize
|
||||
}
|
||||
|
||||
/// Render the modal overlay.
|
||||
pub fn view(state: &ModalState, locale: UiLocale) -> 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,
|
||||
@@ -136,8 +224,7 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> {
|
||||
.color(Color::from_rgb(0.70, 0.70, 0.75));
|
||||
|
||||
let mut content_col = column![
|
||||
row![warning_icon, Space::with_width(8.0), title]
|
||||
.align_y(Alignment::Center),
|
||||
row![warning_icon, Space::with_width(8.0), title].align_y(Alignment::Center),
|
||||
Space::with_height(12.0),
|
||||
entity_label,
|
||||
warning_text,
|
||||
@@ -164,18 +251,18 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> {
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(Length::Fill),
|
||||
button(
|
||||
text(t(locale, "modal.confirmDelete.delete"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::ConfirmModal(on_confirm_clone))
|
||||
.padding([6, 16])
|
||||
.style(danger_button_style),
|
||||
.on_press(Message::ConfirmModal(on_confirm_clone))
|
||||
.padding([6, 16])
|
||||
.style(danger_button_style),
|
||||
];
|
||||
|
||||
content_col = content_col.push(Space::with_height(16.0));
|
||||
@@ -209,18 +296,18 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> {
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(Length::Fill),
|
||||
button(
|
||||
text(t(locale, "modal.confirm.confirm"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::ConfirmModal(on_confirm_clone))
|
||||
.padding([6, 16])
|
||||
.style(confirm_button_style),
|
||||
.on_press(Message::ConfirmModal(on_confirm_clone))
|
||||
.padding([6, 16])
|
||||
.style(confirm_button_style),
|
||||
];
|
||||
|
||||
let content_col = column![
|
||||
@@ -237,6 +324,555 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> {
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::PostInsertLink {
|
||||
post_id: _post_id,
|
||||
title: _title,
|
||||
results,
|
||||
search_query,
|
||||
active_tab,
|
||||
external_url,
|
||||
external_text,
|
||||
} => {
|
||||
let internal_tab = button(
|
||||
text(t(locale, "modal.postInsertLink.tabInternal"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(if active_tab == PostInsertLinkTab::Internal {
|
||||
Color::WHITE
|
||||
} else {
|
||||
Color::from_rgb(0.60, 0.60, 0.65)
|
||||
}),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkTabSwitch(
|
||||
PostInsertLinkTab::Internal,
|
||||
)))
|
||||
.padding([8, 16])
|
||||
.style(cancel_button_style);
|
||||
|
||||
let external_tab = button(
|
||||
text(t(locale, "modal.postInsertLink.tabExternal"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(if active_tab == PostInsertLinkTab::External {
|
||||
Color::WHITE
|
||||
} else {
|
||||
Color::from_rgb(0.60, 0.60, 0.65)
|
||||
}),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkTabSwitch(
|
||||
PostInsertLinkTab::External,
|
||||
)))
|
||||
.padding([8, 16])
|
||||
.style(cancel_button_style);
|
||||
|
||||
let tabs = row![internal_tab, external_tab].spacing(4);
|
||||
|
||||
let search_input = text_input(
|
||||
t(locale, "modal.postInsertLink.searchPlaceholder").as_str(),
|
||||
&search_query,
|
||||
)
|
||||
.size(13)
|
||||
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkSearch(s)))
|
||||
.padding([6, 10])
|
||||
.width(Length::Fill);
|
||||
|
||||
let internal_content: Element<'static, Message> = if results.is_empty() {
|
||||
column![
|
||||
search_input,
|
||||
Space::with_height(12.0),
|
||||
text(t(locale, "modal.postInsertLink.emptyState"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.55, 0.55, 0.60)),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
} else {
|
||||
let mut column = column![search_input, Space::with_height(12.0)];
|
||||
for link in results {
|
||||
column = column.push(
|
||||
button(
|
||||
row![
|
||||
column![
|
||||
text(link.title.clone())
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE),
|
||||
text(link.canonical_url.clone())
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.58, 0.58, 0.64)),
|
||||
]
|
||||
.spacing(2)
|
||||
.width(Length::Fill),
|
||||
container(
|
||||
text(link.status.clone())
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(match link.status.as_str() {
|
||||
"published" => Color::from_rgb(0.52, 0.82, 0.60),
|
||||
"archived" => Color::from_rgb(0.70, 0.70, 0.74),
|
||||
_ => Color::from_rgb(0.90, 0.78, 0.35),
|
||||
}),
|
||||
)
|
||||
.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()
|
||||
}),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkSelected(
|
||||
link.post_id.clone(),
|
||||
)))
|
||||
.padding([8, 12])
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.22, 0.24, 0.30))),
|
||||
text_color: Color::from_rgb(0.85, 0.85, 0.90),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..button::Style::default()
|
||||
}),
|
||||
);
|
||||
}
|
||||
column.spacing(4).into()
|
||||
};
|
||||
|
||||
let external_content: Element<'static, Message> = column![
|
||||
text_input(
|
||||
t(locale, "modal.postInsertLink.externalUrlPlaceholder").as_str(),
|
||||
&external_url,
|
||||
)
|
||||
.size(13)
|
||||
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkUrlChanged(s)))
|
||||
.padding([6, 10])
|
||||
.width(Length::Fill),
|
||||
text_input(
|
||||
t(locale, "modal.postInsertLink.externalTextPlaceholder").as_str(),
|
||||
&external_text,
|
||||
)
|
||||
.size(13)
|
||||
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkTextChanged(s)))
|
||||
.padding([6, 10])
|
||||
.width(Length::Fill),
|
||||
Space::with_height(12.0),
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
button(text(t(locale, "modal.postInsertLink.insert")))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkExternalInsert))
|
||||
.padding([6, 16])
|
||||
.style(confirm_button_style),
|
||||
]
|
||||
]
|
||||
.spacing(8)
|
||||
.into();
|
||||
|
||||
let create_post_btn: Element<'static, Message> = button(
|
||||
text(t(locale, "modal.postInsertLink.createPost"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.65)),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkCreate))
|
||||
.padding([6, 12])
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
||||
text_color: Color::from_rgb(0.60, 0.60, 0.65),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..button::Style::default()
|
||||
})
|
||||
.into();
|
||||
|
||||
let cancel_text = text(t(locale, "modal.postInsertLink.cancel"))
|
||||
.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 buttons = row![
|
||||
button(cancel_text)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(Length::Fill),
|
||||
trailing_button,
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
let content = column![
|
||||
tabs,
|
||||
Space::with_height(12.0),
|
||||
if active_tab == PostInsertLinkTab::Internal {
|
||||
internal_content
|
||||
} else {
|
||||
external_content
|
||||
},
|
||||
Space::with_height(16.0),
|
||||
buttons,
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
container(content.padding(20))
|
||||
.width(Length::Fixed(480.0))
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::InsertMedia {
|
||||
post_id: _post_id,
|
||||
title: _title,
|
||||
media_list,
|
||||
search_query,
|
||||
link_only: _,
|
||||
} => {
|
||||
let title = text(t(locale, "modal.insertMedia.title"))
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
|
||||
let search_input = text_input(
|
||||
t(locale, "modal.insertMedia.searchPlaceholder").as_str(),
|
||||
&search_query,
|
||||
)
|
||||
.size(13)
|
||||
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertMediaSearch(s)))
|
||||
.padding([6, 10])
|
||||
.width(Length::Fill);
|
||||
|
||||
let media_items: Vec<Element<'static, Message>> = media_list
|
||||
.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 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 media_title = text(title.clone())
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.85, 0.85, 0.90));
|
||||
|
||||
let media_name = text(original_name)
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.55, 0.55, 0.60));
|
||||
|
||||
let media_col = column![
|
||||
container(thumb)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| 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()
|
||||
}),
|
||||
Space::with_height(8.0),
|
||||
media_title,
|
||||
media_name,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center);
|
||||
|
||||
let btn = button(media_col)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostInsertMediaSelected(
|
||||
m.id.clone(),
|
||||
)))
|
||||
.padding(8)
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.20, 0.22, 0.27))),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
width: 1.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
..button::Style::default()
|
||||
});
|
||||
btn.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut grid = column![].spacing(12).width(Length::Fill);
|
||||
let mut media_items = media_items.into_iter();
|
||||
loop {
|
||||
let mut grid_row = row![].spacing(12).width(Length::Fill);
|
||||
let mut pushed_any = false;
|
||||
for item in media_items.by_ref().take(3) {
|
||||
grid_row = grid_row.push(item);
|
||||
pushed_any = true;
|
||||
}
|
||||
if !pushed_any {
|
||||
break;
|
||||
}
|
||||
grid = grid.push(grid_row);
|
||||
}
|
||||
|
||||
let media_list_col: Element<'static, Message> = if media_list.is_empty() {
|
||||
column![
|
||||
search_input,
|
||||
Space::with_height(12.0),
|
||||
text(t(locale, "modal.postInsertLink.emptyState"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.55, 0.55, 0.60)),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
} else {
|
||||
column![
|
||||
search_input,
|
||||
Space::with_height(16.0),
|
||||
container(grid).width(Length::Fill).center_x(Length::Fill),
|
||||
]
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
let cancel_text = text(t(locale, "modal.insertMedia.cancel"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced);
|
||||
|
||||
let buttons = row![button(cancel_text)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),];
|
||||
|
||||
let content = column![
|
||||
title,
|
||||
Space::with_height(12.0),
|
||||
media_list_col,
|
||||
Space::with_height(16.0),
|
||||
buttons,
|
||||
]
|
||||
.spacing(4)
|
||||
.width(Length::Fill);
|
||||
|
||||
container(content.padding(20))
|
||||
.width(Length::Fixed(580.0))
|
||||
.height(Length::Fixed(480.0))
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::PostGallery {
|
||||
post_id: _post_id,
|
||||
title: _title,
|
||||
media_list,
|
||||
selected_index,
|
||||
} => {
|
||||
let image_media: Vec<Media> = media_list
|
||||
.iter()
|
||||
.filter(|m| m.mime_type.starts_with("image/"))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let media_items: Vec<Element<'static, Message>> = image_media
|
||||
.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 media_title = text(title)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.85, 0.85, 0.90));
|
||||
|
||||
let media_col = column![
|
||||
container(thumb)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| 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()
|
||||
}),
|
||||
Space::with_height(8.0),
|
||||
media_title,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center);
|
||||
|
||||
let btn = button(media_col)
|
||||
.on_press(Message::PostEditor(
|
||||
PostEditorMsg::PostGalleryImageSelected(index),
|
||||
))
|
||||
.padding(8)
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.20, 0.22, 0.27))),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
width: 1.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
..button::Style::default()
|
||||
});
|
||||
btn.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut grid = column![].spacing(12).width(Length::Fill);
|
||||
let mut media_items = media_items.into_iter();
|
||||
loop {
|
||||
let mut grid_row = row![].spacing(12).width(Length::Fill);
|
||||
let mut pushed_any = false;
|
||||
for item in media_items.by_ref().take(3) {
|
||||
grid_row = grid_row.push(item);
|
||||
pushed_any = true;
|
||||
}
|
||||
if !pushed_any {
|
||||
break;
|
||||
}
|
||||
grid = grid.push(grid_row);
|
||||
}
|
||||
|
||||
let close_text = text(t(locale, "modal.postGallery.close"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced);
|
||||
|
||||
let close_button = button(close_text)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style);
|
||||
|
||||
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()
|
||||
};
|
||||
column![
|
||||
container(preview)
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill),
|
||||
row![
|
||||
button(text("<"))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostGalleryPrevious))
|
||||
.padding([6, 12])
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(Length::Fill),
|
||||
text(format!("{} / {}", index + 1, image_media.len()))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced),
|
||||
Space::with_width(Length::Fill),
|
||||
button(text(">"))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostGalleryNext))
|
||||
.padding([6, 12])
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(12.0),
|
||||
button(text(t(locale, "modal.postGallery.backToGrid")))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostGalleryCloseLightbox))
|
||||
.padding([6, 12])
|
||||
.style(cancel_button_style),
|
||||
]
|
||||
.align_y(Alignment::Center)
|
||||
]
|
||||
.spacing(12)
|
||||
.into()
|
||||
} else {
|
||||
Space::with_height(0.0).into()
|
||||
}
|
||||
} else {
|
||||
Space::with_height(0.0).into()
|
||||
};
|
||||
|
||||
let content = column![
|
||||
if selected_index.is_some() {
|
||||
lightbox
|
||||
} else {
|
||||
container(grid).center_x(Length::Fill).into()
|
||||
},
|
||||
Space::with_height(16.0),
|
||||
close_button,
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
container(content.padding(20))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
// Center the modal in a full-screen backdrop
|
||||
@@ -250,3 +886,25 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> {
|
||||
.style(backdrop_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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", "Example"),
|
||||
Some("[Example](https://example.com)".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gallery_step_wraps_in_both_directions() {
|
||||
assert_eq!(gallery_step(0, 3, -1), 2);
|
||||
assert_eq!(gallery_step(2, 3, 1), 0);
|
||||
assert_eq!(gallery_step(1, 3, 1), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space};
|
||||
use iced::widget::text::{Shaping, Wrapping};
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space};
|
||||
use iced::{Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::{self, UiLocale};
|
||||
use bds_core::model::{Post, PostStatus, PostTranslation};
|
||||
use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
|
||||
use bds_editor::{highlighter, CodeEditor, EditorBuffer, EditorMessage};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
@@ -39,6 +39,15 @@ pub struct ResolvedPostLink {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Linked media metadata shown in the post editor metadata panel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LinkedMediaItem {
|
||||
pub media_id: String,
|
||||
pub name: String,
|
||||
pub is_image: bool,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
/// State for an open post editor.
|
||||
pub struct PostEditorState {
|
||||
pub post_id: String,
|
||||
@@ -56,6 +65,7 @@ pub struct PostEditorState {
|
||||
pub status: PostStatus,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub published_at: Option<i64>,
|
||||
pub is_dirty: bool,
|
||||
pub metadata_expanded: bool,
|
||||
pub excerpt_expanded: bool,
|
||||
@@ -76,6 +86,8 @@ pub struct PostEditorState {
|
||||
pub outlinks: Vec<ResolvedPostLink>,
|
||||
/// Incoming links from other posts to this post.
|
||||
pub backlinks: Vec<ResolvedPostLink>,
|
||||
/// Media linked to this post.
|
||||
pub linked_media: Vec<LinkedMediaItem>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PostEditorState {
|
||||
@@ -106,6 +118,7 @@ impl Clone for PostEditorState {
|
||||
status: self.status.clone(),
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
published_at: self.published_at,
|
||||
is_dirty: self.is_dirty,
|
||||
metadata_expanded: self.metadata_expanded,
|
||||
excerpt_expanded: self.excerpt_expanded,
|
||||
@@ -118,6 +131,7 @@ impl Clone for PostEditorState {
|
||||
translation_drafts: self.translation_drafts.clone(),
|
||||
outlinks: self.outlinks.clone(),
|
||||
backlinks: self.backlinks.clone(),
|
||||
linked_media: self.linked_media.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +143,7 @@ impl PostEditorState {
|
||||
translations: &[PostTranslation],
|
||||
outlinks: Vec<ResolvedPostLink>,
|
||||
backlinks: Vec<ResolvedPostLink>,
|
||||
linked_media: Vec<LinkedMediaItem>,
|
||||
) -> Self {
|
||||
let title = post.title.clone();
|
||||
let content = post.content.clone().unwrap_or_default();
|
||||
@@ -136,13 +151,16 @@ impl PostEditorState {
|
||||
|
||||
let mut translation_drafts = HashMap::new();
|
||||
for tr in translations {
|
||||
translation_drafts.insert(tr.language.clone(), TranslationDraft {
|
||||
title: tr.title.clone(),
|
||||
excerpt: tr.excerpt.clone().unwrap_or_default(),
|
||||
content: tr.content.clone().unwrap_or_default(),
|
||||
status: tr.status.clone(),
|
||||
is_dirty: false,
|
||||
});
|
||||
translation_drafts.insert(
|
||||
tr.language.clone(),
|
||||
TranslationDraft {
|
||||
title: tr.title.clone(),
|
||||
excerpt: tr.excerpt.clone().unwrap_or_default(),
|
||||
content: tr.content.clone().unwrap_or_default(),
|
||||
status: tr.status.clone(),
|
||||
is_dirty: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
@@ -160,6 +178,7 @@ impl PostEditorState {
|
||||
status: post.status.clone(),
|
||||
created_at: post.created_at,
|
||||
updated_at: post.updated_at,
|
||||
published_at: post.published_at,
|
||||
is_dirty: false,
|
||||
metadata_expanded: title.is_empty(),
|
||||
excerpt_expanded: false,
|
||||
@@ -172,10 +191,21 @@ impl PostEditorState {
|
||||
translation_drafts,
|
||||
outlinks,
|
||||
backlinks,
|
||||
linked_media,
|
||||
title,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_markdown_at_cursor(&mut self, markdown: &str) {
|
||||
let new_content = {
|
||||
let mut buffer = self.editor_buffer.borrow_mut();
|
||||
buffer.insert(markdown);
|
||||
buffer.text()
|
||||
};
|
||||
self.content = new_content;
|
||||
self.is_dirty = true;
|
||||
}
|
||||
|
||||
/// Switch the editor to display a different language.
|
||||
/// Saves current fields and loads the target language's draft.
|
||||
pub fn switch_language(&mut self, target_lang: &str) {
|
||||
@@ -195,13 +225,16 @@ impl PostEditorState {
|
||||
});
|
||||
} else {
|
||||
// Switching away from a translation — save to drafts
|
||||
self.translation_drafts.insert(self.active_language.clone(), TranslationDraft {
|
||||
title: self.title.clone(),
|
||||
excerpt: self.excerpt.clone(),
|
||||
content: self.content.clone(),
|
||||
status: PostStatus::Draft,
|
||||
is_dirty: self.is_dirty,
|
||||
});
|
||||
self.translation_drafts.insert(
|
||||
self.active_language.clone(),
|
||||
TranslationDraft {
|
||||
title: self.title.clone(),
|
||||
excerpt: self.excerpt.clone(),
|
||||
content: self.content.clone(),
|
||||
status: PostStatus::Draft,
|
||||
is_dirty: self.is_dirty,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Load target fields
|
||||
@@ -281,6 +314,7 @@ pub enum PostEditorMsg {
|
||||
ExcerptChanged(String),
|
||||
ContentChanged(String),
|
||||
AuthorChanged(String),
|
||||
LanguageChanged(String),
|
||||
TemplateSlugChanged(String),
|
||||
ToggleDoNotTranslate(bool),
|
||||
ToggleMetadata,
|
||||
@@ -294,7 +328,28 @@ pub enum PostEditorMsg {
|
||||
RemoveCategory(String),
|
||||
Save,
|
||||
Publish,
|
||||
Duplicate,
|
||||
Discard,
|
||||
Delete,
|
||||
InsertLink,
|
||||
InsertMedia,
|
||||
Gallery,
|
||||
LinkExistingMedia,
|
||||
OpenLinkedMedia(String),
|
||||
UnlinkLinkedMedia(String),
|
||||
PostInsertLinkSelected(String),
|
||||
PostInsertLinkCreate,
|
||||
PostInsertMediaSelected(String),
|
||||
PostGalleryImageSelected(usize),
|
||||
PostInsertLinkTabSwitch(crate::views::modal::PostInsertLinkTab),
|
||||
PostInsertLinkSearch(String),
|
||||
PostInsertLinkUrlChanged(String),
|
||||
PostInsertLinkTextChanged(String),
|
||||
PostInsertLinkExternalInsert,
|
||||
PostInsertMediaSearch(String),
|
||||
PostGalleryPrevious,
|
||||
PostGalleryNext,
|
||||
PostGalleryCloseLightbox,
|
||||
}
|
||||
|
||||
/// Render the post editor view.
|
||||
@@ -303,6 +358,8 @@ pub fn view<'a>(
|
||||
locale: UiLocale,
|
||||
word_wrap: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let on_translation = state.active_language != state.canonical_language;
|
||||
|
||||
// ── Header bar ──
|
||||
let dirty_indicator = if state.is_dirty { " \u{25CF}" } else { "" };
|
||||
let title_display = if state.title.is_empty() {
|
||||
@@ -312,34 +369,68 @@ pub fn view<'a>(
|
||||
};
|
||||
|
||||
let header = inputs::toolbar(
|
||||
vec![
|
||||
text(title_display)
|
||||
.size(18)
|
||||
.wrapping(Wrapping::None)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into(),
|
||||
],
|
||||
vec![text(title_display)
|
||||
.size(18)
|
||||
.wrapping(Wrapping::None)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into()],
|
||||
vec![
|
||||
status_badge(&state.status),
|
||||
button(text(t(locale, "common.save")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::Save))
|
||||
.style(inputs::primary_button)
|
||||
button(
|
||||
text(t(locale, "common.save"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::Save))
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
if !on_translation {
|
||||
button(
|
||||
text(t(locale, "editor.duplicate"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::Duplicate))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
if state.status == PostStatus::Draft {
|
||||
button(text(t(locale, "editor.publish")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::Publish))
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 16])
|
||||
.into()
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
},
|
||||
button(text(t(locale, "modal.confirmDelete.delete")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::Delete))
|
||||
.style(inputs::danger_button)
|
||||
if state.status == PostStatus::Draft {
|
||||
button(
|
||||
text(t(locale, "editor.publish"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::Publish))
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
},
|
||||
if !on_translation && state.status == PostStatus::Draft && state.published_at.is_some() {
|
||||
button(
|
||||
text(t(locale, "editor.discard"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::Discard))
|
||||
.padding([6, 16])
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
},
|
||||
button(
|
||||
text(t(locale, "modal.confirmDelete.delete"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::Delete))
|
||||
.style(inputs::danger_button)
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -350,7 +441,10 @@ pub fn view<'a>(
|
||||
format!("\u{25B6} {}", t(locale, "editor.metadata"))
|
||||
};
|
||||
let meta_toggle = button(
|
||||
text(meta_toggle_label).size(12).color(inputs::SECTION_COLOR).shaping(Shaping::Advanced),
|
||||
text(meta_toggle_label)
|
||||
.size(12)
|
||||
.color(inputs::SECTION_COLOR)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::ToggleMetadata))
|
||||
.padding([4, 0])
|
||||
@@ -361,7 +455,6 @@ pub fn view<'a>(
|
||||
|
||||
// ── Translation Flags Bar (inline with metadata toggle) ──
|
||||
let flags = state.translation_flags();
|
||||
let on_translation = state.active_language != state.canonical_language;
|
||||
let meta_toggle_row: Element<'a, Message> = if flags.is_empty() {
|
||||
meta_toggle.into()
|
||||
} else {
|
||||
@@ -369,12 +462,14 @@ pub fn view<'a>(
|
||||
for flag in &flags {
|
||||
let lang = flag.language.clone();
|
||||
let label = format!("{}", flag.flag_emoji);
|
||||
let btn = button(
|
||||
text(label).size(14).shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang)))
|
||||
.padding([2, 4])
|
||||
.style(if flag.is_active { flag_active_style } else { flag_inactive_style });
|
||||
let btn = button(text(label).size(14).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang)))
|
||||
.padding([2, 4])
|
||||
.style(if flag.is_active {
|
||||
flag_active_style
|
||||
} else {
|
||||
flag_inactive_style
|
||||
});
|
||||
flag_row = flag_row.push(btn);
|
||||
}
|
||||
row![meta_toggle, Space::with_width(Length::Fill), flag_row]
|
||||
@@ -400,11 +495,20 @@ pub fn view<'a>(
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
let author_input = inputs::labeled_input(
|
||||
&t(locale, "editor.author"),
|
||||
"",
|
||||
&state.author,
|
||||
|s| Message::PostEditor(PostEditorMsg::AuthorChanged(s)),
|
||||
let author_input =
|
||||
inputs::labeled_input(&t(locale, "editor.author"), "", &state.author, |s| {
|
||||
Message::PostEditor(PostEditorMsg::AuthorChanged(s))
|
||||
});
|
||||
let language_options = if state.blog_languages.is_empty() {
|
||||
vec![state.language.clone()]
|
||||
} else {
|
||||
state.blog_languages.clone()
|
||||
};
|
||||
let language_input = inputs::labeled_select(
|
||||
&t(locale, "editor.language"),
|
||||
&language_options,
|
||||
Some(&state.language),
|
||||
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
|
||||
);
|
||||
let template_input = inputs::labeled_input(
|
||||
&t(locale, "editor.templateSlug"),
|
||||
@@ -417,7 +521,7 @@ pub fn view<'a>(
|
||||
state.do_not_translate,
|
||||
|b| Message::PostEditor(PostEditorMsg::ToggleDoNotTranslate(b)),
|
||||
);
|
||||
let meta_row2 = row![author_input, template_input, dnt]
|
||||
let meta_row2 = row![author_input, language_input, template_input, dnt]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
@@ -447,16 +551,18 @@ 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))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.55, 0.70, 0.90))
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Column::with_children(items).spacing(2).into()
|
||||
@@ -465,25 +571,118 @@ 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))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.55, 0.70, 0.90))
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Column::with_children(items).spacing(2).into()
|
||||
};
|
||||
|
||||
column![meta_row1, meta_row2, tags_section, categories_section, outlinks_section, backlinks_section]
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
let link_existing_button: Element<'a, Message> = button(
|
||||
text(t(locale, "editor.linkExistingMedia"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::LinkExistingMedia))
|
||||
.padding([4, 10])
|
||||
.style(|_: &Theme, _| button::Style::default())
|
||||
.into();
|
||||
|
||||
let linked_media_header: Element<'a, Message> = row![
|
||||
text(t(locale, "editor.linkedMedia"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced),
|
||||
Space::with_width(Length::Fill),
|
||||
link_existing_button,
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into();
|
||||
|
||||
let linked_media_section: Element<'a, Message> = if state.linked_media.is_empty() {
|
||||
column![
|
||||
linked_media_header,
|
||||
text(t(locale, "editor.linkedMediaEmpty"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.55, 0.55, 0.60)),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
} else {
|
||||
let mut items: Vec<Element<'a, Message>> = vec![linked_media_header];
|
||||
for media in &state.linked_media {
|
||||
let media_id = media.media_id.clone();
|
||||
let open_id = media.media_id.clone();
|
||||
let kind_label = if media.is_image {
|
||||
t(locale, "editor.linkedMediaKindImage")
|
||||
} else {
|
||||
t(locale, "editor.linkedMediaKindFile")
|
||||
};
|
||||
items.push(
|
||||
container(
|
||||
row![
|
||||
column![
|
||||
text(media.name.clone())
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE),
|
||||
text(format!("{} {}", kind_label, media.sort_order + 1))
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.55, 0.55, 0.60)),
|
||||
]
|
||||
.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),
|
||||
]
|
||||
.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))),
|
||||
border: iced::Border {
|
||||
color: Color::from_rgb(0.28, 0.28, 0.32),
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Column::with_children(items).spacing(6).into()
|
||||
};
|
||||
|
||||
column![
|
||||
meta_row1,
|
||||
meta_row2,
|
||||
tags_section,
|
||||
categories_section,
|
||||
outlinks_section,
|
||||
backlinks_section,
|
||||
linked_media_section,
|
||||
]
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
@@ -495,7 +694,10 @@ pub fn view<'a>(
|
||||
format!("\u{25B6} {}", t(locale, "editor.excerpt"))
|
||||
};
|
||||
let excerpt_toggle = button(
|
||||
text(excerpt_toggle_label).size(12).color(inputs::SECTION_COLOR).shaping(Shaping::Advanced),
|
||||
text(excerpt_toggle_label)
|
||||
.size(12)
|
||||
.color(inputs::SECTION_COLOR)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::ToggleExcerpt))
|
||||
.padding([4, 0])
|
||||
@@ -517,23 +719,64 @@ pub fn view<'a>(
|
||||
|
||||
// ── Content section (fills remaining space) ──
|
||||
let content_label = inputs::section_header(&t(locale, "editor.content"));
|
||||
let editor_widget: Element<'a, Message> = CodeEditor::new(
|
||||
&state.editor_buffer,
|
||||
highlighter(),
|
||||
"md",
|
||||
)
|
||||
.word_wrap(word_wrap)
|
||||
.on_change(|msg| match msg {
|
||||
EditorMessage::ContentChanged(s) => Message::PostEditor(PostEditorMsg::ContentChanged(s)),
|
||||
EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save),
|
||||
})
|
||||
.into();
|
||||
let body_toolbar = inputs::toolbar(
|
||||
vec![content_label.into()],
|
||||
vec![
|
||||
button(
|
||||
text(t(locale, "editor.insertLink"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::InsertLink))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(
|
||||
text(t(locale, "editor.insertMedia"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::InsertMedia))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(
|
||||
text(t(locale, "editor.gallery"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::Gallery))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
],
|
||||
);
|
||||
let editor_widget: Element<'a, Message> =
|
||||
CodeEditor::new(&state.editor_buffer, highlighter(), "md")
|
||||
.word_wrap(word_wrap)
|
||||
.on_change(|msg| match msg {
|
||||
EditorMessage::ContentChanged(s) => {
|
||||
Message::PostEditor(PostEditorMsg::ContentChanged(s))
|
||||
}
|
||||
EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save),
|
||||
})
|
||||
.into();
|
||||
|
||||
// ── Footer ──
|
||||
let published_gap: Element<'a, Message> = if state.published_at.is_some() {
|
||||
Space::with_width(Length::Fixed(24.0)).into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
let published_label: Element<'a, Message> = if let Some(published_at) = state.published_at {
|
||||
inputs::date_label(&t(locale, "editor.publishedAt"), published_at)
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
let footer = row![
|
||||
inputs::date_label(&t(locale, "editor.createdAt"), state.created_at),
|
||||
Space::with_width(Length::Fixed(24.0)),
|
||||
inputs::date_label(&t(locale, "editor.updatedAt"), state.updated_at),
|
||||
published_gap,
|
||||
published_label,
|
||||
]
|
||||
.padding(8);
|
||||
|
||||
@@ -547,22 +790,17 @@ pub fn view<'a>(
|
||||
excerpt_section,
|
||||
]
|
||||
.spacing(4)
|
||||
.width(Length::Fill)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.height(Length::Shrink);
|
||||
|
||||
// ── Full layout: top pane (shrink), editor (fill), footer (shrink) ──
|
||||
column![
|
||||
top_pane,
|
||||
content_label,
|
||||
editor_widget,
|
||||
footer,
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
column![top_pane, body_toolbar, editor_widget, footer,]
|
||||
.spacing(4)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Chip input: shows existing chips as removable buttons + a text input to add new ones.
|
||||
@@ -594,7 +832,10 @@ fn chip_input_field<'a>(
|
||||
}
|
||||
|
||||
column![
|
||||
text(label.to_string()).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced),
|
||||
text(label.to_string())
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced),
|
||||
chip_row.wrap(),
|
||||
text_input(placeholder, input_value)
|
||||
.on_input(on_input)
|
||||
@@ -678,3 +919,66 @@ fn flag_inactive_style(_theme: &Theme, status: button::Status) -> button::Style
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_state() -> PostEditorState {
|
||||
let post = Post {
|
||||
id: "post-1".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
title: "Sample".to_string(),
|
||||
slug: "sample".to_string(),
|
||||
excerpt: None,
|
||||
content: Some("Hello world".to_string()),
|
||||
status: PostStatus::Draft,
|
||||
file_path: String::new(),
|
||||
checksum: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
published_at: None,
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_excerpt: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
tags: Vec::new(),
|
||||
categories: Vec::new(),
|
||||
author: None,
|
||||
language: Some("en".to_string()),
|
||||
template_slug: None,
|
||||
do_not_translate: false,
|
||||
};
|
||||
PostEditorState::from_post(
|
||||
&post,
|
||||
&["en".to_string()],
|
||||
&[],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_markdown_at_cursor_uses_buffer_cursor() {
|
||||
let mut state = sample_state();
|
||||
state.editor_buffer.borrow_mut().set_cursor(0, 5);
|
||||
|
||||
state.insert_markdown_at_cursor(" brave");
|
||||
|
||||
assert_eq!(state.content, "Hello brave world");
|
||||
assert!(state.is_dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_markdown_at_cursor_replaces_selection() {
|
||||
let mut state = sample_state();
|
||||
state.editor_buffer.borrow_mut().set_selection(0, 6, 0, 11);
|
||||
|
||||
state.insert_markdown_at_cursor("Rust");
|
||||
|
||||
assert_eq!(state.content, "Hello Rust");
|
||||
assert!(state.is_dirty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
|
||||
use iced::{Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::t;
|
||||
|
||||
/// State for the translation editor (post translation).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranslationEditorState {
|
||||
pub post_id: String,
|
||||
pub post_title: String,
|
||||
pub language: String,
|
||||
pub title: String,
|
||||
pub excerpt: String,
|
||||
pub content: String,
|
||||
pub status: String, // draft | published
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub is_dirty: bool,
|
||||
}
|
||||
|
||||
impl TranslationEditorState {
|
||||
pub fn new(post_id: String, post_title: String, language: String) -> Self {
|
||||
Self {
|
||||
post_id,
|
||||
post_title,
|
||||
language,
|
||||
title: String::new(),
|
||||
excerpt: String::new(),
|
||||
content: String::new(),
|
||||
status: "draft".to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
is_dirty: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translation editor messages.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TranslationEditorMsg {
|
||||
TitleChanged(String),
|
||||
ExcerptChanged(String),
|
||||
ContentChanged(String),
|
||||
Save,
|
||||
Publish,
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// Render the translation editor view.
|
||||
pub fn view<'a>(
|
||||
state: &'a TranslationEditorState,
|
||||
locale: UiLocale,
|
||||
) -> Element<'a, Message> {
|
||||
let header = inputs::toolbar(
|
||||
vec![
|
||||
text(format!("{} [{}]", &state.post_title, &state.language))
|
||||
.size(18)
|
||||
.into(),
|
||||
status_badge(&state.status),
|
||||
],
|
||||
vec![
|
||||
button(text(t(locale, "common.save")).size(13))
|
||||
.on_press(Message::TranslationEditor(TranslationEditorMsg::Save))
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "editor.publish")).size(13))
|
||||
.on_press(Message::TranslationEditor(TranslationEditorMsg::Publish))
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "modal.confirmDelete.delete")).size(13))
|
||||
.on_press(Message::TranslationEditor(TranslationEditorMsg::Delete))
|
||||
.style(inputs::danger_button)
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
],
|
||||
);
|
||||
|
||||
let title_input = inputs::labeled_input(
|
||||
&t(locale, "editor.title"),
|
||||
&t(locale, "editor.titlePlaceholder"),
|
||||
&state.title,
|
||||
|s| Message::TranslationEditor(TranslationEditorMsg::TitleChanged(s)),
|
||||
);
|
||||
|
||||
let excerpt_input = inputs::labeled_input(
|
||||
&t(locale, "editor.excerpt"),
|
||||
&t(locale, "editor.excerptPlaceholder"),
|
||||
&state.excerpt,
|
||||
|s| Message::TranslationEditor(TranslationEditorMsg::ExcerptChanged(s)),
|
||||
);
|
||||
|
||||
let content_section: Element<'a, Message> = column![
|
||||
inputs::section_header(&t(locale, "editor.content")),
|
||||
container(
|
||||
text_input(&t(locale, "editor.contentPlaceholder"), &state.content)
|
||||
.on_input(|s| Message::TranslationEditor(TranslationEditorMsg::ContentChanged(s)))
|
||||
.size(14)
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(300.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
.into();
|
||||
|
||||
let footer = row![
|
||||
inputs::date_label(&t(locale, "editor.createdAt"), state.created_at),
|
||||
Space::with_width(Length::Fixed(24.0)),
|
||||
inputs::date_label(&t(locale, "editor.updatedAt"), state.updated_at),
|
||||
]
|
||||
.padding(8);
|
||||
|
||||
let body = scrollable(
|
||||
column![
|
||||
header,
|
||||
title_input,
|
||||
excerpt_input,
|
||||
content_section,
|
||||
footer,
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(16)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
|
||||
container(body)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn status_badge<'a>(status: &str) -> Element<'a, Message> {
|
||||
let (label, color) = match status {
|
||||
"published" => ("Published", Color::from_rgb(0.2, 0.7, 0.3)),
|
||||
_ => ("Draft", Color::from_rgb(0.8, 0.7, 0.2)),
|
||||
};
|
||||
container(text(label).size(11).color(color))
|
||||
.padding([2, 8])
|
||||
.style(move |_: &Theme| container::Style {
|
||||
border: iced::Border {
|
||||
radius: 4.0.into(),
|
||||
width: 1.0,
|
||||
color,
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
|
||||
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -10,18 +10,22 @@ use bds_core::model::{Media, Post, Project, Script, Template};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
|
||||
use crate::state::sidebar_filter::{PostFilter, MediaFilter};
|
||||
use crate::state::sidebar_filter::{MediaFilter, PostFilter};
|
||||
use crate::state::tabs::{Tab, TabType};
|
||||
use crate::state::toast::Toast;
|
||||
use crate::views::{
|
||||
activity_bar, modal, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome,
|
||||
post_editor::{self, PostEditorState},
|
||||
media_editor::{self, MediaEditorState},
|
||||
template_editor::{self, TemplateEditorState},
|
||||
script_editor::{self, ScriptEditorState},
|
||||
tags_view::{self, TagsViewState},
|
||||
settings_view::{self, SettingsViewState},
|
||||
activity_bar,
|
||||
dashboard::DashboardState,
|
||||
media_editor::{self, MediaEditorState},
|
||||
modal, panel,
|
||||
post_editor::{self, PostEditorState},
|
||||
project_selector,
|
||||
script_editor::{self, ScriptEditorState},
|
||||
settings_view::{self, SettingsViewState},
|
||||
sidebar, status_bar, tab_bar,
|
||||
tags_view::{self, TagsViewState},
|
||||
template_editor::{self, TemplateEditorState},
|
||||
toast, welcome,
|
||||
};
|
||||
|
||||
/// Main content area background.
|
||||
@@ -94,7 +98,7 @@ pub fn view<'a>(
|
||||
// Toasts
|
||||
toasts: &'a [Toast],
|
||||
// Modal
|
||||
active_modal: Option<&'a modal::ModalState>,
|
||||
active_modal: Option<modal::ModalState>,
|
||||
// Data directory (for thumbnail paths)
|
||||
data_dir: Option<&'a Path>,
|
||||
// Editor states
|
||||
@@ -114,18 +118,29 @@ pub fn view<'a>(
|
||||
|
||||
// Content area — route based on active tab type
|
||||
let content_area = route_content_area(
|
||||
tabs, active_tab, locale, data_dir,
|
||||
post_editors, media_editors, template_editors, script_editors,
|
||||
tags_view_state, settings_state, dashboard_state,
|
||||
tabs,
|
||||
active_tab,
|
||||
locale,
|
||||
data_dir,
|
||||
post_editors,
|
||||
media_editors,
|
||||
template_editors,
|
||||
script_editors,
|
||||
tags_view_state,
|
||||
settings_state,
|
||||
dashboard_state,
|
||||
);
|
||||
|
||||
// Right column: tab bar + content + panel
|
||||
let mut right_col = column![tabs_el, content_area];
|
||||
if panel_visible {
|
||||
// Determine active tab type for panel tab availability (per layout.allium PanelTabAvailability)
|
||||
let active_tab_type = active_tab.and_then(|id| tabs.iter().find(|t| t.id == id)).map(|t| &t.tab_type);
|
||||
let active_tab_type = active_tab
|
||||
.and_then(|id| tabs.iter().find(|t| t.id == id))
|
||||
.map(|t| &t.tab_type);
|
||||
let active_tab_is_post = active_tab_type == Some(&TabType::Post);
|
||||
let active_tab_is_post_or_media = active_tab_is_post || active_tab_type == Some(&TabType::Media);
|
||||
let active_tab_is_post_or_media =
|
||||
active_tab_is_post || active_tab_type == Some(&TabType::Media);
|
||||
|
||||
right_col = right_col.push(separator_h());
|
||||
right_col = right_col.push(panel::view(
|
||||
@@ -146,7 +161,22 @@ pub fn view<'a>(
|
||||
let mut main_row = row![activity];
|
||||
|
||||
if sidebar_visible {
|
||||
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, post_filter, media_filter, sidebar_media_thumbs, sidebar_posts_has_more, sidebar_media_has_more, sidebar_width, active_tab, locale, data_dir));
|
||||
main_row = main_row.push(sidebar::view(
|
||||
sidebar_view,
|
||||
sidebar_posts,
|
||||
sidebar_media,
|
||||
sidebar_scripts,
|
||||
sidebar_templates,
|
||||
post_filter,
|
||||
media_filter,
|
||||
sidebar_media_thumbs,
|
||||
sidebar_posts_has_more,
|
||||
sidebar_media_has_more,
|
||||
sidebar_width,
|
||||
active_tab,
|
||||
locale,
|
||||
data_dir,
|
||||
));
|
||||
|
||||
// Resize drag handle: 4px wide strip between sidebar and content
|
||||
let handle = container(Space::new(0, 0))
|
||||
@@ -168,17 +198,21 @@ pub fn view<'a>(
|
||||
let main_row = main_row.height(Length::Fill);
|
||||
|
||||
// Status bar at bottom — determine active post status for status dot
|
||||
let active_post_status: Option<String> = active_tab.and_then(|id| {
|
||||
tabs.iter().find(|t| t.id == id && t.tab_type == TabType::Post)
|
||||
}).and_then(|tab| {
|
||||
sidebar_posts.iter().find(|p| p.id == tab.id).map(|p| {
|
||||
match p.status {
|
||||
bds_core::model::PostStatus::Draft => "draft".to_string(),
|
||||
bds_core::model::PostStatus::Published => "published".to_string(),
|
||||
bds_core::model::PostStatus::Archived => "archived".to_string(),
|
||||
}
|
||||
let active_post_status: Option<String> = active_tab
|
||||
.and_then(|id| {
|
||||
tabs.iter()
|
||||
.find(|t| t.id == id && t.tab_type == TabType::Post)
|
||||
})
|
||||
});
|
||||
.and_then(|tab| {
|
||||
sidebar_posts
|
||||
.iter()
|
||||
.find(|p| p.id == tab.id)
|
||||
.map(|p| match p.status {
|
||||
bds_core::model::PostStatus::Draft => "draft".to_string(),
|
||||
bds_core::model::PostStatus::Published => "published".to_string(),
|
||||
bds_core::model::PostStatus::Archived => "archived".to_string(),
|
||||
})
|
||||
});
|
||||
|
||||
let status = status_bar::view(
|
||||
active_project_name,
|
||||
@@ -201,9 +235,7 @@ pub fn view<'a>(
|
||||
let items: Vec<Element<'a, Message>> = UiLocale::all()
|
||||
.iter()
|
||||
.map(|&l| {
|
||||
let flag_text = text(l.flag_emoji())
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced);
|
||||
let flag_text = text(l.flag_emoji()).size(16).shaping(Shaping::Advanced);
|
||||
|
||||
button(flag_text)
|
||||
.on_press(Message::SetUiLocale(l))
|
||||
@@ -214,28 +246,33 @@ pub fn view<'a>(
|
||||
.collect();
|
||||
|
||||
let dropdown_menu = container(
|
||||
iced::widget::Column::with_children(items).spacing(2).padding(4),
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(2)
|
||||
.padding(4),
|
||||
)
|
||||
.style(status_bar::dropdown_bg);
|
||||
|
||||
// Position at bottom-right, above status bar
|
||||
Some(
|
||||
container(
|
||||
container(
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
dropdown_menu,
|
||||
Space::with_width(Length::Fixed(40.0)),
|
||||
]
|
||||
)
|
||||
container(row![
|
||||
Space::with_width(Length::Fill),
|
||||
dropdown_menu,
|
||||
Space::with_width(Length::Fixed(40.0)),
|
||||
])
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::End)
|
||||
.padding(Padding { top: 0.0, right: 0.0, bottom: 25.0, left: 0.0 })
|
||||
.padding(Padding {
|
||||
top: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 25.0,
|
||||
left: 0.0,
|
||||
}),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_y(Alignment::End)
|
||||
.into()
|
||||
.into(),
|
||||
)
|
||||
} else if project_dropdown_open {
|
||||
let dropdown = project_selector::view(projects, active_project_id, locale);
|
||||
@@ -243,21 +280,24 @@ pub fn view<'a>(
|
||||
// Position at bottom-left, above status bar
|
||||
Some(
|
||||
container(
|
||||
container(
|
||||
row![
|
||||
Space::with_width(Length::Fixed(8.0)),
|
||||
dropdown,
|
||||
Space::with_width(Length::Fill),
|
||||
]
|
||||
)
|
||||
container(row![
|
||||
Space::with_width(Length::Fixed(8.0)),
|
||||
dropdown,
|
||||
Space::with_width(Length::Fill),
|
||||
])
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::End)
|
||||
.padding(Padding { top: 0.0, right: 0.0, bottom: 25.0, left: 0.0 })
|
||||
.padding(Padding {
|
||||
top: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 25.0,
|
||||
left: 0.0,
|
||||
}),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_y(Alignment::End)
|
||||
.into()
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
@@ -276,7 +316,7 @@ pub fn view<'a>(
|
||||
|
||||
// Modal overlay (highest z-index)
|
||||
if let Some(modal_state) = active_modal {
|
||||
overlays.push(modal::view(modal_state, locale));
|
||||
overlays.push(modal::view(modal_state, locale, data_dir));
|
||||
}
|
||||
|
||||
if overlays.is_empty() {
|
||||
@@ -363,7 +403,9 @@ fn route_content_area<'a>(
|
||||
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)),
|
||||
text(t(locale, "tabBar.loading"))
|
||||
.size(14)
|
||||
.color(Color::from_rgb(0.5, 0.5, 0.5)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
|
||||
Reference in New Issue
Block a user