feat: completed base feature set for the app

This commit is contained in:
2026-07-18 20:25:17 +02:00
parent e9d6c70eb0
commit 638202b96c
46 changed files with 4486 additions and 541 deletions

View File

@@ -220,6 +220,7 @@ pub enum MediaEditorMsg {
OpenLinkedPost(String),
UnlinkPost(String),
Save,
ReplaceFile,
Delete,
}
@@ -263,6 +264,11 @@ pub fn view<'a>(
.style(inputs::primary_button)
.padding([6, 16])
.into(),
button(text(t(locale, "editor.replaceFile")).size(13))
.on_press(Message::MediaEditor(MediaEditorMsg::ReplaceFile))
.style(inputs::secondary_button)
.padding([6, 16])
.into(),
button(text(t(locale, "modal.confirmDelete.delete")).size(13))
.on_press(Message::MediaEditor(MediaEditorMsg::Delete))
.style(inputs::danger_button)

View File

@@ -0,0 +1,153 @@
use iced::widget::text::Shaping;
use iced::widget::{Space, button, column, container, row, scrollable, text};
use iced::{Color, Element, Length};
use bds_core::engine::metadata_diff::{DiffReport, RepairDirection};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::{t, tw};
#[derive(Debug, Clone, Default)]
pub struct MetadataDiffState {
pub is_running: bool,
pub is_repairing: bool,
pub report: Option<DiffReport>,
pub error_message: Option<String>,
}
pub fn view<'a>(state: &'a MetadataDiffState, locale: UiLocale) -> Element<'a, Message> {
let run = button(text(t(locale, "metadataDiff.run")).size(13))
.on_press_maybe(
(!state.is_running && !state.is_repairing).then_some(Message::RunMetadataDiff),
)
.style(inputs::primary_button)
.padding([6, 16]);
let mut content = column![
row![
text(t(locale, "tabBar.metadataDiff"))
.size(24)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.88, 0.88, 0.92)),
Space::with_width(Length::Fill),
run,
]
.align_y(iced::Alignment::Center),
]
.spacing(16);
if state.is_running {
content = content.push(message_card(t(locale, "metadataDiff.running")));
} else if let Some(error) = &state.error_message {
content = content.push(message_card(error.clone()));
} else if let Some(report) = &state.report {
if report.diffs.is_empty() && report.orphans.is_empty() && report.errors.is_empty() {
content = content.push(message_card(t(locale, "metadataDiff.clean")));
}
for (index, item) in report.diffs.iter().enumerate() {
let fields = item
.fields
.iter()
.fold(column!().spacing(4), |column, field| {
column.push(
text(tw(
locale,
"metadataDiff.field",
&[
("field", &field.field_name),
("database", &field.db_value),
("file", &field.file_value),
],
))
.size(12)
.color(Color::from_rgb(0.72, 0.72, 0.78)),
)
});
let actions = row![
button(text(t(locale, "metadataDiff.fileToDb")).size(12))
.on_press_maybe((!state.is_repairing).then_some(
Message::RepairMetadataDiffItem {
index,
direction: RepairDirection::FileToDatabase,
},
))
.style(inputs::secondary_button)
.padding([5, 10]),
button(text(t(locale, "metadataDiff.dbToFile")).size(12))
.on_press_maybe((!state.is_repairing).then_some(
Message::RepairMetadataDiffItem {
index,
direction: RepairDirection::DatabaseToFile,
},
))
.style(inputs::secondary_button)
.padding([5, 10]),
]
.spacing(8);
content = content.push(inputs::card(
column![
text(tw(
locale,
"metadataDiff.entity",
&[("entity", &item.entity_type), ("path", &item.file_path)],
))
.size(15)
.color(Color::from_rgb(0.86, 0.86, 0.92)),
fields,
actions,
]
.spacing(10),
));
}
if !report.orphans.is_empty() {
let rows = report
.orphans
.iter()
.fold(column!().spacing(4), |column, orphan| {
column.push(
text(tw(
locale,
"metadataDiff.orphan",
&[("reason", &orphan.reason), ("path", &orphan.file_path)],
))
.size(12),
)
});
content = content.push(inputs::card(
column![text(t(locale, "metadataDiff.orphans")).size(16), rows].spacing(8),
));
}
if !report.errors.is_empty() {
content = content.push(inputs::card(
column![
text(t(locale, "metadataDiff.errors")).size(16),
report
.errors
.iter()
.fold(column!().spacing(4), |column, error| {
column.push(text(error.clone()).size(12))
}),
]
.spacing(8),
));
}
} else {
content = content.push(message_card(t(locale, "metadataDiff.idle")));
}
container(scrollable(content))
.width(Length::Fill)
.height(Length::Fill)
.padding(24)
.into()
}
fn message_card(value: String) -> Element<'static, Message> {
inputs::card(
text(value)
.size(14)
.color(Color::from_rgb(0.66, 0.66, 0.72)),
)
.into()
}

