use std::path::Path; use iced::widget::text::Shaping; use iced::widget::{ Space, button, checkbox, column, container, image, row, scrollable, text, text_input, }; use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, Vector}; use bds_core::i18n::UiLocale; use bds_core::model::Media; use bds_core::util::paths::thumbnail_path; use crate::app::Message; use crate::components::inputs; 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, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum AiEntityTarget { Post(String), Media(String), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AiSuggestionField { pub key: String, pub label: String, pub current_value: String, pub suggested_value: String, pub accepted: bool, pub locked: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct LanguageTarget { pub code: String, pub name: String, pub flag_emoji: String, pub has_existing_translation: bool, pub existing_status: Option, } /// Active modal state. Only one modal at a time. #[derive(Debug, Clone)] pub enum ModalState { /// Per modals.allium ConfirmDeleteModal: warning, entity name, references. ConfirmDelete { entity_name: String, references: Vec, on_confirm: ConfirmAction, }, /// Per modals.allium ConfirmDialog: generic confirmation. Confirm { title: String, message: String, on_confirm: ConfirmAction, }, SearchIndexRepair, SearchIndexRebuilding, FindReplace { query: String, replacement: String, show_replace: bool, }, /// Per modals.allium InsertPostLinkModal: Ctrl+K link insertion. PostInsertLink { post_id: String, title: String, results: Vec, 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, search_query: String, link_only: bool, }, /// Per modals.allium GalleryOverlay: full-screen media gallery. PostGallery { post_id: String, title: String, media_list: Vec, selected_index: Option, }, AISuggestions { target: AiEntityTarget, fields: Vec, }, LanguagePicker { target: AiEntityTarget, source_language: String, available_targets: Vec, }, RemoteConnection { target: String, connecting: bool, connected: bool, error: Option, projects: Vec, selected_project_id: Option, }, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum PostInsertLinkTab { Internal, External, } /// What action to perform when modal is confirmed. #[derive(Debug, Clone)] pub enum ConfirmAction { RebuildSearchIndex, OpenExternalUrl(String), DeleteProject(String), DeletePost(String), DeleteMedia(String), DeleteScript(String), DeleteTemplate(String), ForceDeleteTemplate(String), DeleteTag(String), DeleteImport(String), MergeTags { sources: Vec, target: String, }, } // ── Modal backdrop style ── fn backdrop_style(_theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.5))), ..container::Style::default() } } fn modal_box_style(_theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(Color::from_rgb(0.18, 0.18, 0.22))), border: Border { color: Color::from_rgb(0.30, 0.30, 0.35), width: 1.0, radius: 10.0.into(), }, shadow: Shadow { color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), offset: Vector::new(0.0, 8.0), blur_radius: 28.0, }, ..container::Style::default() } } fn cancel_button_style(theme: &Theme, status: button::Status) -> button::Style { inputs::secondary_button(theme, status) } fn danger_button_style(theme: &Theme, status: button::Status) -> button::Style { inputs::danger_button(theme, status) } fn confirm_button_style(theme: &Theme, status: button::Status) -> button::Style { inputs::primary_button(theme, status) } fn resolve_thumbnail_file(data_dir: Option<&Path>, media: &Media) -> Option { 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 { 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 { 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, data_dir: Option<&Path>, ) -> Element<'static, Message> { let modal_content: Element<'static, Message> = match state { ModalState::RemoteConnection { target, connecting, connected, error, projects, selected_project_id, } => { let target_input = text_input(&t(locale, "remoteConnection.targetPlaceholder"), &target) .on_input(Message::RemoteTargetChanged) .on_submit(Message::RemoteConnectRequested) .style(inputs::field_style); let mut content = column![ text(t(locale, "remoteConnection.title")) .size(16) .shaping(Shaping::Advanced) .color(Color::WHITE), text(t(locale, "remoteConnection.description")) .size(12) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.70, 0.70, 0.75)), Space::with_height(8.0), text(t(locale, "remoteConnection.target")) .size(12) .color(Color::from_rgb(0.80, 0.80, 0.85)), target_input, ] .spacing(6) .width(Length::Fill); if let Some(error) = error { content = content.push( inputs::card(text(error).size(12).color(Color::from_rgb(1.0, 0.55, 0.55))) .width(Length::Fill), ); } if !projects.is_empty() { content = content.push(Space::with_height(6.0)); content = content.push( text(t(locale, "remoteConnection.projects")) .size(13) .color(Color::WHITE), ); let mut project_rows = column![].spacing(6); for project in projects { let selected = selected_project_id.as_deref() == Some(project.id.as_str()); let marker = if selected { "●" } else { "○" }; project_rows = project_rows.push( button( row![ text(marker).size(12), text(project.name.clone()).size(13), Space::with_width(Length::Fill), text(project.description.unwrap_or_default()) .size(11) .color(Color::from_rgb(0.65, 0.65, 0.70)), ] .spacing(8) .align_y(Alignment::Center), ) .on_press(Message::RemoteProjectSelected(project.id)) .padding([7, 10]) .width(Length::Fill) .style(if selected { inputs::primary_button } else { inputs::secondary_button }), ); } content = content.push(project_rows); } let cancel = button(text(t(locale, "remoteConnection.cancel")).size(13)) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style); let action = if !connected { let button = button( text(if connecting { t(locale, "remoteConnection.connecting") } else { t(locale, "remoteConnection.connect") }) .size(13), ) .padding([6, 16]) .style(confirm_button_style); if connecting || target.trim().is_empty() { button } else { button.on_press(Message::RemoteConnectRequested) } } else { let button = button(text(t(locale, "remoteConnection.open")).size(13)) .padding([6, 16]) .style(confirm_button_style); if selected_project_id.is_some() { button.on_press(Message::RemoteOpenProjectRequested) } else { button } }; content = content.push(Space::with_height(8.0)).push( row![cancel, Space::with_width(Length::Fill), action].align_y(Alignment::Center), ); container(content.padding(20)) .width(Length::Fixed(500.0)) .style(modal_box_style) .into() } ModalState::ConfirmDelete { entity_name, references, on_confirm, } => { let title = text(t(locale, "modal.confirmDelete.title")) .size(16) .shaping(Shaping::Advanced) .color(Color::WHITE); let warning_icon = text("\u{26A0}") .size(20) .shaping(Shaping::Advanced) .color(Color::from_rgb(1.0, 0.80, 0.0)); let entity_label = text(entity_name.clone()) .size(14) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.90, 0.90, 0.95)); let warning_text = text(t(locale, "modal.confirmDelete.warning")) .size(12) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.70, 0.70, 0.75)); let mut content_col = column![ row![warning_icon, Space::with_width(8.0), title].align_y(Alignment::Center), Space::with_height(12.0), entity_label, warning_text, ] .spacing(4); // Reference section if !references.is_empty() { content_col = content_col.push(Space::with_height(8.0)); for r in references { content_col = content_col.push( text(format!("\u{2022} {r}")) .size(11) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.60, 0.60, 0.65)), ); } } let on_confirm_clone = on_confirm.clone(); let buttons = row![ button( text(t(locale, "modal.confirmDelete.cancel")) .size(13) .shaping(Shaping::Advanced) ) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style), Space::with_width(Length::Fill), button( text(t(locale, "modal.confirmDelete.delete")) .size(13) .shaping(Shaping::Advanced) ) .on_press(Message::ConfirmModal(on_confirm_clone)) .padding([6, 16]) .style(danger_button_style), ]; content_col = content_col.push(Space::with_height(16.0)); content_col = content_col.push(buttons); container(content_col.padding(20)) .width(Length::Fixed(380.0)) .style(modal_box_style) .into() } ModalState::Confirm { title: dialog_title, message: dialog_message, on_confirm, } => { let title = text(dialog_title.clone()) .size(16) .shaping(Shaping::Advanced) .color(Color::WHITE); let msg = text(dialog_message.clone()) .size(13) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.80, 0.80, 0.85)); let on_confirm_clone = on_confirm.clone(); let buttons = row![ button( text(t(locale, "modal.confirm.cancel")) .size(13) .shaping(Shaping::Advanced) ) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style), Space::with_width(Length::Fill), button( text(t(locale, "modal.confirm.confirm")) .size(13) .shaping(Shaping::Advanced) ) .on_press(Message::ConfirmModal(on_confirm_clone)) .padding([6, 16]) .style(confirm_button_style), ]; let content_col = column![ title, Space::with_height(12.0), msg, Space::with_height(16.0), buttons, ] .spacing(4); container(content_col.padding(20)) .width(Length::Fixed(380.0)) .style(modal_box_style) .into() } ModalState::SearchIndexRepair => { let buttons = row![ button(text(t(locale, "searchIndexRepair.later")).size(13)) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style), Space::with_width(Length::Fill), button(text(t(locale, "searchIndexRepair.rebuildNow")).size(13)) .on_press(Message::ConfirmModal(ConfirmAction::RebuildSearchIndex)) .padding([6, 16]) .style(confirm_button_style), ]; let content = column![ text(t(locale, "searchIndexRepair.title")) .size(16) .shaping(Shaping::Advanced) .color(Color::WHITE), Space::with_height(12.0), text(t(locale, "searchIndexRepair.message")) .size(13) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.80, 0.80, 0.85)), Space::with_height(16.0), buttons, ]; container(content.padding(20)) .width(Length::Fixed(420.0)) .style(modal_box_style) .into() } ModalState::FindReplace { query, replacement, show_replace, } => { let find = text_input(&t(locale, "find.query"), &query) .on_input(Message::FindQueryChanged) .on_submit(Message::FindNext) .style(inputs::field_style); let mut fields = column![find].spacing(8); if show_replace { fields = fields.push( text_input(&t(locale, "find.replacement"), &replacement) .on_input(Message::ReplaceQueryChanged) .style(inputs::field_style), ); } let mut actions = row![ button(text(t(locale, "find.next"))) .on_press(Message::FindNext) .style(inputs::primary_button), ] .spacing(8); if show_replace { actions = actions .push( button(text(t(locale, "find.replace"))) .on_press(Message::ReplaceCurrent) .style(inputs::secondary_button), ) .push( button(text(t(locale, "find.replaceAll"))) .on_press(Message::ReplaceAll) .style(inputs::secondary_button), ); } container( column![ text(if show_replace { t(locale, "find.titleReplace") } else { t(locale, "find.titleFind") }) .size(16), fields, actions, ] .spacing(12), ) .width(Length::Fixed(440.0)) .padding(20) .style(modal_box_style) .into() } ModalState::SearchIndexRebuilding => container( column![ text(t(locale, "searchIndexRepair.rebuildingTitle")) .size(16) .shaping(Shaping::Advanced) .color(Color::WHITE), Space::with_height(12.0), text(t(locale, "searchIndexRepair.rebuildingMessage")) .size(13) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.80, 0.80, 0.85)), ] .padding(20), ) .width(Length::Fixed(420.0)) .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) .style(inputs::field_style); 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: 6.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) .style(inputs::field_style), 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) .style(inputs::field_style), 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: 6.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) .style(inputs::field_style); let media_items: Vec> = media_list .iter() .map(|m| { let is_image = m.mime_type.starts_with("image/"); 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 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_list .iter() .filter(|m| m.mime_type.starts_with("image/")) .cloned() .collect(); let media_items: Vec> = image_media .iter() .enumerate() .map(|(index, m)| { 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) .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() } ModalState::AISuggestions { target, fields } => { let title = text(t(locale, "modal.aiSuggestions.title")) .size(16) .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() }) .into() }) .collect::>>(); let buttons = row![ button(text(t(locale, "common.cancel")).size(13)) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style), Space::with_width(Length::Fill), button(text(t(locale, "modal.aiSuggestions.applySelected")).size(13)) .on_press(Message::ApplyAiSuggestions(target, fields)) .padding([6, 16]) .style(confirm_button_style), ]; let content = column![ title, Space::with_height(12.0), scrollable(column(rows).spacing(8)) .direction(scrollable::Direction::Vertical(inputs::compact_scrollbar())) .style(inputs::scrollable_style) .height(Length::Fixed(320.0)), Space::with_height(16.0), buttons, ] .spacing(4); container(content.padding(20)) .width(Length::Fixed(640.0)) .style(modal_box_style) .into() } 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 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::>>() }; let content = column![ title, subtitle, Space::with_height(12.0), column(rows).spacing(6), Space::with_height(16.0), button(text(t(locale, "common.cancel")).size(13)) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style), ] .spacing(4); container(content.padding(20)) .width(Length::Fixed(420.0)) .style(modal_box_style) .into() } }; // Center the modal in a full-screen backdrop container( container(modal_content) .center_x(Length::Fill) .center_y(Length::Fill), ) .width(Length::Fill) .height(Length::Fill) .style(backdrop_style) .into() } #[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); } }