From ab3fae08ba32f41718dd2f0f60337935e7c7fc6d Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 10 Apr 2026 16:19:01 +0200 Subject: [PATCH] feat: more work on M4 --- Cargo.lock | 87 +++- Cargo.toml | 1 + crates/bds-core/Cargo.toml | 2 + crates/bds-core/src/engine/preview.rs | 414 ++++++++++++++- crates/bds-ui/src/app.rs | 650 +++++++++++++++++++++++- crates/bds-ui/src/views/media_editor.rs | 20 + crates/bds-ui/src/views/modal.rs | 149 +++++- crates/bds-ui/src/views/post_editor.rs | 23 +- locales/ui/de.json | 9 + locales/ui/en.json | 9 + locales/ui/es.json | 9 + locales/ui/fr.json | 9 + locales/ui/it.json | 9 + 13 files changed, 1380 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3128141..2b188c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -442,6 +442,58 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -461,6 +513,7 @@ dependencies = [ name = "bds-core" version = "0.1.0" dependencies = [ + "axum", "chrono", "deunicode", "image 0.25.10", @@ -479,6 +532,7 @@ dependencies = [ "sys-locale", "tempfile", "thiserror 2.0.18", + "tokio", "uuid", "walkdir", ] @@ -2232,6 +2286,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -2245,6 +2305,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3161,6 +3222,12 @@ dependencies = [ "libc", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3210,6 +3277,12 @@ dependencies = [ "paste", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4454,7 +4527,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5148,6 +5221,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -5890,6 +5974,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 553aa54..67c952a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ deunicode = "1" thiserror = "2" anyhow = "1" tokio = { version = "1", features = ["full"] } +axum = "0.8" walkdir = "2" image = "0.25" rust-stemmers = "1" diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index c6038bb..c3a6346 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -24,6 +24,8 @@ liquid = { workspace = true } liquid-core = { workspace = true } reqwest = { workspace = true } keyring = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs index a87eb64..85a644b 100644 --- a/crates/bds-core/src/engine/preview.rs +++ b/crates/bds-core/src/engine/preview.rs @@ -1,7 +1,110 @@ -use crate::engine::{EngineError, EngineResult}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::thread; + +use axum::extract::{Path as AxumPath, Query, State}; +use axum::http::{StatusCode, Uri, header}; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::get; +use axum::Router; +use serde::Deserialize; +use tokio::sync::oneshot; + +use crate::db::{Database, queries}; use crate::engine::generation::PublishedPostSource; -use crate::model::ProjectMetadata; +use crate::engine::{EngineError, EngineResult}; +use crate::model::{Post, PostStatus, ProjectMetadata}; use crate::render::{build_canonical_post_path, render_starter_list_page, render_starter_single_post_page}; +use crate::util::frontmatter::{read_post_file, read_translation_file}; + +pub const PREVIEW_HOST: &str = "127.0.0.1"; +pub const PREVIEW_PORT: u16 = 4123; + +#[derive(Debug)] +pub struct PreviewServerHandle { + shutdown: Option>, + thread: Option>, +} + +impl PreviewServerHandle { + pub fn stop(mut self) -> EngineResult<()> { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + Ok(()) + } +} + +impl Drop for PreviewServerHandle { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +#[derive(Debug, Clone)] +struct PreviewServerState { + db_path: PathBuf, + data_dir: PathBuf, + project_id: String, +} + +#[derive(Debug, Deserialize, Default)] +struct DraftPreviewQuery { + language: Option, +} + +pub fn start_preview_server( + db_path: PathBuf, + data_dir: PathBuf, + project_id: String, +) -> EngineResult { + let state = PreviewServerState { + db_path, + data_dir, + project_id, + }; + let listener = std::net::TcpListener::bind((PREVIEW_HOST, PREVIEW_PORT)).map_err(|error| { + if error.kind() == std::io::ErrorKind::AddrInUse { + EngineError::Conflict(format!("preview server already running on {PREVIEW_HOST}:{PREVIEW_PORT}")) + } else { + EngineError::Io(error) + } + })?; + listener.set_nonblocking(true)?; + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let thread = thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("preview runtime"); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener).expect("preview listener"); + let app = Router::new() + .route("/__draft/{post_id}", get(handle_draft_preview)) + .route("/", get(handle_preview_request)) + .route("/{*path}", get(handle_preview_request)) + .with_state(state); + let server = axum::serve(listener, app).with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }); + let _ = server.await; + }); + }); + + Ok(PreviewServerHandle { + shutdown: Some(shutdown_tx), + thread: Some(thread), + }) +} pub fn render_preview_path( path: &str, @@ -33,6 +136,192 @@ pub fn render_preview_path( Ok(None) } +async fn handle_preview_request( + State(state): State, + uri: Uri, +) -> Response { + match render_preview_response(&state, uri.path(), None) { + Ok(response) => response, + Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +async fn handle_draft_preview( + State(state): State, + AxumPath(post_id): AxumPath, + Query(query): Query, +) -> Response { + match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref()) { + Ok(response) => response, + Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +fn render_preview_response( + state: &PreviewServerState, + path: &str, + requested_language: Option<&str>, +) -> EngineResult { + if let Some(post_id) = path.strip_prefix("/__draft/") { + let html = render_draft_preview(state, post_id, requested_language)?; + return Ok(Html(html).into_response()); + } + + if let Some(file_response) = serve_project_file(&state.data_dir, path)? { + return Ok(file_response); + } + + let metadata = crate::engine::meta::read_project_json(&state.data_dir)?; + let published_posts = collect_published_posts(state, &metadata)?; + match render_preview_path(path, &metadata, &published_posts)? { + Some(html) => Ok(Html(html).into_response()), + None => Ok(error_response(StatusCode::NOT_FOUND, "preview not found")), + } +} + +fn render_draft_preview( + state: &PreviewServerState, + post_id: &str, + requested_language: Option<&str>, +) -> EngineResult { + let db = Database::open(&state.db_path)?; + let metadata = crate::engine::meta::read_project_json(&state.data_dir)?; + let post = queries::post::get_post_by_id(db.conn(), post_id)?; + let canonical_language = post.language.as_deref().unwrap_or_else(|| metadata.main_language.as_deref().unwrap_or("en")); + let target_language = requested_language.unwrap_or(canonical_language); + + if target_language != canonical_language { + if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language( + db.conn(), + post_id, + target_language, + ) { + let mut translated_post = post.clone(); + translated_post.title = translation.title.clone(); + translated_post.excerpt = translation.excerpt.clone(); + translated_post.language = Some(translation.language.clone()); + translated_post.status = translation.status.clone(); + translated_post.file_path = translation.file_path.clone(); + translated_post.published_at = translation.published_at.or(post.published_at); + let body = load_translation_body(&state.data_dir, &translation)?; + return render_starter_single_post_page(&translated_post, &body, &metadata, target_language) + .map(|page| page.html) + .map_err(|error| EngineError::Parse(error.to_string())); + } + } + + let body = load_post_body(&state.data_dir, &post)?; + render_starter_single_post_page(&post, &body, &metadata, canonical_language) + .map(|page| page.html) + .map_err(|error| EngineError::Parse(error.to_string())) +} + +fn collect_published_posts( + state: &PreviewServerState, + metadata: &ProjectMetadata, +) -> EngineResult> { + let db = Database::open(&state.db_path)?; + let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?; + let mut published = Vec::new(); + for post in posts.into_iter().filter(|post| matches!(post.status, PostStatus::Published)) { + published.push(PublishedPostSource { + body_markdown: load_post_body(&state.data_dir, &post)?, + post, + }); + } + let main_language = metadata.main_language.as_deref().unwrap_or("en"); + published.sort_by_key(|source| build_canonical_post_path(&source.post, main_language, main_language)); + Ok(published) +} + +fn load_post_body(data_dir: &Path, post: &Post) -> EngineResult { + if let Some(content) = &post.content { + return Ok(content.clone()); + } + if let Some(content) = &post.published_content { + return Ok(content.clone()); + } + load_markdown_body(data_dir, &post.file_path, false) +} + +fn load_translation_body( + data_dir: &Path, + translation: &crate::model::PostTranslation, +) -> EngineResult { + if let Some(content) = &translation.content { + return Ok(content.clone()); + } + load_markdown_body(data_dir, &translation.file_path, true) +} + +fn load_markdown_body(data_dir: &Path, relative_path: &str, translation: bool) -> EngineResult { + let raw = fs::read_to_string(data_dir.join(relative_path.trim_start_matches('/')))?; + let body = if translation { + read_translation_file(&raw).map(|(_, body)| body) + } else { + read_post_file(&raw).map(|(_, body)| body) + } + .map_err(EngineError::Parse)?; + Ok(body) +} + +fn serve_project_file(data_dir: &Path, path: &str) -> EngineResult> { + if let Some(response) = serve_scoped_file(data_dir, path, "/media/", "media")? { + return Ok(Some(response)); + } + if let Some(response) = serve_scoped_file(data_dir, path, "/assets/", "assets")? { + return Ok(Some(response)); + } + Ok(None) +} + +fn serve_scoped_file( + data_dir: &Path, + path: &str, + prefix: &str, + scope_dir: &str, +) -> EngineResult> { + let Some(relative) = path.strip_prefix(prefix) else { + return Ok(None); + }; + let scope_root = data_dir.join(scope_dir); + let candidate = scope_root.join(relative); + if !candidate.exists() || !candidate.is_file() { + return Ok(Some(error_response(StatusCode::NOT_FOUND, "preview asset not found"))); + } + let canonical_candidate = candidate.canonicalize()?; + let canonical_scope_root = scope_root.canonicalize().unwrap_or(scope_root); + if !canonical_candidate.starts_with(&canonical_scope_root) { + return Ok(Some(error_response(StatusCode::NOT_FOUND, "preview asset not found"))); + } + let bytes = fs::read(&canonical_candidate)?; + let mime = guess_content_type(&canonical_candidate); + Ok(Some(( + StatusCode::OK, + [(header::CONTENT_TYPE, mime)], + bytes, + ) + .into_response())) +} + +fn guess_content_type(path: &Path) -> &'static str { + match path.extension().and_then(|ext| ext.to_str()).unwrap_or_default() { + "css" => "text/css; charset=utf-8", + "js" => "application/javascript; charset=utf-8", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + _ => "application/octet-stream", + } +} + +fn error_response(status: StatusCode, message: &str) -> Response { + (status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], message.to_string()).into_response() +} + fn split_language_prefix(path: &str, metadata: &ProjectMetadata) -> (String, String) { let trimmed = path.trim_start_matches('/'); let mut segments = trimmed.split('/'); @@ -51,7 +340,43 @@ fn split_language_prefix(path: &str, metadata: &ProjectMetadata) -> (String, Str #[cfg(test)] mod tests { use super::*; - use crate::model::{Post, PostStatus}; + use crate::db::queries; + use crate::engine::meta; + use crate::model::{Post, Project, ProjectMetadata, PostStatus}; + use std::sync::{Mutex, OnceLock}; + + fn preview_port_guard() -> &'static Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| Mutex::new(())) + } + + fn setup_preview_fixture() -> (tempfile::TempDir, Database) { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("meta")).unwrap(); + std::fs::create_dir_all(dir.path().join("posts/2024/03")).unwrap(); + std::fs::create_dir_all(dir.path().join("media")).unwrap(); + + meta::write_project_json(dir.path(), &make_metadata()).unwrap(); + + let db_path = dir.path().join("bds.db"); + let mut db = Database::open(&db_path).unwrap(); + db.migrate().unwrap(); + queries::project::insert_project( + db.conn(), + &Project { + id: "project-1".into(), + name: "Blog".into(), + slug: "blog".into(), + description: None, + data_path: Some(dir.path().to_string_lossy().to_string()), + is_active: true, + created_at: 1_710_000_000_000, + updated_at: 1_710_000_000_000, + }, + ) + .unwrap(); + (dir, db) + } fn make_metadata() -> ProjectMetadata { ProjectMetadata { @@ -99,6 +424,34 @@ mod tests { } } + fn make_draft_post() -> Post { + Post { + id: "post-1".into(), + project_id: "project-1".into(), + title: "Hello".into(), + slug: "hello".into(), + excerpt: Some("Excerpt".into()), + content: Some("Draft **body**".into()), + status: PostStatus::Draft, + author: None, + language: Some("en".into()), + do_not_translate: false, + template_slug: None, + file_path: "posts/2024/03/hello.md".into(), + checksum: None, + tags: vec![], + categories: vec![], + published_title: None, + published_content: None, + published_tags: None, + published_categories: None, + published_excerpt: None, + created_at: 1_710_000_000_000, + updated_at: 1_710_000_000_000, + published_at: Some(1_710_000_000_000), + } + } + #[test] fn root_preview_renders_index_page() { let html = render_preview_path("/", &make_metadata(), &[make_post()]) @@ -123,4 +476,59 @@ mod tests { .unwrap(); assert!(html.contains("lang=\"de\"")); } + + #[test] + fn preview_server_serves_draft_post_from_localhost() { + let _guard = preview_port_guard().lock().unwrap(); + let (dir, db) = setup_preview_fixture(); + queries::post::insert_post(db.conn(), &make_draft_post()).unwrap(); + + let server = start_preview_server( + dir.path().join("bds.db"), + dir.path().to_path_buf(), + "project-1".into(), + ) + .unwrap(); + + let client = reqwest::blocking::Client::new(); + let mut body = None; + for _ in 0..20 { + if let Ok(response) = client.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1")).send() { + if response.status().is_success() { + body = Some(response.text().unwrap()); + break; + } + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + server.stop().unwrap(); + + let body = body.expect("draft preview response"); + assert!(body.contains("

Hello

")); + assert!(body.contains("body")); + } + + #[test] + fn preview_server_blocks_media_path_traversal() { + let _guard = preview_port_guard().lock().unwrap(); + let (dir, _db) = setup_preview_fixture(); + std::fs::write(dir.path().join("outside.txt"), "nope").unwrap(); + std::fs::write(dir.path().join("media/ok.txt"), "ok").unwrap(); + + let server = start_preview_server( + dir.path().join("bds.db"), + dir.path().to_path_buf(), + "project-1".into(), + ) + .unwrap(); + + let client = reqwest::blocking::Client::new(); + let response = client + .get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt")) + .send() + .unwrap(); + server.stop().unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } } \ No newline at end of file diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index d5060b7..2be3f44 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use chrono::Datelike; use iced::{Element, Subscription, Task}; +use serde_json::json; use bds_core::db::Database; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; @@ -118,6 +119,8 @@ pub enum Message { ShowModal(modal::ModalState), DismissModal, ConfirmModal(modal::ConfirmAction), + ToggleAiSuggestionField(usize, bool), + ApplyAiSuggestions(modal::AiEntityTarget, Vec), // Blog actions (dispatched to engine) RebuildDatabase, @@ -217,6 +220,11 @@ enum PersistedMediaState { Translation, } +struct PreviewSession { + project_id: String, + handle: engine::preview::PreviewServerHandle, +} + fn persist_media_editor_state_impl( db: &Database, data_dir: &Path, @@ -1162,6 +1170,9 @@ pub struct BdsApp { // Modal active_modal: Option, + // Local preview + preview_session: Option, + // Editor states (keyed by entity id) post_editors: HashMap, media_editors: HashMap, @@ -1288,6 +1299,7 @@ impl BdsApp { theme_badge: String::from("pico"), toasts: Vec::new(), active_modal: None, + preview_session: None, post_editors: HashMap::new(), media_editors: HashMap::new(), template_editors: HashMap::new(), @@ -1342,6 +1354,7 @@ impl BdsApp { theme_badge: String::from("pico"), toasts: Vec::new(), active_modal: None, + preview_session: None, post_editors: HashMap::new(), media_editors: HashMap::new(), template_editors: HashMap::new(), @@ -1512,6 +1525,7 @@ impl BdsApp { match engine::project::set_active_project(db.conn(), &project_id) { Ok(()) => { self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned(); + self.preview_session = None; self.data_dir = self .active_project .as_ref() @@ -1562,6 +1576,7 @@ impl BdsApp { let _ = engine::project::set_active_project(db.conn(), &project.id); self.projects = engine::project::list_projects(db.conn()).unwrap_or_default(); self.active_project = Some(project.clone()); + self.preview_session = None; self.data_dir = project.data_path.as_ref().map(PathBuf::from); let msg = tw(self.ui_locale, "projectSelector.toast.created", &[("name", &project.name)]); self.notify(ToastLevel::Success, &msg); @@ -2042,11 +2057,30 @@ impl BdsApp { } } } + Message::ToggleAiSuggestionField(index, accepted) => { + if let Some(modal::ModalState::AISuggestions { fields, .. }) = self.active_modal.as_mut() { + if let Some(field) = fields.get_mut(index) { + if !field.locked { + field.accepted = accepted; + } + } + } + Task::none() + } + Message::ApplyAiSuggestions(target, fields) => { + self.active_modal = None; + self.apply_ai_suggestions(target, &fields) + } // ── Editor view messages ── Message::PostEditor(msg) => { enum DeferredPostAction { None, + Analyze(String), + AnalyzeTaxonomy(String), + DetectLanguage(String), + OpenTranslate(String), + TranslateTo { post_id: String, target_language: String }, Save(String), Publish(String), Duplicate(String), @@ -2076,6 +2110,21 @@ impl BdsApp { if let Some(tab_id) = self.active_tab.clone() { if let Some(state) = self.post_editors.get_mut(&tab_id) { match msg { + PostEditorMsg::AnalyzeWithAi => { + deferred = DeferredPostAction::Analyze(tab_id.clone()); + } + PostEditorMsg::AnalyzeTaxonomy => { + deferred = DeferredPostAction::AnalyzeTaxonomy(tab_id.clone()); + } + PostEditorMsg::DetectLanguage => { + deferred = DeferredPostAction::DetectLanguage(tab_id.clone()); + } + PostEditorMsg::Translate => { + deferred = DeferredPostAction::OpenTranslate(tab_id.clone()); + } + PostEditorMsg::TranslateTo(target_language) => { + deferred = DeferredPostAction::TranslateTo { post_id: tab_id.clone(), target_language }; + } PostEditorMsg::TitleChanged(s) => { state.title = s; state.mark_dirty(); } PostEditorMsg::SlugChanged(s) => { state.slug = s; state.mark_dirty(); } PostEditorMsg::ExcerptChanged(s) => { state.excerpt = s; state.mark_dirty(); } @@ -2216,6 +2265,13 @@ impl BdsApp { match deferred { DeferredPostAction::None => Task::none(), + DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id), + DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_analysis(&tab_id), + DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id), + DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id), + DeferredPostAction::TranslateTo { post_id, target_language } => { + self.translate_post_to(&post_id, &target_language) + } DeferredPostAction::Save(tab_id) => self.save_post_editor(&tab_id), DeferredPostAction::Publish(tab_id) => self.publish_post_editor(&tab_id), DeferredPostAction::Duplicate(tab_id) => self.duplicate_post_editor(&tab_id), @@ -2331,6 +2387,10 @@ impl BdsApp { Message::MediaEditor(msg) => { enum DeferredMediaAction { None, + Analyze(String), + DetectLanguage(String), + OpenTranslate(String), + TranslateTo { media_id: String, target_language: String }, LinkPost { media_id: String, post_id: String }, OpenLinkedPost(String), UnlinkPost { media_id: String, post_id: String }, @@ -2343,6 +2403,18 @@ impl BdsApp { if let Some(tab_id) = self.active_tab.clone() { if let Some(state) = self.media_editors.get_mut(&tab_id) { match msg { + MediaEditorMsg::AnalyzeWithAi => { + deferred = DeferredMediaAction::Analyze(tab_id.clone()); + } + MediaEditorMsg::DetectLanguage => { + deferred = DeferredMediaAction::DetectLanguage(tab_id.clone()); + } + MediaEditorMsg::TranslateMetadata => { + deferred = DeferredMediaAction::OpenTranslate(tab_id.clone()); + } + MediaEditorMsg::TranslateTo(target_language) => { + deferred = DeferredMediaAction::TranslateTo { media_id: tab_id.clone(), target_language }; + } MediaEditorMsg::TitleChanged(s) => { state.title = s; state.is_dirty = true; } MediaEditorMsg::AltChanged(s) => { state.alt = s; state.is_dirty = true; } MediaEditorMsg::CaptionChanged(s) => { state.caption = s; state.is_dirty = true; } @@ -2403,6 +2475,12 @@ impl BdsApp { } match deferred { DeferredMediaAction::None => Task::none(), + DeferredMediaAction::Analyze(media_id) => self.run_media_ai_analysis(&media_id), + DeferredMediaAction::DetectLanguage(media_id) => self.detect_media_language(&media_id), + DeferredMediaAction::OpenTranslate(media_id) => self.open_media_translation_modal(&media_id), + DeferredMediaAction::TranslateTo { media_id, target_language } => { + self.translate_media_to(&media_id, &target_language) + } DeferredMediaAction::LinkPost { media_id, post_id } => self.link_media_to_post(&media_id, &post_id), DeferredMediaAction::OpenLinkedPost(post_id) => { let title = self @@ -2704,7 +2782,7 @@ impl BdsApp { t(self.ui_locale, "dialog.imageFilter"), ), MenuAction::Save => Task::none(), // Disabled in M2 - MenuAction::OpenInBrowser => Task::none(), + MenuAction::OpenInBrowser => self.preview_active_post(), MenuAction::OpenDataFolder => { if let Some(ref dir) = self.data_dir { let _ = open::that(dir); @@ -2733,7 +2811,7 @@ impl BdsApp { } // Blog MenuAction::PublishSelected => Task::none(), // Disabled in M2 - MenuAction::PreviewPost => Task::none(), // Disabled in M2 + MenuAction::PreviewPost => self.preview_active_post(), MenuAction::EditMenu => { self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor"); Task::none() @@ -3603,13 +3681,17 @@ impl BdsApp { /// offline toggle) so that menu items reflect what's actually possible. fn sync_menu_state(&self) { let has_project = self.active_project.is_some(); - let has_tab = self.active_tab.is_some(); + let active_tab_type = self.active_tab.as_ref().and_then(|id| { + self.tabs.iter().find(|t| t.id == *id).map(|t| &t.tab_type) + }); + let has_tab = active_tab_type.is_some(); + let has_post_tab = active_tab_type == Some(&TabType::Post); // File group: need active project for most, need open tab for Save self.menu_registry.set_enabled(MenuAction::NewPost, has_project); self.menu_registry.set_enabled(MenuAction::ImportMedia, has_project); self.menu_registry.set_enabled(MenuAction::Save, has_tab); - self.menu_registry.set_enabled(MenuAction::OpenInBrowser, has_tab); + self.menu_registry.set_enabled(MenuAction::OpenInBrowser, has_project && has_post_tab); self.menu_registry.set_enabled(MenuAction::OpenDataFolder, has_project); // Edit: Find/Replace need an open tab @@ -3618,7 +3700,7 @@ impl BdsApp { // Blog group: need active project self.menu_registry.set_enabled(MenuAction::PublishSelected, has_project && has_tab); - self.menu_registry.set_enabled(MenuAction::PreviewPost, has_project && has_tab); + self.menu_registry.set_enabled(MenuAction::PreviewPost, has_project && has_post_tab); self.menu_registry.set_enabled(MenuAction::EditMenu, has_project); self.menu_registry.set_enabled(MenuAction::RebuildDatabase, has_project); self.menu_registry.set_enabled(MenuAction::ReindexText, has_project); @@ -5575,4 +5657,562 @@ impl BdsApp { api_key, }) } + + fn preview_active_post(&mut self) -> Task { + let Some(active_id) = self.active_tab.clone() else { + return Task::none(); + }; + if !self + .tabs + .iter() + .any(|tab| tab.id == active_id && tab.tab_type == TabType::Post) + { + return Task::none(); + } + if let Err(error) = self.persist_post_editor_state(&active_id) { + self.notify(ToastLevel::Error, &error); + return Task::none(); + } + + let Some(project) = self.active_project.as_ref() else { + self.notify(ToastLevel::Error, &t(self.ui_locale, "engine.generateSiteNoProject")); + return Task::none(); + }; + let Some(data_dir) = self.data_dir.clone() else { + self.notify(ToastLevel::Error, &t(self.ui_locale, "engine.previewDataDirUnavailable")); + return Task::none(); + }; + + let should_restart = self + .preview_session + .as_ref() + .map(|session| session.project_id != project.id) + .unwrap_or(true); + if should_restart { + self.preview_session = None; + match engine::preview::start_preview_server( + self.db_path.clone(), + data_dir, + project.id.clone(), + ) { + Ok(handle) => { + self.preview_session = Some(PreviewSession { + project_id: project.id.clone(), + handle, + }); + } + Err(engine::EngineError::Conflict(_)) if self.preview_session.is_none() => { + self.notify(ToastLevel::Error, &t(self.ui_locale, "engine.previewPortInUse")); + return Task::none(); + } + Err(error) => { + self.notify(ToastLevel::Error, &error.to_string()); + return Task::none(); + } + } + } + + if let Some(session) = &self.preview_session { + let _ = &session.handle; + } + + let language = self + .post_editors + .get(&active_id) + .map(|editor| editor.active_language.clone()) + .filter(|language| !language.is_empty()) + .unwrap_or_else(|| self.content_language.clone()); + let url = format!( + "http://{}:{}/__draft/{}?language={}", + engine::preview::PREVIEW_HOST, + engine::preview::PREVIEW_PORT, + active_id, + language + ); + if let Err(error) = open::that(&url) { + self.notify(ToastLevel::Error, &error.to_string()); + } + Task::none() + } + + fn run_post_ai_analysis(&mut self, post_id: &str) -> Task { + let Some(db) = &self.db else { + self.notify(ToastLevel::Error, "database unavailable"); + return Task::none(); + }; + let Some(state) = self.post_editors.get(post_id).cloned() else { + return Task::none(); + }; + let request = ai::OneShotRequest { + operation: ai::OneShotOperation::AnalyzePost, + content: json!({ + "title": state.title, + "excerpt": state.excerpt, + "content": content_sample(&state.content, 2000), + }), + }; + match ai::run_one_shot(db.conn(), self.offline_mode, &request) { + Ok(ai::OneShotResponse::PostAnalysis(result)) => { + self.active_modal = Some(modal::ModalState::AISuggestions { + target: modal::AiEntityTarget::Post(post_id.to_string()), + fields: vec![ + modal::AiSuggestionField { + key: "title".to_string(), + label: t(self.ui_locale, "editor.title"), + current_value: state.title, + suggested_value: result.title, + accepted: true, + locked: false, + }, + modal::AiSuggestionField { + key: "excerpt".to_string(), + label: t(self.ui_locale, "editor.excerpt"), + current_value: state.excerpt, + suggested_value: result.excerpt, + accepted: true, + locked: false, + }, + modal::AiSuggestionField { + key: "slug".to_string(), + label: t(self.ui_locale, "editor.slug"), + current_value: state.slug, + suggested_value: result.slug, + accepted: state.published_at.is_none(), + locked: state.published_at.is_some(), + }, + ], + }); + } + Ok(_) => {} + Err(error) => self.notify(ToastLevel::Error, &error.to_string()), + } + Task::none() + } + + fn run_post_taxonomy_analysis(&mut self, post_id: &str) -> Task { + let Some(db) = &self.db else { + self.notify(ToastLevel::Error, "database unavailable"); + return Task::none(); + }; + let Some(state) = self.post_editors.get(post_id).cloned() else { + return Task::none(); + }; + let request = ai::OneShotRequest { + operation: ai::OneShotOperation::AnalyzeTaxonomy, + content: json!({ + "title": state.title, + "excerpt": state.excerpt, + "content": content_sample(&state.content, 2000), + "tags": state.tags, + "categories": state.categories, + }), + }; + match ai::run_one_shot(db.conn(), self.offline_mode, &request) { + Ok(ai::OneShotResponse::Taxonomy(result)) => { + self.active_modal = Some(modal::ModalState::AISuggestions { + target: modal::AiEntityTarget::Post(post_id.to_string()), + fields: vec![ + modal::AiSuggestionField { + key: "tags".to_string(), + label: t(self.ui_locale, "sidebar.filter.tags"), + current_value: state.tags.join(", "), + suggested_value: result.tags.join(", "), + accepted: true, + locked: false, + }, + modal::AiSuggestionField { + key: "categories".to_string(), + label: t(self.ui_locale, "sidebar.filter.categories"), + current_value: state.categories.join(", "), + suggested_value: result.categories.join(", "), + accepted: true, + locked: false, + }, + ], + }); + } + Ok(_) => {} + Err(error) => self.notify(ToastLevel::Error, &error.to_string()), + } + Task::none() + } + + fn detect_post_language(&mut self, post_id: &str) -> Task { + let Some(db) = &self.db else { + self.notify(ToastLevel::Error, "database unavailable"); + return Task::none(); + }; + let Some(state) = self.post_editors.get(post_id).cloned() else { + return Task::none(); + }; + let request = ai::OneShotRequest { + operation: ai::OneShotOperation::DetectLanguage, + content: json!({ + "text": format!("{}\n\n{}\n\n{}", state.title, state.excerpt, content_sample(&state.content, 2000)), + }), + }; + match ai::run_one_shot(db.conn(), self.offline_mode, &request) { + Ok(ai::OneShotResponse::LanguageDetection(result)) => { + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.language = result.language_code; + editor.mark_dirty(); + } + if let Err(error) = self.persist_post_editor_state(post_id) { + self.notify(ToastLevel::Error, &error); + } else { + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + } + Ok(_) => {} + Err(error) => self.notify(ToastLevel::Error, &error.to_string()), + } + Task::none() + } + + fn open_post_translation_modal(&mut self, post_id: &str) -> Task { + let Some(state) = self.post_editors.get(post_id) else { + return Task::none(); + }; + let targets = self + .blog_languages + .iter() + .filter(|language| **language != state.active_language) + .map(|language| modal::LanguageTarget { + code: language.clone(), + name: language_label(self.ui_locale, language), + flag_emoji: bds_core::i18n::normalize_language(language).flag_emoji().to_string(), + has_existing_translation: state.translation_drafts.contains_key(language), + existing_status: state.translation_drafts.get(language).map(|draft| match draft.status { + PostStatus::Draft => "draft".to_string(), + PostStatus::Published => "published".to_string(), + PostStatus::Archived => "archived".to_string(), + }), + }) + .collect::>(); + self.active_modal = Some(modal::ModalState::LanguagePicker { + target: modal::AiEntityTarget::Post(post_id.to_string()), + source_language: state.active_language.clone(), + available_targets: targets, + }); + Task::none() + } + + fn translate_post_to(&mut self, post_id: &str, target_language: &str) -> Task { + self.active_modal = None; + let Some(db) = &self.db else { + self.notify(ToastLevel::Error, "database unavailable"); + return Task::none(); + }; + let Some(data_dir) = &self.data_dir else { + self.notify(ToastLevel::Error, "project data directory unavailable"); + return Task::none(); + }; + let Some(state) = self.post_editors.get(post_id).cloned() else { + return Task::none(); + }; + + if state.active_language == state.canonical_language && state.status == PostStatus::Published { + match engine::post::update_post( + db.conn(), + data_dir, + post_id, + Some(&state.title), + None, + Some(Some(state.excerpt.as_str())), + Some(&state.content), + Some(state.tags.clone()), + Some(state.categories.clone()), + Some(Some(state.author.as_str())), + Some(Some(state.language.as_str())), + Some(Some(state.template_slug.as_str())), + Some(state.do_not_translate), + ) { + Ok(updated) => { + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.status = updated.status; + } + } + Err(error) => { + self.notify(ToastLevel::Error, &error.to_string()); + return Task::none(); + } + } + } + + let request = ai::OneShotRequest { + operation: ai::OneShotOperation::TranslatePost { target_language: target_language.to_string() }, + content: json!({ + "title": state.title, + "excerpt": state.excerpt, + "content": state.content, + }), + }; + match ai::run_one_shot(db.conn(), self.offline_mode, &request) { + Ok(ai::OneShotResponse::Translation(result)) => { + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.switch_language(target_language); + editor.title = result.title.clone(); + editor.excerpt = result.excerpt.clone(); + editor.content = result.content.clone(); + editor.editor_buffer = std::cell::RefCell::new(bds_editor::EditorBuffer::new(&result.content)); + editor.mark_dirty(); + } + if let Err(error) = self.persist_post_editor_state(post_id) { + self.notify(ToastLevel::Error, &error); + } else { + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + } + Ok(_) => {} + Err(error) => self.notify(ToastLevel::Error, &error.to_string()), + } + Task::none() + } + + fn run_media_ai_analysis(&mut self, media_id: &str) -> Task { + let Some(db) = &self.db else { + self.notify(ToastLevel::Error, "database unavailable"); + return Task::none(); + }; + let Some(state) = self.media_editors.get(media_id).cloned() else { + return Task::none(); + }; + let request = ai::OneShotRequest { + operation: ai::OneShotOperation::AnalyzeImage, + content: json!({ + "title": state.title, + "alt": state.alt, + "caption": state.caption, + "filename": state.original_name, + "mime_type": state.mime_type, + }), + }; + match ai::run_one_shot(db.conn(), self.offline_mode, &request) { + Ok(ai::OneShotResponse::ImageAnalysis(result)) => { + self.active_modal = Some(modal::ModalState::AISuggestions { + target: modal::AiEntityTarget::Media(media_id.to_string()), + fields: vec![ + modal::AiSuggestionField { + key: "title".to_string(), + label: t(self.ui_locale, "editor.title"), + current_value: state.title, + suggested_value: result.title, + accepted: true, + locked: false, + }, + modal::AiSuggestionField { + key: "alt".to_string(), + label: t(self.ui_locale, "editor.alt"), + current_value: state.alt, + suggested_value: result.alt, + accepted: true, + locked: false, + }, + modal::AiSuggestionField { + key: "caption".to_string(), + label: t(self.ui_locale, "editor.caption"), + current_value: state.caption, + suggested_value: result.caption, + accepted: true, + locked: false, + }, + ], + }); + } + Ok(_) => {} + Err(error) => self.notify(ToastLevel::Error, &error.to_string()), + } + Task::none() + } + + fn detect_media_language(&mut self, media_id: &str) -> Task { + let Some(db) = &self.db else { + self.notify(ToastLevel::Error, "database unavailable"); + return Task::none(); + }; + let Some(state) = self.media_editors.get(media_id).cloned() else { + return Task::none(); + }; + let request = ai::OneShotRequest { + operation: ai::OneShotOperation::DetectLanguage, + content: json!({ + "text": format!("{}\n{}\n{}", state.title, state.alt, state.caption), + }), + }; + match ai::run_one_shot(db.conn(), self.offline_mode, &request) { + Ok(ai::OneShotResponse::LanguageDetection(result)) => { + if let Some(editor) = self.media_editors.get_mut(media_id) { + editor.language = result.language_code; + editor.is_dirty = true; + } + if let Err(error) = self.persist_media_editor_state(media_id) { + self.notify(ToastLevel::Error, &error); + } else { + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + } + Ok(_) => {} + Err(error) => self.notify(ToastLevel::Error, &error.to_string()), + } + Task::none() + } + + fn open_media_translation_modal(&mut self, media_id: &str) -> Task { + let Some(state) = self.media_editors.get(media_id) else { + return Task::none(); + }; + let targets = self + .blog_languages + .iter() + .filter(|language| **language != state.active_language) + .map(|language| modal::LanguageTarget { + code: language.clone(), + name: language_label(self.ui_locale, language), + flag_emoji: bds_core::i18n::normalize_language(language).flag_emoji().to_string(), + has_existing_translation: state.translation_drafts.contains_key(language), + existing_status: state.translation_drafts.get(language).map(|_| "draft".to_string()), + }) + .collect::>(); + self.active_modal = Some(modal::ModalState::LanguagePicker { + target: modal::AiEntityTarget::Media(media_id.to_string()), + source_language: state.active_language.clone(), + available_targets: targets, + }); + Task::none() + } + + fn translate_media_to(&mut self, media_id: &str, target_language: &str) -> Task { + self.active_modal = None; + let Some(db) = &self.db else { + self.notify(ToastLevel::Error, "database unavailable"); + return Task::none(); + }; + let Some(state) = self.media_editors.get(media_id).cloned() else { + return Task::none(); + }; + let source_language = if state.language.is_empty() { + match ai::run_one_shot( + db.conn(), + self.offline_mode, + &ai::OneShotRequest { + operation: ai::OneShotOperation::DetectLanguage, + content: json!({ "text": format!("{}\n{}\n{}", state.title, state.alt, state.caption) }), + }, + ) { + Ok(ai::OneShotResponse::LanguageDetection(result)) => result.language_code, + Ok(_) => String::new(), + Err(error) => { + self.notify(ToastLevel::Error, &error.to_string()); + return Task::none(); + } + } + } else { + state.language.clone() + }; + if let Some(editor) = self.media_editors.get_mut(media_id) { + if editor.language.is_empty() { + editor.language = source_language; + } + } + let request = ai::OneShotRequest { + operation: ai::OneShotOperation::TranslateMedia { target_language: target_language.to_string() }, + content: json!({ + "title": state.title, + "alt": state.alt, + "caption": state.caption, + }), + }; + match ai::run_one_shot(db.conn(), self.offline_mode, &request) { + Ok(ai::OneShotResponse::MediaTranslation(result)) => { + if let Some(editor) = self.media_editors.get_mut(media_id) { + editor.switch_language(target_language); + editor.title = result.title.clone(); + editor.alt = result.alt.clone(); + editor.caption = result.caption.clone(); + editor.is_dirty = true; + } + if let Err(error) = self.persist_media_editor_state(media_id) { + self.notify(ToastLevel::Error, &error); + } else { + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + } + Ok(_) => {} + Err(error) => self.notify(ToastLevel::Error, &error.to_string()), + } + Task::none() + } + + fn apply_ai_suggestions( + &mut self, + target: modal::AiEntityTarget, + fields: &[modal::AiSuggestionField], + ) -> Task { + match target { + modal::AiEntityTarget::Post(post_id) => { + if let Some(editor) = self.post_editors.get_mut(&post_id) { + for field in fields.iter().filter(|field| field.accepted && !field.locked) { + match field.key.as_str() { + "title" => editor.title = field.suggested_value.clone(), + "excerpt" => editor.excerpt = field.suggested_value.clone(), + "slug" => editor.slug = field.suggested_value.clone(), + "tags" => { + editor.tags = split_csv_values(&field.suggested_value); + editor.tags_input.clear(); + } + "categories" => { + editor.categories = split_csv_values(&field.suggested_value); + editor.categories_input.clear(); + } + _ => {} + } + } + editor.mark_dirty(); + } + if let Err(error) = self.persist_post_editor_state(&post_id) { + self.notify(ToastLevel::Error, &error); + } else { + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + } + modal::AiEntityTarget::Media(media_id) => { + if let Some(editor) = self.media_editors.get_mut(&media_id) { + for field in fields.iter().filter(|field| field.accepted && !field.locked) { + match field.key.as_str() { + "title" => editor.title = field.suggested_value.clone(), + "alt" => editor.alt = field.suggested_value.clone(), + "caption" => editor.caption = field.suggested_value.clone(), + _ => {} + } + } + editor.is_dirty = true; + } + if let Err(error) = self.persist_media_editor_state(&media_id) { + self.notify(ToastLevel::Error, &error); + } else { + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + } + } + Task::none() + } +} + +fn content_sample(content: &str, max_len: usize) -> String { + content.chars().take(max_len).collect() +} + +fn split_csv_values(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| item.to_string()) + .collect() +} + +fn language_label(locale: UiLocale, code: &str) -> String { + let key = format!("language.{code}"); + let value = t(locale, &key); + if value == key { code.to_string() } else { value } } diff --git a/crates/bds-ui/src/views/media_editor.rs b/crates/bds-ui/src/views/media_editor.rs index c208078..b7ce6c9 100644 --- a/crates/bds-ui/src/views/media_editor.rs +++ b/crates/bds-ui/src/views/media_editor.rs @@ -197,6 +197,10 @@ impl MediaEditorState { /// Media editor messages. #[derive(Debug, Clone)] pub enum MediaEditorMsg { + AnalyzeWithAi, + DetectLanguage, + TranslateMetadata, + TranslateTo(String), TitleChanged(String), AltChanged(String), CaptionChanged(String), @@ -224,6 +228,22 @@ pub fn view<'a>( text(state.original_name.clone()).size(18).into(), ], vec![ + if state.mime_type.starts_with("image/") { + button(text(t(locale, "editor.aiAnalyze")).size(13)) + .on_press(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)) + .padding([6, 16]) + .into() + } else { + Space::new(0, 0).into() + }, + button(text(t(locale, "editor.detectLanguage")).size(13)) + .on_press(Message::MediaEditor(MediaEditorMsg::DetectLanguage)) + .padding([6, 16]) + .into(), + button(text(t(locale, "editor.translate")).size(13)) + .on_press(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)) + .padding([6, 16]) + .into(), button(text(t(locale, "common.save")).size(13)) .on_press(Message::MediaEditor(MediaEditorMsg::Save)) .style(inputs::primary_button) diff --git a/crates/bds-ui/src/views/modal.rs b/crates/bds-ui/src/views/modal.rs index eb96d28..cdaf874 100644 --- a/crates/bds-ui/src/views/modal.rs +++ b/crates/bds-ui/src/views/modal.rs @@ -1,7 +1,7 @@ use std::path::Path; use iced::widget::text::Shaping; -use iced::widget::{button, column, container, image, row, text, text_input, Space}; +use iced::widget::{button, checkbox, column, container, image, row, scrollable, text, text_input, Space}; use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -20,6 +20,31 @@ pub struct InsertLinkResult { 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 { @@ -60,6 +85,15 @@ pub enum ModalState { media_list: Vec, selected_index: Option, }, + AISuggestions { + target: AiEntityTarget, + fields: Vec, + }, + LanguagePicker { + target: AiEntityTarget, + source_language: String, + available_targets: Vec, + }, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -874,6 +908,119 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele .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)).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 diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 137855d..745cfde 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -322,6 +322,11 @@ impl PostEditorState { /// Post editor messages. #[derive(Debug, Clone)] pub enum PostEditorMsg { + AnalyzeWithAi, + AnalyzeTaxonomy, + DetectLanguage, + Translate, + TranslateTo(String), TitleChanged(String), SlugChanged(String), ExcerptChanged(String), @@ -389,6 +394,18 @@ pub fn view<'a>( .into()], vec![ status_badge(&state.status), + button(text(t(locale, "editor.aiAnalyze")).size(13).shaping(Shaping::Advanced)) + .on_press(Message::PostEditor(PostEditorMsg::AnalyzeWithAi)) + .padding([6, 16]) + .into(), + button(text(t(locale, "editor.suggestTaxonomy")).size(13).shaping(Shaping::Advanced)) + .on_press(Message::PostEditor(PostEditorMsg::AnalyzeTaxonomy)) + .padding([6, 16]) + .into(), + button(text(t(locale, "editor.translate")).size(13).shaping(Shaping::Advanced)) + .on_press(Message::PostEditor(PostEditorMsg::Translate)) + .padding([6, 16]) + .into(), button( text(t(locale, "common.save")) .size(13) @@ -523,6 +540,9 @@ pub fn view<'a>( Some(&state.language), |lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)), ); + let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced)) + .on_press(Message::PostEditor(PostEditorMsg::DetectLanguage)) + .padding([6, 12]); let template_input = inputs::labeled_input( &t(locale, "editor.templateSlug"), "", @@ -534,7 +554,8 @@ pub fn view<'a>( state.do_not_translate, |b| Message::PostEditor(PostEditorMsg::ToggleDoNotTranslate(b)), ); - let meta_row2 = row![author_input, language_input, template_input, dnt] + let language_block = column![language_input, detect_language].spacing(6); + let meta_row2 = row![author_input, language_block, template_input, dnt] .spacing(16) .width(Length::Fill); diff --git a/locales/ui/de.json b/locales/ui/de.json index 9da5374..de8c0a6 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -144,6 +144,8 @@ "engine.uploadOffline": "Hochladen nicht möglich: Flugmodus ist aktiv", "engine.generateSiteStarted": "Seite wird generiert...", "engine.generateSiteNoProject": "Kein aktives Projekt", + "engine.previewDataDirUnavailable": "Projekt-Datenverzeichnis nicht verfügbar", + "engine.previewPortInUse": "Der Port des Vorschau-Servers wird bereits verwendet", "dialog.selectFolder": "Ordner auswählen", "dialog.importMedia": "Medien importieren", "dialog.imageFilter": "Bilder", @@ -193,6 +195,9 @@ "modal.postGallery.close": "Schließen", "modal.postGallery.backToGrid": "Zurück zur Übersicht", "modal.postGallery.unavailable": "Vorschau nicht verfügbar", + "modal.aiSuggestions.title": "KI-Vorschläge", + "modal.aiSuggestions.applySelected": "Auswahl anwenden", + "modal.languagePicker.title": "Übersetzen nach", "sidebar.filter.search": "Suchen...", "sidebar.filter.toggle": "Filter", "sidebar.filter.clearAll": "Alle Filter zurücksetzen", @@ -218,6 +223,10 @@ "editor.excerptPlaceholder": "Kurze Zusammenfassung...", "editor.author": "Autor", "editor.language": "Sprache", + "editor.aiAnalyze": "Mit KI analysieren", + "editor.detectLanguage": "Sprache erkennen", + "editor.suggestTaxonomy": "Taxonomie vorschlagen", + "editor.translate": "Übersetzen", "editor.templateSlug": "Vorlage", "editor.doNotTranslate": "Nicht übersetzen", "editor.publish": "Veröffentlichen", diff --git a/locales/ui/en.json b/locales/ui/en.json index 4b54368..8e338c3 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -144,6 +144,8 @@ "engine.uploadOffline": "Cannot upload: airplane mode is active", "engine.generateSiteStarted": "Generating site...", "engine.generateSiteNoProject": "No active project", + "engine.previewDataDirUnavailable": "Project data directory unavailable", + "engine.previewPortInUse": "Preview server port is already in use", "dialog.selectFolder": "Select Folder", "dialog.importMedia": "Import Media", "dialog.imageFilter": "Images", @@ -193,6 +195,9 @@ "modal.postGallery.close": "Close", "modal.postGallery.backToGrid": "Back to Grid", "modal.postGallery.unavailable": "Preview unavailable", + "modal.aiSuggestions.title": "AI Suggestions", + "modal.aiSuggestions.applySelected": "Apply Selected", + "modal.languagePicker.title": "Translate To", "sidebar.filter.search": "Search...", "sidebar.filter.toggle": "Filters", "sidebar.filter.clearAll": "Clear All Filters", @@ -215,6 +220,10 @@ "editor.excerptPlaceholder": "Brief summary...", "editor.author": "Author", "editor.language": "Language", + "editor.aiAnalyze": "Analyze with AI", + "editor.detectLanguage": "Detect Language", + "editor.suggestTaxonomy": "Suggest Taxonomy", + "editor.translate": "Translate", "editor.templateSlug": "Template", "editor.doNotTranslate": "Do Not Translate", "editor.publish": "Publish", diff --git a/locales/ui/es.json b/locales/ui/es.json index 94f8fd2..0fc1e94 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -144,6 +144,8 @@ "engine.uploadOffline": "No se puede subir: el modo avión está activo", "engine.generateSiteStarted": "Generando sitio...", "engine.generateSiteNoProject": "No hay proyecto activo", + "engine.previewDataDirUnavailable": "El directorio de datos del proyecto no está disponible", + "engine.previewPortInUse": "El puerto del servidor de vista previa ya está en uso", "dialog.selectFolder": "Seleccionar carpeta", "dialog.importMedia": "Importar medios", "dialog.imageFilter": "Imágenes", @@ -193,6 +195,9 @@ "modal.postGallery.close": "Cerrar", "modal.postGallery.backToGrid": "Volver a la cuadrícula", "modal.postGallery.unavailable": "Vista previa no disponible", + "modal.aiSuggestions.title": "Sugerencias de IA", + "modal.aiSuggestions.applySelected": "Aplicar selección", + "modal.languagePicker.title": "Traducir a", "sidebar.filter.search": "Buscar...", "sidebar.filter.toggle": "Filtros", "sidebar.filter.clearAll": "Borrar todos los filtros", @@ -218,6 +223,10 @@ "editor.excerptPlaceholder": "Breve resumen...", "editor.author": "Autor", "editor.language": "Idioma", + "editor.aiAnalyze": "Analizar con IA", + "editor.detectLanguage": "Detectar idioma", + "editor.suggestTaxonomy": "Sugerir taxonomía", + "editor.translate": "Traducir", "editor.templateSlug": "Plantilla", "editor.doNotTranslate": "No traducir", "editor.publish": "Publicar", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index 63a5408..5d71a99 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -144,6 +144,8 @@ "engine.uploadOffline": "Impossible de publier : le mode avion est actif", "engine.generateSiteStarted": "Génération du site...", "engine.generateSiteNoProject": "Aucun projet actif", + "engine.previewDataDirUnavailable": "Le répertoire de données du projet est indisponible", + "engine.previewPortInUse": "Le port du serveur d'aperçu est déjà utilisé", "dialog.selectFolder": "Sélectionner un dossier", "dialog.importMedia": "Importer des médias", "dialog.imageFilter": "Images", @@ -193,6 +195,9 @@ "modal.postGallery.close": "Fermer", "modal.postGallery.backToGrid": "Retour à la grille", "modal.postGallery.unavailable": "Aperçu indisponible", + "modal.aiSuggestions.title": "Suggestions IA", + "modal.aiSuggestions.applySelected": "Appliquer la sélection", + "modal.languagePicker.title": "Traduire vers", "sidebar.filter.search": "Rechercher...", "sidebar.filter.toggle": "Filtres", "sidebar.filter.clearAll": "Effacer tous les filtres", @@ -218,6 +223,10 @@ "editor.excerptPlaceholder": "Bref résumé...", "editor.author": "Auteur", "editor.language": "Langue", + "editor.aiAnalyze": "Analyser avec l'IA", + "editor.detectLanguage": "Détecter la langue", + "editor.suggestTaxonomy": "Suggérer la taxonomie", + "editor.translate": "Traduire", "editor.templateSlug": "Modèle", "editor.doNotTranslate": "Ne pas traduire", "editor.publish": "Publier", diff --git a/locales/ui/it.json b/locales/ui/it.json index 28612f8..136b789 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -144,6 +144,8 @@ "engine.uploadOffline": "Impossibile caricare: la modalità aereo è attiva", "engine.generateSiteStarted": "Generazione del sito...", "engine.generateSiteNoProject": "Nessun progetto attivo", + "engine.previewDataDirUnavailable": "Directory dati del progetto non disponibile", + "engine.previewPortInUse": "La porta del server di anteprima è già in uso", "dialog.selectFolder": "Seleziona cartella", "dialog.importMedia": "Importa media", "dialog.imageFilter": "Immagini", @@ -193,6 +195,9 @@ "modal.postGallery.close": "Chiudi", "modal.postGallery.backToGrid": "Torna alla griglia", "modal.postGallery.unavailable": "Anteprima non disponibile", + "modal.aiSuggestions.title": "Suggerimenti IA", + "modal.aiSuggestions.applySelected": "Applica selezione", + "modal.languagePicker.title": "Traduci in", "sidebar.filter.search": "Cerca...", "sidebar.filter.toggle": "Filtri", "sidebar.filter.clearAll": "Cancella tutti i filtri", @@ -218,6 +223,10 @@ "editor.excerptPlaceholder": "Breve riassunto...", "editor.author": "Autore", "editor.language": "Lingua", + "editor.aiAnalyze": "Analizza con IA", + "editor.detectLanguage": "Rileva lingua", + "editor.suggestTaxonomy": "Suggerisci tassonomia", + "editor.translate": "Traduci", "editor.templateSlug": "Modello", "editor.doNotTranslate": "Non tradurre", "editor.publish": "Pubblica",