View File

@@ -1,6 +1,7 @@
pub mod activity_bar;
pub mod dashboard;
pub mod media_editor;
pub mod metadata_diff;
pub mod modal;
pub mod panel;
pub mod post_editor;
@@ -14,5 +15,6 @@ pub mod tab_bar;
pub mod tags_view;
pub mod template_editor;
pub mod toast;
pub mod translation_validation;
pub mod welcome;
pub mod workspace;

View File

@@ -65,6 +65,11 @@ pub enum ModalState {
},
SearchIndexRepair,
SearchIndexRebuilding,
FindReplace {
query: String,
replacement: String,
show_replace: bool,
},
/// Per modals.allium InsertPostLinkModal: Ctrl+K link insertion.
PostInsertLink {
post_id: String,
@@ -369,6 +374,60 @@ pub fn view(
.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![

View File

@@ -1200,7 +1200,7 @@ mod tests {
fn unsupported_default_mode_falls_back_to_markdown() {
let mut state = sample_state();
state.set_editor_mode("wysiwyg");
state.set_editor_mode("visual");
assert_eq!(state.editor_mode, "markdown");
state.set_editor_mode("preview");

View File

@@ -168,7 +168,27 @@ pub fn view(
.into(),
);
// New project button
// Open and create project actions
items.push(
button(
row![
text("")
.size(14)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.55, 0.75, 0.95)),
text(t(locale, "projectSelector.openProject"))
.size(12)
.shaping(Shaping::Advanced),
]
.spacing(6),
)
.on_press(Message::RequestOpenProject)
.padding([4, 8])
.width(Length::Fill)
.style(new_project_btn)
.into(),
);
items.push(
button(
row![

View File

@@ -205,12 +205,8 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M
.align_y(iced::Alignment::End)
.width(Length::Fill);
// Content editor (CodeEditor with syntax highlighting based on file extension)
let syntax_ext = if state.file_path.ends_with(".py") {
"py"
} else {
"lua"
};
// Content editor (Lua is the only scripting runtime).
let syntax_ext = "lua";
let content_section: Element<'a, Message> = inputs::card(
column![
inputs::section_header(&t(locale, "editor.content")),

View File

@@ -111,6 +111,7 @@ pub struct SettingsViewState {
pub available_languages: Vec<String>,
pub default_author: String,
pub max_posts_per_page: String,
pub image_import_concurrency: String,
pub blogmark_category: String,
// Editor
pub default_mode: String,
@@ -167,6 +168,7 @@ impl Clone for SettingsViewState {
available_languages: self.available_languages.clone(),
default_author: self.default_author.clone(),
max_posts_per_page: self.max_posts_per_page.clone(),
image_import_concurrency: self.image_import_concurrency.clone(),
blogmark_category: self.blogmark_category.clone(),
default_mode: self.default_mode.clone(),
diff_view_style: self.diff_view_style.clone(),
@@ -218,6 +220,7 @@ impl Default for SettingsViewState {
],
default_author: String::new(),
max_posts_per_page: "50".to_string(),
image_import_concurrency: "4".to_string(),
blogmark_category: String::new(),
default_mode: "markdown".to_string(),
diff_view_style: "inline".to_string(),
@@ -287,6 +290,7 @@ pub enum SettingsMsg {
ToggleBlogLanguage(String),
DefaultAuthorChanged(String),
MaxPostsPerPageChanged(String),
ImageImportConcurrencyChanged(String),
BlogmarkCategoryChanged(String),
SaveProject,
// Editor
@@ -524,6 +528,12 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
&state.max_posts_per_page,
|s| Message::Settings(SettingsMsg::MaxPostsPerPageChanged(s)),
);
let image_import_concurrency = inputs::labeled_input(
&t(locale, "settings.imageImportConcurrency"),
"4",
&state.image_import_concurrency,
|s| Message::Settings(SettingsMsg::ImageImportConcurrencyChanged(s)),
);
let blogmark_category = inputs::labeled_select(
&t(locale, "settings.blogmarkCategory"),
&state.categories,
@@ -554,6 +564,7 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
.spacing(4),
author,
max_posts,
image_import_concurrency,
blogmark_category,
save,
]
@@ -563,11 +574,7 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
}
fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
let mode_options = vec![
"wysiwyg".to_string(),
"markdown".to_string(),
"preview".to_string(),
];
let mode_options = vec!["markdown".to_string(), "preview".to_string()];
let diff_options = vec!["inline".to_string(), "side-by-side".to_string()];
let mode = inputs::labeled_select(
&t(locale, "settings.defaultMode"),

View File

@@ -0,0 +1,114 @@
use iced::widget::text::Shaping;
use iced::widget::{Space, button, column, container, row, scrollable, text};
use iced::{Color, Element, Length};
use bds_core::engine::validate_translations::{
TranslationIssue, TranslationIssueKind, TranslationValidationReport,
};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
#[derive(Debug, Clone, Default)]
pub struct TranslationValidationState {
pub is_running: bool,
pub report: Option<TranslationValidationReport>,
pub error_message: Option<String>,
}
pub fn view<'a>(state: &'a TranslationValidationState, locale: UiLocale) -> Element<'a, Message> {
let run = button(text(t(locale, "translationValidation.run")).size(13))
.on_press_maybe((!state.is_running).then_some(Message::ValidateTranslations))
.style(inputs::primary_button)
.padding([6, 16]);
let mut content = column![
row![
text(t(locale, "tabBar.translationValidation"))
.size(24)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.88, 0.88, 0.92)),
Space::with_width(Length::Fill),
run,
]
.align_y(iced::Alignment::Center),
]
.spacing(16);
if state.is_running {
content = content.push(message_card(t(locale, "translationValidation.running")));
} else if let Some(error) = &state.error_message {
content = content.push(message_card(error.clone()));
} else if let Some(report) = &state.report {
if report.db_issues.is_empty() && report.fs_issues.is_empty() {
content = content.push(message_card(t(locale, "translationValidation.clean")));
} else {
content = content.push(issue_section(
t(locale, "translationValidation.databaseIssues"),
&report.db_issues,
locale,
));
content = content.push(issue_section(
t(locale, "translationValidation.filesystemIssues"),
&report.fs_issues,
locale,
));
}
} else {
content = content.push(message_card(t(locale, "translationValidation.idle")));
}
container(scrollable(content))
.width(Length::Fill)
.height(Length::Fill)
.padding(24)
.into()
}
fn issue_section<'a>(
title: String,
issues: &'a [TranslationIssue],
locale: UiLocale,
) -> Element<'a, Message> {
if issues.is_empty() {
return Space::new(0, 0).into();
}
let rows = issues.iter().fold(column!().spacing(6), |column, issue| {
let location = issue.file_path.as_deref().unwrap_or(issue.post_id.as_str());
column.push(
text(format!(
"{} · {} · {}",
issue.language,
location,
issue_kind(locale, &issue.kind)
))
.size(12),
)
});
inputs::card(column![text(title).size(16), rows].spacing(8)).into()
}
fn issue_kind(locale: UiLocale, kind: &TranslationIssueKind) -> String {
let key = match kind {
TranslationIssueKind::MissingSourcePost => "translationValidation.missingSourcePost",
TranslationIssueKind::SameLanguageAsCanonical => {
"translationValidation.sameLanguageAsCanonical"
}
TranslationIssueKind::DoNotTranslateHasTranslations => {
"translationValidation.doNotTranslateHasTranslations"
}
TranslationIssueKind::ContentInDatabase => "translationValidation.contentInDatabase",
TranslationIssueKind::MissingTranslation => "translationValidation.missingTranslation",
};
t(locale, key)
}
fn message_card<'a>(message: String) -> Element<'a, Message> {
inputs::card(
text(message)
.size(13)
.color(Color::from_rgb(0.72, 0.72, 0.78)),
)
.into()
}

View File

@@ -17,6 +17,7 @@ use crate::views::{
activity_bar,
dashboard::DashboardState,
media_editor::{self, MediaEditorState},
metadata_diff::{self, MetadataDiffState},
modal, panel,
post_editor::{self, PostEditorState},
project_selector,
@@ -27,7 +28,9 @@ use crate::views::{
status_bar, tab_bar,
tags_view::{self, TagsViewState},
template_editor::{self, TemplateEditorState},
toast, welcome,
toast,
translation_validation::{self, TranslationValidationState},
welcome,
};
/// Main content area background.
@@ -117,6 +120,8 @@ pub fn view<'a>(
settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState,
) -> Element<'a, Message> {
// Activity bar (leftmost column)
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
@@ -140,6 +145,8 @@ pub fn view<'a>(
settings_state,
dashboard_state,
site_validation_state,
metadata_diff_state,
translation_validation_state,
);
// Right column: tab bar + content + panel
@@ -368,6 +375,8 @@ fn route_content_area<'a>(
settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState,
) -> Element<'a, Message> {
match route_kind(
tabs,
@@ -439,6 +448,10 @@ fn route_content_area<'a>(
}
}
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
ContentRoute::MetadataDiff => metadata_diff::view(metadata_diff_state, locale),
ContentRoute::TranslationValidation => {
translation_validation::view(translation_validation_state, locale)
}
ContentRoute::Placeholder(title) => welcome::tab_placeholder(locale, title, None),
}
}
@@ -455,6 +468,8 @@ enum ContentRoute<'a> {
Tags,
Settings,
SiteValidation,
MetadataDiff,
TranslationValidation,
Placeholder(&'a str),
}
@@ -529,16 +544,16 @@ fn route_kind<'a>(
}
}
TabType::SiteValidation => ContentRoute::SiteValidation,
TabType::MetadataDiff => ContentRoute::MetadataDiff,
TabType::Style
| TabType::Chat
| TabType::Import
| TabType::MenuEditor
| TabType::MetadataDiff
| TabType::GitDiff
| TabType::Documentation
| TabType::ApiDocumentation
| TabType::TranslationValidation
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
TabType::TranslationValidation => ContentRoute::TranslationValidation,
}
}
@@ -598,11 +613,9 @@ mod tests {
TabType::Chat,
TabType::Import,
TabType::MenuEditor,
TabType::MetadataDiff,
TabType::GitDiff,
TabType::Documentation,
TabType::ApiDocumentation,
TabType::TranslationValidation,
TabType::FindDuplicates,
];
@@ -627,6 +640,44 @@ mod tests {
}
}
#[test]
fn validation_tabs_route_to_real_views() {
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let site_validation_state = SiteValidationState::default();
for (tab_type, expected) in [
(TabType::MetadataDiff, "metadata diff"),
(TabType::TranslationValidation, "translation validation"),
] {
let tabs = vec![tab("tool", tab_type, "Tool")];
let route = route_kind(
&tabs,
Some("tool"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
None,
None,
None,
&site_validation_state,
);
let matches_expected = matches!(
(route, expected),
(ContentRoute::MetadataDiff, "metadata diff")
| (
ContentRoute::TranslationValidation,
"translation validation"
)
);
assert!(matches_expected, "expected {expected} route");
}
}
#[test]
fn dashboard_is_used_when_available_and_no_tab_is_active() {
let dashboard = DashboardState::new("Test Project".to_string());