1325 lines
49 KiB
Rust
1325 lines
49 KiB
Rust
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<String>,
|
|
}
|
|
|
|
/// Active modal state. Only one modal at a time.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ModalState {
|
|
/// Per modals.allium ConfirmDeleteModal: warning, entity name, references.
|
|
ConfirmDelete {
|
|
entity_name: String,
|
|
references: Vec<String>,
|
|
on_confirm: ConfirmAction,
|
|
},
|
|
/// Per modals.allium ConfirmDialog: generic confirmation.
|
|
Confirm {
|
|
title: String,
|
|
message: String,
|
|
on_confirm: ConfirmAction,
|
|
},
|
|
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<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>,
|
|
},
|
|
AISuggestions {
|
|
target: AiEntityTarget,
|
|
fields: Vec<AiSuggestionField>,
|
|
},
|
|
LanguagePicker {
|
|
target: AiEntityTarget,
|
|
source_language: String,
|
|
available_targets: Vec<LanguageTarget>,
|
|
},
|
|
RemoteConnection {
|
|
target: String,
|
|
connecting: bool,
|
|
connected: bool,
|
|
error: Option<String>,
|
|
projects: Vec<bds_core::model::Project>,
|
|
selected_project_id: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[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<String>,
|
|
target: String,
|
|
},
|
|
}
|
|
|
|
// ── Modal backdrop style ──
|
|
|
|
fn backdrop_style(_theme: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
|
..container::Style::default()
|
|
}
|
|
}
|
|
|
|
fn modal_box_style(_theme: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(Color::from_rgb(0.18, 0.18, 0.22))),
|
|
border: Border {
|
|
color: Color::from_rgb(0.30, 0.30, 0.35),
|
|
width: 1.0,
|
|
radius: 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<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,
|
|
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<Element<'static, Message>> = 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> = 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_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::<Vec<Element<'static, Message>>>();
|
|
|
|
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::<Vec<Element<'static, Message>>>()
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|