From be7bfa97f67b963209feb4a7945c542eaf20be2d Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 8 Apr 2026 16:57:16 +0200 Subject: [PATCH] fix: more finishing work for M3 --- crates/bds-core/src/engine/media.rs | 41 +- crates/bds-core/src/engine/post.rs | 514 ++++++- crates/bds-core/src/engine/post_media.rs | 51 +- crates/bds-editor/src/widget.rs | 40 +- crates/bds-ui/src/app.rs | 1334 +++++++++++++++-- crates/bds-ui/src/views/media_editor.rs | 23 + crates/bds-ui/src/views/mod.rs | 1 - crates/bds-ui/src/views/modal.rs | 690 ++++++++- crates/bds-ui/src/views/post_editor.rs | 478 ++++-- crates/bds-ui/src/views/translation_editor.rs | 154 -- crates/bds-ui/src/views/workspace.rs | 142 +- locales/ui/de.json | 39 + locales/ui/en.json | 39 + locales/ui/es.json | 39 + locales/ui/fr.json | 39 + locales/ui/it.json | 39 + 16 files changed, 3113 insertions(+), 550 deletions(-) delete mode 100644 crates/bds-ui/src/views/translation_editor.rs diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index d40fa64..7569886 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -11,10 +11,12 @@ use crate::db::queries::media_translation as qmt; use crate::db::queries::post_media as qpm; use crate::engine::{EngineError, EngineResult}; use crate::model::{Media, MediaTranslation}; -use crate::util::sidecar::{MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar}; +use crate::util::sidecar::{ + read_sidecar, read_translation_sidecar, MediaSidecar, MediaTranslationSidecar, +}; use crate::util::thumbnail::{ - generate_all_thumbnails, image_dimensions, mime_from_extension, - ThumbnailFormat, THUMBNAIL_SIZES, + generate_all_thumbnails, image_dimensions, mime_from_extension, ThumbnailFormat, + THUMBNAIL_SIZES, }; use crate::util::{ atomic_write_str, content_hash, media_dir_path, media_sidecar_path, @@ -33,9 +35,14 @@ pub struct MediaRebuildReport { /// Supported image MIME types for import (per media_processing.allium). const SUPPORTED_IMAGE_TYPES: &[&str] = &[ - "image/jpeg", "image/png", "image/gif", - "image/webp", "image/tiff", "image/bmp", - "image/heic", "image/heif", + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/tiff", + "image/bmp", + "image/heic", + "image/heif", ]; /// Import a media file (image, etc.) into the project. @@ -184,11 +191,7 @@ pub fn update_media( } /// Delete a media item and all related artifacts. -pub fn delete_media( - conn: &Connection, - data_dir: &Path, - media_id: &str, -) -> EngineResult<()> { +pub fn delete_media(conn: &Connection, data_dir: &Path, media_id: &str) -> EngineResult<()> { let media = qm::get_media_by_id(conn, media_id)?; // Delete binary file @@ -363,10 +366,7 @@ pub fn rebuild_media_from_filesystem_with_progress( let mut canonical_sidecars = Vec::new(); let mut translation_sidecars = Vec::new(); - for entry in WalkDir::new(&media_dir) - .into_iter() - .filter_map(|e| e.ok()) - { + for entry in WalkDir::new(&media_dir).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); if !path.is_file() { continue; @@ -931,7 +931,10 @@ mod tests { delete_media_translation(db.conn(), dir.path(), &media.id, "de").unwrap(); // Sidecar should be gone - assert!(!abs_sidecar.exists(), "translation sidecar should be removed"); + assert!( + !abs_sidecar.exists(), + "translation sidecar should be removed" + ); // DB entry should be gone assert!( @@ -964,7 +967,11 @@ createdAt: 2024-01-15T12:00:00.000Z updatedAt: 2024-01-15T12:00:00.000Z tags: [\"test\"] ---"; - fs::write(media_subdir.join("abcdef12-test-uuid.png.meta"), sidecar_content).unwrap(); + fs::write( + media_subdir.join("abcdef12-test-uuid.png.meta"), + sidecar_content, + ) + .unwrap(); // Write a translation sidecar let trans_sidecar_content = "\ diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 53d0bb0..660e659 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -182,11 +182,7 @@ pub fn update_post( } /// Publish a post: write file, clear content, set published_at. -pub fn publish_post( - conn: &Connection, - data_dir: &Path, - post_id: &str, -) -> EngineResult { +pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult { let mut post = qp::get_post_by_id(conn, post_id)?; // Require Draft or Archived status @@ -211,8 +207,7 @@ pub fn publish_post( c.clone() } else if abs_path.exists() { let file_content = fs::read_to_string(&abs_path)?; - let (_fm, body) = read_post_file(&file_content) - .map_err(|e| EngineError::Parse(e))?; + let (_fm, body) = read_post_file(&file_content).map_err(|e| EngineError::Parse(e))?; body } else { String::new() @@ -235,8 +230,7 @@ pub fn publish_post( // Set published snapshot fields let tags_json = serde_json::to_string(&post.tags).unwrap_or_else(|_| "[]".into()); - let cats_json = - serde_json::to_string(&post.categories).unwrap_or_else(|_| "[]".into()); + let cats_json = serde_json::to_string(&post.categories).unwrap_or_else(|_| "[]".into()); post.published_title = Some(post.title.clone()); post.published_content = Some(body.clone()); post.published_tags = Some(tags_json.clone()); @@ -313,7 +307,8 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine )); } // Reload content from filesystem if transitioning from published (content is NULL) - if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty() { + if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty() + { let abs_path = data_dir.join(&post.file_path); if abs_path.exists() { if let Ok(file_content) = fs::read_to_string(&abs_path) { @@ -329,12 +324,156 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine Ok(()) } -/// Delete a post and all related data. -pub fn delete_post( +/// Discard unpublished draft changes and restore the published version. +pub fn discard_post_draft( conn: &Connection, data_dir: &Path, post_id: &str, -) -> EngineResult<()> { +) -> EngineResult { + let mut post = qp::get_post_by_id(conn, post_id)?; + if post.published_at.is_none() { + return Err(EngineError::Conflict( + "cannot discard changes for a post that was never published".to_string(), + )); + } + + let (title, slug, excerpt, author, language, template_slug, do_not_translate, tags, categories, checksum) = + if !post.file_path.is_empty() { + let abs_path = data_dir.join(&post.file_path); + if abs_path.exists() { + let raw = fs::read_to_string(&abs_path)?; + let (fm, _body) = read_post_file(&raw).map_err(EngineError::Parse)?; + ( + fm.title, + fm.slug, + fm.excerpt, + fm.author, + fm.language, + fm.template_slug, + fm.do_not_translate, + fm.tags, + fm.categories, + Some(content_hash(raw.as_bytes())), + ) + } else { + ( + post.published_title.clone().unwrap_or_else(|| post.title.clone()), + post.slug.clone(), + post.published_excerpt.clone().or_else(|| post.excerpt.clone()), + post.author.clone(), + post.language.clone(), + post.template_slug.clone(), + post.do_not_translate, + post.published_tags + .as_deref() + .and_then(|s| serde_json::from_str::>(s).ok()) + .unwrap_or_else(|| post.tags.clone()), + post.published_categories + .as_deref() + .and_then(|s| serde_json::from_str::>(s).ok()) + .unwrap_or_else(|| post.categories.clone()), + post.checksum.clone(), + ) + } + } else { + ( + post.published_title.clone().unwrap_or_else(|| post.title.clone()), + post.slug.clone(), + post.published_excerpt.clone().or_else(|| post.excerpt.clone()), + post.author.clone(), + post.language.clone(), + post.template_slug.clone(), + post.do_not_translate, + post.published_tags + .as_deref() + .and_then(|s| serde_json::from_str::>(s).ok()) + .unwrap_or_else(|| post.tags.clone()), + post.published_categories + .as_deref() + .and_then(|s| serde_json::from_str::>(s).ok()) + .unwrap_or_else(|| post.categories.clone()), + post.checksum.clone(), + ) + }; + + post.title = title; + post.slug = slug; + post.excerpt = excerpt; + post.author = author; + post.language = language; + post.template_slug = template_slug; + post.do_not_translate = do_not_translate; + post.tags = tags; + post.categories = categories; + post.content = None; + post.status = PostStatus::Published; + post.checksum = checksum; + post.updated_at = now_unix_ms(); + qp::update_post(conn, &post)?; + fts_index_post(conn, &post)?; + Ok(post) +} + +/// Create a new draft post by duplicating an existing post. +pub fn duplicate_post( + conn: &Connection, + data_dir: &Path, + post_id: &str, +) -> EngineResult { + let source = qp::get_post_by_id(conn, post_id)?; + let body = if let Some(ref content) = source.content { + content.clone() + } else if !source.file_path.is_empty() { + let abs_path = data_dir.join(&source.file_path); + if abs_path.exists() { + let raw = fs::read_to_string(&abs_path)?; + let (_fm, body) = read_post_file(&raw).map_err(EngineError::Parse)?; + body + } else { + String::new() + } + } else { + String::new() + }; + + let duplicate_title = if source.title.is_empty() { + "Untitled Copy".to_string() + } else { + format!("{} Copy", source.title) + }; + + let duplicated = create_post( + conn, + data_dir, + &source.project_id, + &duplicate_title, + Some(&body), + source.tags.clone(), + source.categories.clone(), + source.author.as_deref(), + source.language.as_deref(), + source.template_slug.as_deref(), + )?; + + update_post( + conn, + data_dir, + &duplicated.id, + None, + None, + Some(source.excerpt.as_deref()), + None, + None, + None, + None, + None, + None, + Some(source.do_not_translate), + ) +} + +/// Delete a post and all related data. +pub fn delete_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<()> { let post = qp::get_post_by_id(conn, post_id)?; // Delete .md file if exists @@ -507,10 +646,7 @@ pub fn rebuild_posts_from_filesystem_with_progress( let mut canonical_files = Vec::new(); let mut translation_files = Vec::new(); - for entry in WalkDir::new(&posts_dir) - .into_iter() - .filter_map(|e| e.ok()) - { + for entry in WalkDir::new(&posts_dir).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); if !path.is_file() { continue; @@ -659,10 +795,14 @@ fn parse_post_links(content: &str) -> Vec<(String, String)> { let url = &line[url_start..url_start + paren_end]; // Check if URL matches /YYYY/MM/DD/slug pattern let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect(); - if parts.len() == 5 && parts[0].is_empty() - && parts[1].len() == 4 && parts[1].chars().all(|c| c.is_ascii_digit()) - && parts[2].len() == 2 && parts[2].chars().all(|c| c.is_ascii_digit()) - && parts[3].len() == 2 && parts[3].chars().all(|c| c.is_ascii_digit()) + if parts.len() == 5 + && parts[0].is_empty() + && parts[1].len() == 4 + && parts[1].chars().all(|c| c.is_ascii_digit()) + && parts[2].len() == 2 + && parts[2].chars().all(|c| c.is_ascii_digit()) + && parts[3].len() == 2 + && parts[3].chars().all(|c| c.is_ascii_digit()) { links.push((parts[4].to_string(), link_text.to_string())); } @@ -862,8 +1002,7 @@ fn rebuild_translation( path: &Path, ) -> EngineResult { let content = fs::read_to_string(path)?; - let (fm, body) = - read_translation_file(&content).map_err(|e| EngineError::Parse(e))?; + let (fm, body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?; let rel_path = path .strip_prefix(data_dir) @@ -940,6 +1079,44 @@ fn rebuild_translation( } } +// ─────────────────────────────────────────────────────────── +// M3: Editor Actions +// ─────────────────────────────────────────────────────────── + +/// Insert a link to another post in the editor buffer. +/// Returns the Markdown link syntax. +pub fn post_insert_link(slug: &str) -> String { + format!("[title](/YYYY/MM/DD/{slug})") +} + +/// Insert a media reference in the editor buffer. +/// Returns the Markdown syntax: ![alt](bds-media://id) or [name](bds-media://id) +pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) -> String { + if is_image { + format!( + "![]({bds_media_url})", + bds_media_url = format!("bds-media://{media_id}") + ) + } else { + format!( + "[{original_name}]({bds_media_url})", + bds_media_url = format!("bds-media://{media_id}") + ) + } +} + +/// Get posts linked from a given post (outlinks). +pub fn list_post_outlinks(conn: &Connection, post_id: &str) -> EngineResult> { + let links = ql::list_links_by_source(conn, post_id)?; + Ok(links.into_iter().map(|l| l.target_post_id).collect()) +} + +/// Get posts linking to a given post (backlinks). +pub fn list_post_backlinks(conn: &Connection, post_id: &str) -> EngineResult> { + let links = ql::list_links_by_target(conn, post_id)?; + Ok(links.into_iter().map(|l| l.source_post_id).collect()) +} + #[cfg(test)] mod tests { use super::*; @@ -1181,6 +1358,103 @@ mod tests { assert!(file_content.contains("Publish Me")); } + #[test] + fn discard_post_draft_restores_published_state() { + let (db, dir) = setup(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Discard Me", + Some("published body"), + vec!["one".into()], + vec!["cat".into()], + Some("Alice"), + Some("en"), + None, + ) + .unwrap(); + + let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let updated = update_post( + db.conn(), + dir.path(), + &post.id, + Some("Changed Title"), + None, + Some(Some("changed excerpt")), + Some("draft body"), + Some(vec!["two".into()]), + Some(vec!["other".into()]), + Some(Some("Bob")), + Some(Some("de")), + None, + Some(true), + ) + .unwrap(); + + assert_eq!(updated.status, PostStatus::Draft); + assert_eq!(updated.content.as_deref(), Some("draft body")); + + let discarded = discard_post_draft(db.conn(), dir.path(), &post.id).unwrap(); + assert_eq!(discarded.status, PostStatus::Published); + assert_eq!(discarded.title, published.title); + assert_eq!(discarded.excerpt, published.published_excerpt); + assert_eq!(discarded.tags, vec!["one"]); + assert_eq!(discarded.categories, vec!["cat"]); + assert_eq!(discarded.content, None); + assert_eq!(discarded.language.as_deref(), Some("en")); + assert!(!discarded.do_not_translate); + } + + #[test] + fn duplicate_post_creates_new_draft_copy() { + let (db, dir) = setup(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Original", + Some("body text"), + vec!["rust".into()], + vec!["guide".into()], + Some("Alice"), + Some("en"), + None, + ) + .unwrap(); + let source = update_post( + db.conn(), + dir.path(), + &post.id, + None, + None, + Some(Some("excerpt")), + None, + None, + None, + None, + None, + None, + Some(true), + ) + .unwrap(); + + let duplicate = duplicate_post(db.conn(), dir.path(), &post.id).unwrap(); + + assert_ne!(duplicate.id, source.id); + assert_eq!(duplicate.status, PostStatus::Draft); + assert_eq!(duplicate.title, "Original Copy"); + assert_eq!(duplicate.content.as_deref(), Some("body text")); + assert_eq!(duplicate.tags, source.tags); + assert_eq!(duplicate.categories, source.categories); + assert_eq!(duplicate.author, source.author); + assert_eq!(duplicate.language, source.language); + assert_eq!(duplicate.excerpt, source.excerpt); + assert!(duplicate.do_not_translate); + assert!(duplicate.slug.starts_with("original-copy")); + } + #[test] fn publish_preserves_published_at_on_republish() { let (db, dir) = setup(); @@ -1388,8 +1662,7 @@ mod tests { fs::write(posts_dir.join("rebuilt-post.de.md"), trans_content).unwrap(); // Run rebuild - let report = - rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap(); + let report = rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap(); assert_eq!(report.posts_created, 1); assert_eq!(report.translations_created, 1); @@ -1402,17 +1675,13 @@ mod tests { assert_eq!(post.tags, vec!["test"]); // Verify translation in DB - let trans = qt::get_post_translation_by_post_and_language( - db.conn(), - "rebuild-post-1", - "de", - ) - .unwrap(); + let trans = + qt::get_post_translation_by_post_and_language(db.conn(), "rebuild-post-1", "de") + .unwrap(); assert_eq!(trans.title, "Wiederhergestellter Beitrag"); // Run rebuild again - should update, not create - let report2 = - rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap(); + let report2 = rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap(); assert_eq!(report2.posts_created, 0); assert_eq!(report2.posts_updated, 1); assert_eq!(report2.translations_created, 0); @@ -1436,17 +1705,44 @@ mod tests { fn do_not_translate_guard_rejects_translation() { let (db, dir) = setup(); let post = create_post( - db.conn(), dir.path(), "p1", "No Translate", Some("body"), - vec![], vec![], None, None, None, - ).unwrap(); + db.conn(), + dir.path(), + "p1", + "No Translate", + Some("body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); // Set do_not_translate update_post( - db.conn(), dir.path(), &post.id, - None, None, None, None, None, None, None, None, None, Some(true), - ).unwrap(); + db.conn(), + dir.path(), + &post.id, + None, + None, + None, + None, + None, + None, + None, + None, + None, + Some(true), + ) + .unwrap(); let result = upsert_translation( - db.conn(), dir.path(), &post.id, "de", "German", None, Some("Inhalt"), + db.conn(), + dir.path(), + &post.id, + "de", + "German", + None, + Some("Inhalt"), ); assert!(result.is_err()); match result.unwrap_err() { @@ -1459,17 +1755,46 @@ mod tests { fn update_post_slug_uniqueness_enforced() { let (db, dir) = setup(); create_post( - db.conn(), dir.path(), "p1", "First", Some("body"), - vec![], vec![], None, None, None, - ).unwrap(); + db.conn(), + dir.path(), + "p1", + "First", + Some("body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); let second = create_post( - db.conn(), dir.path(), "p1", "Second", Some("body"), - vec![], vec![], None, None, None, - ).unwrap(); + db.conn(), + dir.path(), + "p1", + "Second", + Some("body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); let result = update_post( - db.conn(), dir.path(), &second.id, - None, Some("first"), None, None, None, None, None, None, None, None, + db.conn(), + dir.path(), + &second.id, + None, + Some("first"), + None, + None, + None, + None, + None, + None, + None, + None, ); assert!(result.is_err()); } @@ -1478,9 +1803,18 @@ mod tests { fn update_published_post_transitions_to_draft() { let (db, dir) = setup(); let post = create_post( - db.conn(), dir.path(), "p1", "Published", Some("body"), - vec![], vec![], None, None, None, - ).unwrap(); + db.conn(), + dir.path(), + "p1", + "Published", + Some("body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap(); // Archive then update to test auto-draft @@ -1490,9 +1824,21 @@ mod tests { // Now update the published post let updated = update_post( - db.conn(), dir.path(), &post.id, - Some("New Title"), None, None, Some("new body"), None, None, None, None, None, None, - ).unwrap(); + db.conn(), + dir.path(), + &post.id, + Some("New Title"), + None, + None, + Some("new body"), + None, + None, + None, + None, + None, + None, + ) + .unwrap(); assert_eq!(updated.status, PostStatus::Draft); } @@ -1507,9 +1853,18 @@ mod tests { fn publish_post_already_published_rejected() { let (db, dir) = setup(); let post = create_post( - db.conn(), dir.path(), "p1", "Double Pub", Some("body"), - vec![], vec![], None, None, None, - ).unwrap(); + db.conn(), + dir.path(), + "p1", + "Double Pub", + Some("body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap(); let result = publish_post(db.conn(), dir.path(), &post.id); @@ -1525,18 +1880,36 @@ mod tests { let (db, dir) = setup(); // Create target post first let target = create_post( - db.conn(), dir.path(), "p1", "Target Post", Some("target body"), - vec![], vec![], None, None, None, - ).unwrap(); + db.conn(), + dir.path(), + "p1", + "Target Post", + Some("target body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); publish_post(db.conn(), dir.path(), &target.id).unwrap(); // Create source post with a link to target let target_url = canonical_url(target.created_at, &target.slug); let body = format!("Check out [this post]({target_url}) for more."); let source = create_post( - db.conn(), dir.path(), "p1", "Source Post", Some(&body), - vec![], vec![], None, None, None, - ).unwrap(); + db.conn(), + dir.path(), + "p1", + "Source Post", + Some(&body), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); publish_post(db.conn(), dir.path(), &source.id).unwrap(); // Verify link was created @@ -1549,9 +1922,18 @@ mod tests { fn archive_from_published_status() { let (db, dir) = setup(); let post = create_post( - db.conn(), dir.path(), "p1", "To Archive", Some("body"), - vec![], vec![], None, None, None, - ).unwrap(); + db.conn(), + dir.path(), + "p1", + "To Archive", + Some("body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap(); archive_post(db.conn(), dir.path(), &post.id).unwrap(); diff --git a/crates/bds-core/src/engine/post_media.rs b/crates/bds-core/src/engine/post_media.rs index 21288e9..5a8fcbf 100644 --- a/crates/bds-core/src/engine/post_media.rs +++ b/crates/bds-core/src/engine/post_media.rs @@ -4,9 +4,10 @@ use rusqlite::Connection; use uuid::Uuid; use crate::db::queries::media as qm; +use crate::db::queries::post as qp; use crate::db::queries::post_media as qpm; use crate::engine::EngineResult; -use crate::model::PostMedia; +use crate::model::{Media, Post, PostMedia}; use crate::util::sidecar::MediaSidecar; use crate::util::{atomic_write_str, now_unix_ms}; @@ -59,6 +60,30 @@ pub fn reorder_post_media( Ok(()) } +/// List media items currently linked to a post. +pub fn list_media_for_post(conn: &Connection, post_id: &str) -> EngineResult> { + let links = qpm::list_post_media_by_post(conn, post_id)?; + let mut media = Vec::with_capacity(links.len()); + for link in links { + if let Ok(item) = qm::get_media_by_id(conn, &link.media_id) { + media.push(item); + } + } + Ok(media) +} + +/// List posts currently linked to a media item. +pub fn list_posts_for_media(conn: &Connection, media_id: &str) -> EngineResult> { + let links = qpm::list_post_media_by_media(conn, media_id)?; + let mut posts = Vec::with_capacity(links.len()); + for link in links { + if let Ok(post) = qp::get_post_by_id(conn, &link.post_id) { + posts.push(post); + } + } + Ok(posts) +} + /// Rebuild the media sidecar file so that `linkedPostIds` reflects the current /// set of posts linked to this media item. fn sync_sidecar_linked_post_ids( @@ -193,4 +218,28 @@ mod tests { assert!(ids.contains(&"post1".to_string())); assert!(ids.contains(&"post2".to_string())); } + + #[test] + fn list_media_for_post_returns_resolved_media() { + let (db, dir) = setup(); + insert_test_media(&db, dir.path(), "m1"); + + link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap(); + + let media = list_media_for_post(db.conn(), "post1").unwrap(); + assert_eq!(media.len(), 1); + assert_eq!(media[0].id, "m1"); + } + + #[test] + fn list_posts_for_media_returns_resolved_posts() { + let (db, dir) = setup(); + insert_test_media(&db, dir.path(), "m1"); + + link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap(); + + let posts = list_posts_for_media(db.conn(), "m1").unwrap(); + assert_eq!(posts.len(), 1); + assert_eq!(posts[0].id, "post1"); + } } diff --git a/crates/bds-editor/src/widget.rs b/crates/bds-editor/src/widget.rs index 6fbca7e..8cd6921 100644 --- a/crates/bds-editor/src/widget.rs +++ b/crates/bds-editor/src/widget.rs @@ -92,6 +92,19 @@ const SCROLLBAR_THUMB: Color = Color::from_rgba(0.50, 0.53, 0.60, 0.7); const SCROLLBAR_THUMB_HOVER: Color = Color::from_rgba(0.60, 0.63, 0.70, 0.9); const MIN_THUMB_HEIGHT: f32 = 20.0; +fn committed_text_input<'a>(text: Option<&'a str>, is_command_shortcut: bool) -> Option<&'a str> { + if is_command_shortcut { + return None; + } + + let text = text?; + if text.is_empty() || !text.chars().any(|character| !character.is_control()) { + return None; + } + + Some(text) +} + /// Convert syntect RGBA color to Iced Color. fn syntect_to_iced(c: syntect::highlighting::Color) -> Color { Color::from_rgba( @@ -761,12 +774,13 @@ where return Status::Captured; } Event::Keyboard(keyboard::Event::KeyPressed { - key, modifiers, .. + key, modifiers, text, .. }) if state.is_focused => { let vis = (bounds.height / metrics.line_height) as usize; let is_cmd = modifiers.command(); let is_shift = modifiers.shift(); let is_alt = modifiers.alt(); + let committed_text = committed_text_input(text.as_deref(), is_cmd); // Helper: ensure cursor visible accounting for word wrap. // Must be called while buf is still borrowed mutably. @@ -953,10 +967,10 @@ where } self.emit_change(shell); } - keyboard::Key::Character(ref c) if !is_cmd => { + _ if committed_text.is_some() => { { let mut buf = self.buffer.borrow_mut(); - buf.insert(c); + buf.insert(committed_text.expect("committed text already checked")); ensure_vis!(buf); } self.emit_change(shell); @@ -980,6 +994,26 @@ impl<'a, Message> CodeEditor<'a, Message> { } } +#[cfg(test)] +mod tests { + use super::committed_text_input; + + #[test] + fn committed_text_input_accepts_regular_and_ime_text() { + assert_eq!(committed_text_input(Some("a"), false), Some("a")); + assert_eq!(committed_text_input(Some("é"), false), Some("é")); + assert_eq!(committed_text_input(Some("にほん"), false), Some("にほん")); + } + + #[test] + fn committed_text_input_ignores_shortcuts_and_control_only_text() { + assert_eq!(committed_text_input(Some("s"), true), None); + assert_eq!(committed_text_input(Some("\n"), false), None); + assert_eq!(committed_text_input(Some("\u{7f}"), false), None); + assert_eq!(committed_text_input(None, false), None); + } +} + impl<'a, Message, Renderer> From> for Element<'a, Message, Theme, Renderer> where Renderer: renderer::Renderer + text::Renderer + 'a, diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 5e3240f..dec59e0 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -8,7 +8,7 @@ use bds_core::db::Database; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; use bds_core::engine; use bds_core::i18n::{detect_os_locale, UiLocale}; -use bds_core::model::{Media, Post, Project, Script, Template}; +use bds_core::model::{Media, Post, Project, PublishingPreferences, Script, SshMode, Template}; use crate::i18n::{t, tw}; use crate::platform::menu::{self, MenuAction, MenuRegistry}; @@ -22,14 +22,13 @@ use crate::state::tabs::{self, Tab, TabType}; use crate::state::toast::{Toast, ToastLevel}; use crate::views::{ modal, workspace, - post_editor::{PostEditorState, PostEditorMsg, ResolvedPostLink}, + post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink}, media_editor::{MediaEditorState, MediaEditorMsg}, template_editor::{TemplateEditorState, TemplateEditorMsg}, script_editor::{ScriptEditorState, ScriptEditorMsg}, tags_view::{self, TagsViewState, TagsMsg}, settings_view::{SettingsViewState, SettingsMsg}, dashboard::DashboardState, - translation_editor::{TranslationEditorState, TranslationEditorMsg}, }; // ─────────────────────────────────────────────────────────── @@ -130,7 +129,6 @@ pub enum Message { ScriptEditor(ScriptEditorMsg), Tags(TagsMsg), Settings(SettingsMsg), - TranslationEditor(TranslationEditorMsg), // Editor data loading PostLoaded(Result), @@ -236,7 +234,6 @@ pub struct BdsApp { media_editors: HashMap, template_editors: HashMap, script_editors: HashMap, - translation_editors: HashMap, tags_view_state: Option, settings_state: Option, dashboard_state: Option, @@ -362,7 +359,6 @@ impl BdsApp { media_editors: HashMap::new(), template_editors: HashMap::new(), script_editors: HashMap::new(), - translation_editors: HashMap::new(), tags_view_state: None, settings_state: None, dashboard_state: None, @@ -952,27 +948,47 @@ impl BdsApp { modal::ConfirmAction::DeleteProject(id) => { Task::done(Message::DeleteProject(id)) } - modal::ConfirmAction::DeletePost(_id) => { - // Post deletion will be implemented in M3 editors - Task::none() - } - modal::ConfirmAction::DeleteMedia(_id) => { - Task::none() - } - modal::ConfirmAction::DeleteScript(_id) => { - Task::none() - } - modal::ConfirmAction::DeleteTemplate(_id) => { - Task::none() - } - modal::ConfirmAction::MergeTags { .. } => { - Task::none() + modal::ConfirmAction::DeletePost(id) => self.delete_post_editor(&id), + modal::ConfirmAction::DeleteMedia(id) => self.delete_media_editor(&id), + modal::ConfirmAction::DeleteScript(id) => self.delete_script_editor(&id), + modal::ConfirmAction::DeleteTemplate(id) => self.delete_template_editor(&id), + modal::ConfirmAction::DeleteTag(id) => self.delete_tag(&id), + modal::ConfirmAction::MergeTags { source, target } => { + self.merge_tags(&source, &target) } } } // ── Editor view messages ── Message::PostEditor(msg) => { + enum DeferredPostAction { + None, + Save(String), + Publish(String), + Duplicate(String), + Discard(String), + ShowDelete { tab_id: String, name: String }, + OpenInsertLink(String), + OpenInsertMedia { post_id: String, link_only: bool }, + OpenGallery(String), + OpenLinkedMedia(String), + UnlinkLinkedMedia { post_id: String, media_id: String }, + InsertSelectedLink { post_id: String, linked_post_id: String }, + CreateLinkedPost(String), + InsertSelectedMedia { post_id: String, media_id: String }, + SetLinkTab(modal::PostInsertLinkTab), + SetLinkSearch(String), + SetExternalUrl(String), + SetExternalText(String), + InsertExternalLink, + SetMediaSearch(String), + SelectGalleryImage(usize), + GalleryPrevious, + GalleryNext, + GalleryCloseLightbox, + } + + let mut deferred = DeferredPostAction::None; if let Some(tab_id) = self.active_tab.clone() { if let Some(state) = self.post_editors.get_mut(&tab_id) { match msg { @@ -984,6 +1000,7 @@ impl BdsApp { state.is_dirty = true; } PostEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; } + PostEditorMsg::LanguageChanged(s) => { state.language = s; state.is_dirty = true; } PostEditorMsg::TemplateSlugChanged(s) => { state.template_slug = s; state.is_dirty = true; } PostEditorMsg::ToggleDoNotTranslate(b) => { state.do_not_translate = b; state.is_dirty = true; } PostEditorMsg::ToggleMetadata => { state.metadata_expanded = !state.metadata_expanded; } @@ -1016,27 +1033,216 @@ impl BdsApp { state.is_dirty = true; } PostEditorMsg::Save => { - return self.save_post_editor(&tab_id); + deferred = DeferredPostAction::Save(tab_id.clone()); } PostEditorMsg::Publish => { - return self.publish_post_editor(&tab_id); + deferred = DeferredPostAction::Publish(tab_id.clone()); + } + PostEditorMsg::Duplicate => { + deferred = DeferredPostAction::Duplicate(tab_id.clone()); + } + PostEditorMsg::Discard => { + deferred = DeferredPostAction::Discard(tab_id.clone()); } PostEditorMsg::Delete => { - let name = state.title.clone(); - return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete { - entity_name: name, - references: Vec::new(), - on_confirm: modal::ConfirmAction::DeletePost(tab_id), - })); + deferred = DeferredPostAction::ShowDelete { + tab_id: tab_id.clone(), + name: state.title.clone(), + }; } - } - // Mark tab dirty - if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == *state.post_id.as_str()) { + PostEditorMsg::InsertLink => { + deferred = DeferredPostAction::OpenInsertLink(state.post_id.clone()); + } + PostEditorMsg::InsertMedia => { + deferred = DeferredPostAction::OpenInsertMedia { + post_id: state.post_id.clone(), + link_only: false, + }; + } + PostEditorMsg::Gallery => { + deferred = DeferredPostAction::OpenGallery(state.post_id.clone()); + } + PostEditorMsg::LinkExistingMedia => { + deferred = DeferredPostAction::OpenInsertMedia { + post_id: state.post_id.clone(), + link_only: true, + }; + } + PostEditorMsg::OpenLinkedMedia(media_id) => { + deferred = DeferredPostAction::OpenLinkedMedia(media_id); + } + PostEditorMsg::UnlinkLinkedMedia(media_id) => { + deferred = DeferredPostAction::UnlinkLinkedMedia { + post_id: state.post_id.clone(), + media_id, + }; + } + PostEditorMsg::PostInsertLinkSelected(linked_post_id) => { + deferred = DeferredPostAction::InsertSelectedLink { + post_id: state.post_id.clone(), + linked_post_id, + }; + } + PostEditorMsg::PostInsertLinkCreate => { + deferred = DeferredPostAction::CreateLinkedPost(state.post_id.clone()); + } + PostEditorMsg::PostInsertMediaSelected(media_id) => { + deferred = DeferredPostAction::InsertSelectedMedia { + post_id: state.post_id.clone(), + media_id, + }; + } + PostEditorMsg::PostGalleryImageSelected(index) => { + deferred = DeferredPostAction::SelectGalleryImage(index); + } + PostEditorMsg::PostInsertLinkTabSwitch(tab) => { + deferred = DeferredPostAction::SetLinkTab(tab); + } + PostEditorMsg::PostInsertLinkSearch(query) => { + deferred = DeferredPostAction::SetLinkSearch(query); + } + PostEditorMsg::PostInsertLinkUrlChanged(url) => { + deferred = DeferredPostAction::SetExternalUrl(url); + } + PostEditorMsg::PostInsertLinkTextChanged(text) => { + deferred = DeferredPostAction::SetExternalText(text); + } + PostEditorMsg::PostInsertLinkExternalInsert => { + deferred = DeferredPostAction::InsertExternalLink; + } + PostEditorMsg::PostInsertMediaSearch(query) => { + deferred = DeferredPostAction::SetMediaSearch(query); + } + PostEditorMsg::PostGalleryPrevious => { + deferred = DeferredPostAction::GalleryPrevious; + } + PostEditorMsg::PostGalleryNext => { + deferred = DeferredPostAction::GalleryNext; + } + PostEditorMsg::PostGalleryCloseLightbox => { + deferred = DeferredPostAction::GalleryCloseLightbox; + } + } + + if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == state.post_id) { tab.is_dirty = state.is_dirty; } } } - Task::none() + + match deferred { + DeferredPostAction::None => Task::none(), + 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), + DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id), + DeferredPostAction::ShowDelete { tab_id, name } => Task::done(Message::ShowModal( + modal::ModalState::ConfirmDelete { + entity_name: name, + references: Vec::new(), + on_confirm: modal::ConfirmAction::DeletePost(tab_id), + }, + )), + DeferredPostAction::OpenInsertLink(post_id) => self.insert_link_modal(&post_id), + DeferredPostAction::OpenInsertMedia { post_id, link_only } => { + self.insert_media_modal(&post_id, link_only) + } + DeferredPostAction::OpenGallery(post_id) => self.post_gallery(&post_id), + DeferredPostAction::OpenLinkedMedia(media_id) => { + let title = self + .db + .as_ref() + .and_then(|db| { + bds_core::db::queries::media::get_media_by_id(db.conn(), &media_id) + .ok() + .map(|media| media.title.unwrap_or(media.original_name)) + }) + .unwrap_or_else(|| media_id.clone()); + Task::done(Message::OpenTab(Tab { + id: media_id, + tab_type: TabType::Media, + title, + is_transient: false, + is_dirty: false, + })) + } + DeferredPostAction::UnlinkLinkedMedia { post_id, media_id } => { + if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) { + if let Err(err) = engine::post_media::unlink_media_from_post( + db.conn(), + data_dir, + &post_id, + &media_id, + ) { + self.notify(ToastLevel::Error, &format!("Failed to unlink media: {err}")); + return Task::none(); + } + self.refresh_post_relationships(&post_id); + } + Task::none() + } + DeferredPostAction::InsertSelectedLink { post_id, linked_post_id } => { + self.insert_selected_post_link(&post_id, &linked_post_id) + } + DeferredPostAction::CreateLinkedPost(post_id) => self.insert_created_post_link(&post_id), + DeferredPostAction::InsertSelectedMedia { post_id, media_id } => { + self.insert_selected_media(&post_id, &media_id) + } + DeferredPostAction::SetLinkTab(tab) => { + self.refresh_post_insert_link_modal(Some(tab), None, None, None); + Task::none() + } + DeferredPostAction::SetLinkSearch(query) => { + self.refresh_post_insert_link_modal(None, Some(query), None, None); + Task::none() + } + DeferredPostAction::SetExternalUrl(url) => { + self.refresh_post_insert_link_modal(None, None, Some(url), None); + Task::none() + } + DeferredPostAction::SetExternalText(text) => { + self.refresh_post_insert_link_modal(None, None, None, Some(text)); + Task::none() + } + DeferredPostAction::InsertExternalLink => { + if let Some(modal::ModalState::PostInsertLink { + post_id, + external_url, + external_text, + .. + }) = self.active_modal.clone() + { + if let Some(markdown) = modal::external_link_markdown(&external_url, &external_text) { + self.insert_markdown_into_post(&post_id, &markdown) + } else { + self.notify(ToastLevel::Error, &t(self.ui_locale, "modal.postInsertLink.urlRequired")); + Task::none() + } + } else { + Task::none() + } + } + DeferredPostAction::SetMediaSearch(query) => { + self.refresh_insert_media_modal(query); + Task::none() + } + DeferredPostAction::SelectGalleryImage(index) => { + self.update_gallery_selection(Some(index)); + Task::none() + } + DeferredPostAction::GalleryPrevious => { + self.step_gallery_selection(-1); + Task::none() + } + DeferredPostAction::GalleryNext => { + self.step_gallery_selection(1); + Task::none() + } + DeferredPostAction::GalleryCloseLightbox => { + self.update_gallery_selection(None); + Task::none() + } + } } Message::MediaEditor(msg) => { if let Some(tab_id) = self.active_tab.clone() { @@ -1046,6 +1252,8 @@ impl BdsApp { MediaEditorMsg::AltChanged(s) => { state.alt = s; state.is_dirty = true; } MediaEditorMsg::CaptionChanged(s) => { state.caption = s; state.is_dirty = true; } MediaEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; } + MediaEditorMsg::LanguageChanged(s) => { state.language = s; state.is_dirty = true; } + MediaEditorMsg::TagsChanged(s) => { state.tags_input = s; state.is_dirty = true; } MediaEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); } MediaEditorMsg::Save => { return self.save_media_editor(&tab_id); @@ -1159,21 +1367,6 @@ impl BdsApp { Message::Settings(msg) => { self.handle_settings_msg(msg) } - Message::TranslationEditor(msg) => { - if let Some(tab_id) = self.active_tab.clone() { - if let Some(state) = self.translation_editors.get_mut(&tab_id) { - match msg { - TranslationEditorMsg::TitleChanged(s) => { state.title = s; state.is_dirty = true; } - TranslationEditorMsg::ExcerptChanged(s) => { state.excerpt = s; state.is_dirty = true; } - TranslationEditorMsg::ContentChanged(s) => { state.content = s; state.is_dirty = true; } - TranslationEditorMsg::Save | TranslationEditorMsg::Publish | TranslationEditorMsg::Delete => { - // Translation save/publish/delete will be wired to engine in future - } - } - } - } - Task::none() - } // ── Editor data loading ── Message::PostLoaded(result) => { @@ -1205,7 +1398,15 @@ impl BdsApp { } } let (outlinks, backlinks) = self.load_post_links(&post.id); - let state = PostEditorState::from_post(&post, &self.blog_languages, &translations, outlinks, backlinks); + let linked_media = self.load_post_media_items(&post.id); + let state = PostEditorState::from_post( + &post, + &self.blog_languages, + &translations, + outlinks, + backlinks, + linked_media, + ); self.post_editors.insert(post.id.clone(), state); } Err(e) => self.notify(ToastLevel::Error, &e), @@ -1294,7 +1495,7 @@ impl BdsApp { &self.theme_badge, self.ui_locale, &self.toasts, - self.active_modal.as_ref(), + self.active_modal.clone(), self.data_dir.as_deref(), &self.post_editors, &self.media_editors, @@ -1916,49 +2117,18 @@ impl BdsApp { // ── Editor save/publish helpers ── fn save_post_editor(&mut self, post_id: &str) -> Task { - let Some(state) = self.post_editors.get(post_id) else { return Task::none() }; - let Some(ref db) = self.db else { return Task::none() }; - let Some(ref data_dir) = self.data_dir else { return Task::none() }; - - let excerpt_val = if state.excerpt.is_empty() { None } else { Some(state.excerpt.as_str()) }; - let author_val = if state.author.is_empty() { None } else { Some(state.author.as_str()) }; - let tmpl_val = if state.template_slug.is_empty() { None } else { Some(state.template_slug.as_str()) }; - - match engine::post::update_post( - db.conn(), - data_dir, - &state.post_id, - Some(&state.title), - Some(&state.slug), - Some(excerpt_val), - Some(&state.content), - None, // tags - None, // categories - Some(author_val), - None, // language - Some(tmpl_val), - Some(state.do_not_translate), - ) { - Ok(post) => { - let s = self.post_editors.get_mut(post_id).unwrap(); - s.is_dirty = false; - s.updated_at = post.updated_at; - if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == post.id) { - tab.is_dirty = false; - if !post.title.is_empty() { - tab.title = post.title.clone(); - } - } - self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); - } - Err(e) => { - self.notify(ToastLevel::Error, &format!("Save failed: {e}")); - } + match self.persist_post_editor_state(post_id) { + Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), + Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), } Task::none() } fn publish_post_editor(&mut self, post_id: &str) -> Task { + if let Err(e) = self.persist_post_editor_state(post_id) { + self.notify(ToastLevel::Error, &format!("Publish failed: {e}")); + return Task::none(); + } let Some(ref db) = self.db else { return Task::none() }; let Some(ref data_dir) = self.data_dir else { return Task::none() }; match engine::post::publish_post(db.conn(), data_dir, post_id) { @@ -1966,6 +2136,12 @@ impl BdsApp { if let Some(s) = self.post_editors.get_mut(post_id) { s.status = post.status.clone(); s.is_dirty = false; + s.updated_at = post.updated_at; + s.published_at = post.published_at; + for draft in s.translation_drafts.values_mut() { + draft.status = bds_core::model::PostStatus::Published; + draft.is_dirty = false; + } } self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.published")); } @@ -1976,46 +2152,315 @@ impl BdsApp { Task::none() } - fn save_media_editor(&mut self, media_id: &str) -> Task { - let Some(state) = self.media_editors.get(media_id) else { return Task::none() }; - let Some(ref db) = self.db else { return Task::none() }; - - // Build a Media struct from editor state for the update call - let media = bds_core::model::Media { - id: state.media_id.clone(), - project_id: self.active_project.as_ref().map(|p| p.id.clone()).unwrap_or_default(), - filename: state.filename.clone(), - original_name: state.original_name.clone(), - mime_type: state.mime_type.clone(), - size: state.size, - width: state.width, - height: state.height, - title: Some(state.title.clone()), - alt: Some(state.alt.clone()), - caption: Some(state.caption.clone()), - author: Some(state.author.clone()), - language: if state.language.is_empty() { None } else { Some(state.language.clone()) }, - file_path: state.file_path.clone(), - sidecar_path: String::new(), - checksum: None, - tags: state.tags.clone(), - created_at: state.created_at, - updated_at: state.updated_at, + fn insert_link_modal(&mut self, post_id: &str) -> Task { + let state = match self.post_editors.get(post_id) { + Some(s) => s.clone(), + None => return Task::none(), }; - match bds_core::db::queries::media::update_media(db.conn(), &media) { - Ok(()) => { - let s = self.media_editors.get_mut(media_id).unwrap(); - s.is_dirty = false; - if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == media.id) { - tab.is_dirty = false; - } - self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + self.active_modal = Some(modal::ModalState::PostInsertLink { + post_id: post_id.to_string(), + title: state.title, + results: self.query_post_link_results(post_id, ""), + search_query: String::new(), + active_tab: modal::PostInsertLinkTab::Internal, + external_url: String::new(), + external_text: String::new(), + }); + Task::none() + } + + fn insert_media_modal(&mut self, post_id: &str, link_only: bool) -> Task { + let state = match self.post_editors.get(post_id) { + Some(s) => s.clone(), + None => return Task::none(), + }; + + self.active_modal = Some(modal::ModalState::InsertMedia { + post_id: post_id.to_string(), + title: state.title, + media_list: self.query_post_insert_media_results(""), + search_query: String::new(), + link_only, + }); + Task::none() + } + + fn post_gallery(&mut self, post_id: &str) -> Task { + let state = match self.post_editors.get(post_id) { + Some(s) => s.clone(), + None => return Task::none(), + }; + + let media_list = if let Some(ref db) = self.db { + engine::post_media::list_media_for_post(db.conn(), post_id).unwrap_or_default() + } else { + Vec::new() + }; + + self.active_modal = Some(modal::ModalState::PostGallery { + post_id: post_id.to_string(), + title: state.title, + media_list, + selected_index: None, + }); + Task::none() + } + + fn query_post_link_results(&self, current_post_id: &str, search_query: &str) -> Vec { + let (Some(db), Some(project)) = (&self.db, &self.active_project) else { + return Vec::new(); + }; + let query = search_query.trim(); + if query.chars().count() < 2 { + return Vec::new(); + } + + let filters = bds_core::db::fts::PostSearchFilters { + limit: Some(20), + ..Default::default() + }; + + let ids = bds_core::db::fts::search_posts_filtered( + db.conn(), + query, + &self.content_language, + &filters, + ) + .map(|results| results.post_ids) + .unwrap_or_default(); + + ids.into_iter() + .filter_map(|post_id| bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()) + .filter(|post| post.project_id == project.id && post.id != current_post_id) + .map(|post| modal::InsertLinkResult { + post_id: post.id, + title: post.title, + status: match post.status { + bds_core::model::PostStatus::Draft => "draft".to_string(), + bds_core::model::PostStatus::Published => "published".to_string(), + bds_core::model::PostStatus::Archived => "archived".to_string(), + }, + canonical_url: bds_core::engine::post::canonical_url(post.created_at, &post.slug), + }) + .collect() + } + + fn query_post_insert_media_results(&self, search_query: &str) -> Vec { + let (Some(db), Some(project)) = (&self.db, &self.active_project) else { + return Vec::new(); + }; + + let filters = bds_core::db::queries::media::MediaFilterParams { + search_query: search_query.trim().to_string(), + ..Default::default() + }; + + bds_core::db::queries::media::list_media_filtered(db.conn(), &project.id, &filters, 24, 0) + .unwrap_or_default() + } + + fn insert_markdown_into_post(&mut self, post_id: &str, markdown: &str) -> Task { + let Some(state) = self.post_editors.get_mut(post_id) else { return Task::none() }; + state.insert_markdown_at_cursor(markdown); + if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == post_id) { + tab.is_dirty = true; + } + self.active_modal = None; + self.save_post_editor(post_id) + } + + fn insert_selected_post_link(&mut self, post_id: &str, linked_post_id: &str) -> Task { + let Some(ref db) = self.db else { return Task::none() }; + let Ok(linked_post) = bds_core::db::queries::post::get_post_by_id(db.conn(), linked_post_id) else { + self.notify(ToastLevel::Error, &t(self.ui_locale, "modal.postInsertLink.loadFailed")); + return Task::none(); + }; + + let markdown = bds_core::engine::post::post_insert_link(&linked_post.slug) + .replacen("title", &linked_post.title, 1); + self.insert_markdown_into_post(post_id, &markdown) + } + + fn insert_created_post_link(&mut self, post_id: &str) -> Task { + let Some(modal::ModalState::PostInsertLink { search_query, .. }) = self.active_modal.clone() else { + return Task::none(); + }; + let title = search_query.trim(); + if title.is_empty() { + self.notify(ToastLevel::Error, &t(self.ui_locale, "modal.postInsertLink.titleRequired")); + return Task::none(); + } + + let Some(ref db) = self.db else { return Task::none() }; + let Some(ref data_dir) = self.data_dir else { return Task::none() }; + let Some(ref project) = self.active_project else { return Task::none() }; + + match engine::post::create_post( + db.conn(), + data_dir, + &project.id, + title, + Some(""), + Vec::new(), + Vec::new(), + None, + None, + None, + ) { + Ok(post) => { + let markdown = bds_core::engine::post::post_insert_link(&post.slug) + .replacen("title", &post.title, 1); + self.insert_markdown_into_post(post_id, &markdown) } - Err(e) => { - self.notify(ToastLevel::Error, &format!("Save failed: {e}")); + Err(_) => { + self.notify(ToastLevel::Error, &t(self.ui_locale, "modal.postInsertLink.createFailed")); + Task::none() } } + } + + fn insert_selected_media(&mut self, post_id: &str, media_id: &str) -> Task { + let Some(ref db) = self.db else { return Task::none() }; + let Ok(media) = bds_core::db::queries::media::get_media_by_id(db.conn(), media_id) else { + self.notify(ToastLevel::Error, &t(self.ui_locale, "modal.insertMedia.loadFailed")); + return Task::none(); + }; + + let link_only = matches!( + self.active_modal, + Some(modal::ModalState::InsertMedia { link_only: true, .. }) + ); + + if let (Some(data_dir), Some(project)) = (&self.data_dir, &self.active_project) { + let already_linked = engine::post_media::list_media_for_post(db.conn(), post_id) + .map(|items| items.into_iter().any(|item| item.id == media_id)) + .unwrap_or(false); + if !already_linked { + let sort_order = engine::post_media::list_media_for_post(db.conn(), post_id) + .map(|items| items.len() as i32) + .unwrap_or(0); + let _ = engine::post_media::link_media_to_post( + db.conn(), + data_dir, + &project.id, + post_id, + media_id, + sort_order, + ); + } + } + + self.refresh_post_relationships(post_id); + + if link_only { + self.active_modal = None; + return Task::none(); + } + + let markdown = bds_core::engine::post::post_insert_media( + &media.id, + media.mime_type.starts_with("image/"), + &media.original_name, + ); + self.insert_markdown_into_post(post_id, &markdown) + } + + fn refresh_post_insert_link_modal( + &mut self, + active_tab: Option, + search_query: Option, + external_url: Option, + external_text: Option, + ) { + let Some(modal::ModalState::PostInsertLink { + post_id, + title, + search_query: current_query, + active_tab: current_tab, + external_url: current_url, + external_text: current_text, + .. + }) = self.active_modal.clone() else { + return; + }; + + let next_query = search_query.unwrap_or(current_query); + let next_tab = active_tab.unwrap_or(current_tab); + let next_url = external_url.unwrap_or(current_url); + let next_text = external_text.unwrap_or(current_text); + + self.active_modal = Some(modal::ModalState::PostInsertLink { + post_id: post_id.clone(), + title, + results: self.query_post_link_results(&post_id, &next_query), + search_query: next_query, + active_tab: next_tab, + external_url: next_url, + external_text: next_text, + }); + } + + fn refresh_insert_media_modal(&mut self, search_query: String) { + let Some(modal::ModalState::InsertMedia { post_id, title, link_only, .. }) = self.active_modal.clone() else { + return; + }; + + self.active_modal = Some(modal::ModalState::InsertMedia { + post_id, + title, + media_list: self.query_post_insert_media_results(&search_query), + search_query, + link_only, + }); + } + + fn update_gallery_selection(&mut self, next_index: Option) { + let Some(modal::ModalState::PostGallery { + post_id, + title, + media_list, + .. + }) = self.active_modal.clone() else { + return; + }; + + self.active_modal = Some(modal::ModalState::PostGallery { + post_id, + title, + media_list, + selected_index: next_index, + }); + } + + fn step_gallery_selection(&mut self, delta: isize) { + let Some(modal::ModalState::PostGallery { + selected_index, + media_list, + .. + }) = self.active_modal.clone() else { + return; + }; + + let image_count = media_list.iter().filter(|media| media.mime_type.starts_with("image/")).count(); + if image_count == 0 { + return; + } + + let current = selected_index.unwrap_or(0); + let next = if delta < 0 { + (current + image_count - 1) % image_count + } else { + (current + 1) % image_count + }; + self.update_gallery_selection(Some(next)); + } + + fn save_media_editor(&mut self, media_id: &str) -> Task { + match self.persist_media_editor_state(media_id) { + Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), + Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } Task::none() } @@ -2084,6 +2529,438 @@ impl BdsApp { Task::none() } + fn persist_post_editor_state(&mut self, post_id: &str) -> Result<(), String> { + let state = self + .post_editors + .get(post_id) + .cloned() + .ok_or_else(|| "missing post editor".to_string())?; + let db = self.db.as_ref().ok_or_else(|| "database unavailable".to_string())?; + let data_dir = self + .data_dir + .as_ref() + .ok_or_else(|| "project data directory unavailable".to_string())?; + + if state.active_language != state.canonical_language { + let translation = engine::post::upsert_translation( + db.conn(), + data_dir, + &state.post_id, + &state.active_language, + &state.title, + if state.excerpt.is_empty() { + None + } else { + Some(state.excerpt.as_str()) + }, + Some(&state.content), + ) + .map_err(|e| e.to_string())?; + + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.is_dirty = false; + if let Some(draft) = editor.translation_drafts.get_mut(&state.active_language) { + draft.title = translation.title.clone(); + draft.excerpt = translation.excerpt.clone().unwrap_or_default(); + draft.content = translation.content.clone().unwrap_or_default(); + draft.status = translation.status.clone(); + draft.is_dirty = false; + } + } + } else { + let post = engine::post::update_post( + db.conn(), + data_dir, + &state.post_id, + Some(&state.title), + Some(&state.slug), + Some(if state.excerpt.is_empty() { + None + } else { + Some(state.excerpt.as_str()) + }), + Some(&state.content), + Some(state.tags.clone()), + Some(state.categories.clone()), + Some(if state.author.is_empty() { + None + } else { + Some(state.author.as_str()) + }), + Some(if state.language.is_empty() { + None + } else { + Some(state.language.as_str()) + }), + Some(if state.template_slug.is_empty() { + None + } else { + Some(state.template_slug.as_str()) + }), + Some(state.do_not_translate), + ) + .map_err(|e| e.to_string())?; + + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.title = post.title.clone(); + editor.slug = post.slug.clone(); + editor.excerpt = post.excerpt.clone().unwrap_or_default(); + editor.author = post.author.clone().unwrap_or_default(); + editor.language = post.language.clone().unwrap_or_default(); + editor.template_slug = post.template_slug.clone().unwrap_or_default(); + editor.do_not_translate = post.do_not_translate; + editor.tags = post.tags.clone(); + editor.categories = post.categories.clone(); + editor.status = post.status.clone(); + editor.updated_at = post.updated_at; + editor.published_at = post.published_at; + editor.is_dirty = false; + } + if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post.id) { + tab.is_dirty = false; + if !post.title.is_empty() { + tab.title = post.title.clone(); + } + } + self.refresh_post_relationships(post_id); + } + + if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) { + tab.is_dirty = false; + } + Ok(()) + } + + fn persist_media_editor_state(&mut self, media_id: &str) -> Result<(), String> { + let state = self + .media_editors + .get(media_id) + .cloned() + .ok_or_else(|| "missing media editor".to_string())?; + let db = self.db.as_ref().ok_or_else(|| "database unavailable".to_string())?; + let data_dir = self + .data_dir + .as_ref() + .ok_or_else(|| "project data directory unavailable".to_string())?; + + if state.active_language != state.canonical_language { + engine::media::upsert_media_translation( + db.conn(), + data_dir, + &state.media_id, + &state.active_language, + if state.title.is_empty() { + None + } else { + Some(state.title.as_str()) + }, + if state.alt.is_empty() { + None + } else { + Some(state.alt.as_str()) + }, + if state.caption.is_empty() { + None + } else { + Some(state.caption.as_str()) + }, + ) + .map_err(|e| e.to_string())?; + if let Some(editor) = self.media_editors.get_mut(media_id) { + editor.is_dirty = false; + } + } else { + let tags = state + .tags_input + .split(',') + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(|tag| tag.to_string()) + .collect::>(); + let media = engine::media::update_media( + db.conn(), + data_dir, + &state.media_id, + Some(if state.title.is_empty() { + None + } else { + Some(state.title.as_str()) + }), + Some(if state.alt.is_empty() { + None + } else { + Some(state.alt.as_str()) + }), + Some(if state.caption.is_empty() { + None + } else { + Some(state.caption.as_str()) + }), + Some(if state.author.is_empty() { + None + } else { + Some(state.author.as_str()) + }), + Some(if state.language.is_empty() { + None + } else { + Some(state.language.as_str()) + }), + Some(tags.clone()), + ) + .map_err(|e| e.to_string())?; + + if let Some(editor) = self.media_editors.get_mut(media_id) { + editor.title = media.title.clone().unwrap_or_default(); + editor.alt = media.alt.clone().unwrap_or_default(); + editor.caption = media.caption.clone().unwrap_or_default(); + editor.author = media.author.clone().unwrap_or_default(); + editor.language = media.language.clone().unwrap_or_default(); + editor.tags = tags; + editor.tags_input = editor.tags.join(", "); + editor.updated_at = media.updated_at; + editor.is_dirty = false; + } + } + + if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == media_id) { + tab.is_dirty = false; + } + Ok(()) + } + + fn close_entity_tab(&mut self, entity_id: &str) { + if let Some(next_idx) = tabs::close_tab(&mut self.tabs, entity_id) { + self.active_tab = self.tabs.get(next_idx).map(|tab| tab.id.clone()); + } else if self.active_tab.as_deref() == Some(entity_id) { + self.active_tab = None; + } + self.enforce_panel_tab_fallback(); + self.sync_menu_state(); + } + + fn delete_post_editor(&mut self, post_id: &str) -> Task { + let Some(db) = &self.db else { return Task::none() }; + let Some(data_dir) = &self.data_dir else { return Task::none() }; + match engine::post::delete_post(db.conn(), data_dir, post_id) { + Ok(()) => { + self.post_editors.remove(post_id); + self.close_entity_tab(post_id); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")), + } + Task::none() + } + + fn discard_post_editor(&mut self, post_id: &str) -> Task { + let Some(db) = &self.db else { return Task::none() }; + let Some(data_dir) = &self.data_dir else { return Task::none() }; + match engine::post::discard_post_draft(db.conn(), data_dir, post_id) { + Ok(post) => { + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.title = post.title.clone(); + editor.slug = post.slug.clone(); + editor.excerpt = post.excerpt.clone().unwrap_or_default(); + editor.content = post.content.clone().unwrap_or_default(); + editor.author = post.author.clone().unwrap_or_default(); + editor.language = post.language.clone().unwrap_or_default(); + editor.active_language = editor.language.clone(); + editor.canonical_language = editor.language.clone(); + editor.template_slug = post.template_slug.clone().unwrap_or_default(); + editor.tags = post.tags.clone(); + editor.categories = post.categories.clone(); + editor.status = post.status.clone(); + editor.do_not_translate = post.do_not_translate; + editor.updated_at = post.updated_at; + editor.published_at = post.published_at; + editor.is_dirty = false; + editor.translation_drafts.clear(); + } + if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post.id) { + tab.is_dirty = false; + tab.title = post.title.clone(); + } + self.refresh_post_relationships(post_id); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Discard failed: {e}")), + } + Task::none() + } + + fn duplicate_post_editor(&mut self, post_id: &str) -> Task { + let Some(db) = &self.db else { return Task::none() }; + let Some(data_dir) = &self.data_dir else { return Task::none() }; + match engine::post::duplicate_post(db.conn(), data_dir, post_id) { + Ok(post) => { + let tab = Tab { + id: post.id.clone(), + title: post.title.clone(), + tab_type: TabType::Post, + is_transient: false, + is_dirty: false, + }; + let idx = tabs::open_tab(&mut self.tabs, tab); + self.active_tab = self.tabs.get(idx).map(|tab| tab.id.clone()); + if let Some(tab) = self.tabs.get(idx).cloned() { + self.load_editor_for_tab(&tab); + } + self.enforce_panel_tab_fallback(); + self.sync_menu_state(); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Duplicate failed: {e}")), + } + Task::none() + } + + fn delete_media_editor(&mut self, media_id: &str) -> Task { + let Some(db) = &self.db else { return Task::none() }; + let Some(data_dir) = &self.data_dir else { return Task::none() }; + match engine::media::delete_media(db.conn(), data_dir, media_id) { + Ok(()) => { + self.media_editors.remove(media_id); + self.close_entity_tab(media_id); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")), + } + Task::none() + } + + fn delete_template_editor(&mut self, template_id: &str) -> Task { + let Some(db) = &self.db else { return Task::none() }; + let Some(data_dir) = &self.data_dir else { return Task::none() }; + match engine::template::delete_template(db.conn(), data_dir, template_id, false) { + Ok(()) => { + self.template_editors.remove(template_id); + self.close_entity_tab(template_id); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")), + } + Task::none() + } + + fn delete_script_editor(&mut self, script_id: &str) -> Task { + let Some(db) = &self.db else { return Task::none() }; + let Some(data_dir) = &self.data_dir else { return Task::none() }; + match engine::script::delete_script(db.conn(), data_dir, script_id) { + Ok(()) => { + self.script_editors.remove(script_id); + self.close_entity_tab(script_id); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")), + } + Task::none() + } + + fn reload_tags_state(&mut self) { + let Some(db) = &self.db else { return }; + let Some(project) = &self.active_project else { return }; + let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id) + .unwrap_or_default(); + let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id) + .unwrap_or_default(); + let mut counts = HashMap::new(); + for post in &posts { + for tag_name in &post.tags { + *counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1; + } + } + if let Some(state) = self.tags_view_state.as_mut() { + state.tags = tags; + state.tag_post_counts = counts; + } else { + self.tags_view_state = Some(TagsViewState::new(tags, counts)); + } + } + + fn delete_tag(&mut self, tag_id: &str) -> Task { + let Some(db) = &self.db else { return Task::none() }; + let Some(data_dir) = &self.data_dir else { return Task::none() }; + let Some(project) = &self.active_project else { return Task::none() }; + match engine::tag::delete_tag(db.conn(), data_dir, &project.id, tag_id) { + Ok(()) => { + self.reload_tags_state(); + if let Some(state) = self.tags_view_state.as_mut() { + if state.editing_tag.as_ref().map(|tag| tag.id.as_str()) == Some(tag_id) { + state.editing_tag = None; + } + } + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.deleted")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Delete failed: {e}")), + } + Task::none() + } + + fn merge_tags(&mut self, source: &str, target: &str) -> Task { + let Some(db) = &self.db else { return Task::none() }; + let Some(data_dir) = &self.data_dir else { return Task::none() }; + let Some(project) = &self.active_project else { return Task::none() }; + match engine::tag::merge_tags(db.conn(), data_dir, &project.id, &[source], target) { + Ok(()) => { + self.reload_tags_state(); + if let Some(state) = self.tags_view_state.as_mut() { + state.merge_source = None; + state.merge_target = None; + } + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Merge failed: {e}")), + } + Task::none() + } + + fn hydrate_settings_state(&self) -> SettingsViewState { + let mut state = SettingsViewState::default(); + if let Some(project) = &self.active_project { + state.project_name = project.name.clone(); + state.project_description = iced::widget::text_editor::Content::with_text( + &project.description.clone().unwrap_or_default(), + ); + state.data_path = project.data_path.clone().unwrap_or_default(); + } + if let Some(data_dir) = &self.data_dir { + if let Ok(meta) = engine::meta::read_project_json(data_dir) { + state.public_url = meta.public_url.unwrap_or_default(); + state.default_author = meta.default_author.unwrap_or_default(); + state.max_posts_per_page = meta.max_posts_per_page.to_string(); + } + if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) { + state.ssh_host = pub_prefs.ssh_host.unwrap_or_default(); + state.ssh_username = pub_prefs.ssh_user.unwrap_or_default(); + state.ssh_remote_path = pub_prefs.ssh_remote_path.unwrap_or_default(); + state.ssh_mode = match pub_prefs.ssh_mode { + SshMode::Scp => "scp".to_string(), + SshMode::Rsync => "rsync".to_string(), + }; + } + } + if let Some(db) = &self.db { + if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.default_mode") { + state.default_mode = setting.value; + } + if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.diff_view_style") { + state.diff_view_style = setting.value; + } + if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.wrap_long_lines") { + state.wrap_long_lines = setting.value == "true"; + } + if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.hide_unchanged_regions") { + state.hide_unchanged_regions = setting.value == "true"; + } + if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "ai.system_prompt") { + state.system_prompt = iced::widget::text_editor::Content::with_text(&setting.value); + } + } + state.offline_mode = self.offline_mode; + state + } + fn handle_tags_msg(&mut self, msg: TagsMsg) -> Task { // Ensure tags view state exists if self.tags_view_state.is_none() { @@ -2118,19 +2995,58 @@ impl BdsApp { }); } } - TagsMsg::CreateTag(_name) => { - // Tag creation will dispatch to engine + TagsMsg::CreateTag(name) => { + let mut created_editing = None; + if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, &self.active_project) { + match engine::tag::create_tag(db.conn(), data_dir, &project.id, &name, None) { + Ok(tag) => { + created_editing = Some(tags_view::EditingTag { + id: tag.id, + name: tag.name, + color: tag.color.unwrap_or_default(), + template_slug: tag.post_template_slug.unwrap_or_default(), + }); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } + } + if let Some(editing_tag) = created_editing { + self.reload_tags_state(); + if let Some(state) = self.tags_view_state.as_mut() { + state.editing_tag = Some(editing_tag); + } + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } } TagsMsg::EditTagName(s) => { if let Some(ref mut e) = state.editing_tag { e.name = s; } } TagsMsg::EditTagColor(s) => { if let Some(ref mut e) = state.editing_tag { e.color = s; } } TagsMsg::EditTagTemplate(s) => { if let Some(ref mut e) = state.editing_tag { e.template_slug = s; } } - TagsMsg::SaveTag => { /* will wire to engine */ } + TagsMsg::SaveTag => { + if let Some(editing) = state.editing_tag.clone() { + if let (Some(db), Some(data_dir)) = (&self.db, &self.data_dir) { + match engine::tag::update_tag( + db.conn(), + data_dir, + &editing.id, + Some(&editing.name), + Some(&editing.color), + Some(&editing.template_slug), + ) { + Ok(()) => { + self.reload_tags_state(); + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } + } + } + } TagsMsg::DeleteTag(id) => { let name = state.tags.iter().find(|t| t.id == id).map(|t| t.name.clone()).unwrap_or_default(); return Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete { entity_name: name, references: Vec::new(), - on_confirm: modal::ConfirmAction::DeletePost(id), // TODO: add DeleteTag variant + on_confirm: modal::ConfirmAction::DeleteTag(id), })); } TagsMsg::SetMergeSource(s) => { state.merge_source = Some(s); } @@ -2154,29 +3070,7 @@ impl BdsApp { fn handle_settings_msg(&mut self, msg: SettingsMsg) -> Task { // Ensure settings state exists if self.settings_state.is_none() { - let mut state = SettingsViewState::default(); - if let Some(ref project) = self.active_project { - state.project_name = project.name.clone(); - state.project_description = iced::widget::text_editor::Content::with_text( - &project.description.clone().unwrap_or_default(), - ); - state.data_path = project.data_path.clone().unwrap_or_default(); - } - if let Some(ref data_dir) = self.data_dir { - if let Ok(meta) = engine::meta::read_project_json(data_dir) { - state.public_url = meta.public_url.unwrap_or_default(); - state.default_author = meta.default_author.unwrap_or_default(); - state.max_posts_per_page = meta.max_posts_per_page.to_string(); - } - if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) { - state.ssh_host = pub_prefs.ssh_host.unwrap_or_default(); - state.ssh_username = pub_prefs.ssh_user.unwrap_or_default(); - state.ssh_remote_path = pub_prefs.ssh_remote_path.unwrap_or_default(); - state.ssh_mode = format!("{:?}", pub_prefs.ssh_mode).to_lowercase(); - } - } - state.offline_mode = self.offline_mode; - self.settings_state = Some(state); + self.settings_state = Some(self.hydrate_settings_state()); } let state = self.settings_state.as_mut().unwrap(); match msg { @@ -2204,17 +3098,96 @@ impl BdsApp { SettingsMsg::PublicUrlChanged(s) => { state.public_url = s; } SettingsMsg::DefaultAuthorChanged(s) => { state.default_author = s; } SettingsMsg::MaxPostsPerPageChanged(s) => { state.max_posts_per_page = s; } - SettingsMsg::SaveProject => { /* Project save will be wired to engine */ } + SettingsMsg::SaveProject => { + if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, self.active_project.as_mut()) { + let max_posts = match state.max_posts_per_page.trim().parse::() { + Ok(value) => value, + Err(_) => { + self.notify(ToastLevel::Error, "Invalid max posts per page"); + return Task::none(); + } + }; + let mut meta = engine::meta::read_project_json(data_dir).unwrap_or(bds_core::model::metadata::ProjectMetadata { + name: state.project_name.clone(), + description: None, + public_url: None, + main_language: None, + default_author: None, + max_posts_per_page: 50, + blogmark_category: None, + pico_theme: None, + semantic_similarity_enabled: false, + blog_languages: Vec::new(), + }); + meta.name = state.project_name.clone(); + meta.description = { + let value = state.project_description.text(); + if value.trim().is_empty() { None } else { Some(value) } + }; + meta.public_url = if state.public_url.trim().is_empty() { None } else { Some(state.public_url.clone()) }; + meta.default_author = if state.default_author.trim().is_empty() { None } else { Some(state.default_author.clone()) }; + meta.max_posts_per_page = max_posts; + if let Err(e) = meta.validate() { + self.notify(ToastLevel::Error, &format!("Save failed: {e}")); + return Task::none(); + } + project.name = state.project_name.clone(); + project.description = meta.description.clone(); + project.data_path = if state.data_path.trim().is_empty() { None } else { Some(state.data_path.clone()) }; + project.updated_at = bds_core::util::now_unix_ms(); + let db_result = bds_core::db::queries::project::update_project(db.conn(), project); + let file_result = engine::meta::write_project_json(data_dir, &meta); + match (db_result, file_result) { + (Ok(()), Ok(())) => { + if let Some(listing) = self.projects.iter_mut().find(|p| p.id == project.id) { + *listing = project.clone(); + } + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); + } + (Err(e), _) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + (_, Err(e)) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } + } + } SettingsMsg::DefaultModeChanged(s) => { state.default_mode = s; } SettingsMsg::DiffViewStyleChanged(s) => { state.diff_view_style = s; } SettingsMsg::WrapLongLinesChanged(b) => { state.wrap_long_lines = b; } SettingsMsg::HideUnchangedRegionsChanged(b) => { state.hide_unchanged_regions = b; } - SettingsMsg::SaveEditor => { /* Editor prefs save will be wired */ } + SettingsMsg::SaveEditor => { + if let Some(db) = &self.db { + let now = bds_core::util::now_unix_ms(); + let result = [ + bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.default_mode", &state.default_mode, now), + bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.diff_view_style", &state.diff_view_style, now), + bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.wrap_long_lines", if state.wrap_long_lines { "true" } else { "false" }, now), + bds_core::db::queries::setting::set_setting_value(db.conn(), "editor.hide_unchanged_regions", if state.hide_unchanged_regions { "true" } else { "false" }, now), + ] + .into_iter() + .collect::, _>>(); + match result { + Ok(_) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), + Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } + } + } SettingsMsg::SshModeChanged(s) => { state.ssh_mode = s; } SettingsMsg::SshHostChanged(s) => { state.ssh_host = s; } SettingsMsg::SshUsernameChanged(s) => { state.ssh_username = s; } SettingsMsg::SshRemotePathChanged(s) => { state.ssh_remote_path = s; } - SettingsMsg::SavePublishing => { /* Publishing save will be wired */ } + SettingsMsg::SavePublishing => { + if let Some(data_dir) = &self.data_dir { + let prefs = PublishingPreferences { + ssh_host: if state.ssh_host.trim().is_empty() { None } else { Some(state.ssh_host.clone()) }, + ssh_user: if state.ssh_username.trim().is_empty() { None } else { Some(state.ssh_username.clone()) }, + ssh_remote_path: if state.ssh_remote_path.trim().is_empty() { None } else { Some(state.ssh_remote_path.clone()) }, + ssh_mode: if state.ssh_mode.eq_ignore_ascii_case("scp") { SshMode::Scp } else { SshMode::Rsync }, + }; + match engine::meta::write_publishing_json(data_dir, &prefs) { + Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), + Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } + } + } SettingsMsg::ClearPublishing => { state.ssh_host.clear(); state.ssh_username.clear(); @@ -2227,7 +3200,19 @@ impl BdsApp { SettingsMsg::SystemPromptAction(action) => { state.system_prompt.perform(action); } - SettingsMsg::SaveSystemPrompt => { /* System prompt save will be wired */ } + SettingsMsg::SaveSystemPrompt => { + if let Some(db) = &self.db { + match bds_core::db::queries::setting::set_setting_value( + db.conn(), + "ai.system_prompt", + &state.system_prompt.text(), + bds_core::util::now_unix_ms(), + ) { + Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")), + Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")), + } + } + } SettingsMsg::ResetSystemPrompt => { state.system_prompt = iced::widget::text_editor::Content::new(); } @@ -2313,6 +3298,37 @@ impl BdsApp { (outlinks, backlinks) } + fn load_post_media_items(&self, post_id: &str) -> Vec { + let Some(ref db) = self.db else { + return Vec::new(); + }; + + bds_core::db::queries::post_media::list_post_media_by_post(db.conn(), post_id) + .unwrap_or_default() + .into_iter() + .filter_map(|link| { + bds_core::db::queries::media::get_media_by_id(db.conn(), &link.media_id) + .ok() + .map(|media| LinkedMediaItem { + media_id: media.id, + name: media.title.unwrap_or(media.original_name), + is_image: media.mime_type.starts_with("image/"), + sort_order: link.sort_order, + }) + }) + .collect() + } + + fn refresh_post_relationships(&mut self, post_id: &str) { + let (outlinks, backlinks) = self.load_post_links(post_id); + let linked_media = self.load_post_media_items(post_id); + if let Some(state) = self.post_editors.get_mut(post_id) { + state.outlinks = outlinks; + state.backlinks = backlinks; + state.linked_media = linked_media; + } + } + /// Load editor state when a tab is opened for an entity. fn load_editor_for_tab(&mut self, tab: &Tab) { let Some(ref db) = self.db else { return }; @@ -2363,9 +3379,17 @@ impl BdsApp { } } let (outlinks, backlinks) = self.load_post_links(&post.id); + let linked_media = self.load_post_media_items(&post.id); self.post_editors.insert( post.id.clone(), - PostEditorState::from_post(&post, &self.blog_languages, &translations, outlinks, backlinks), + PostEditorState::from_post( + &post, + &self.blog_languages, + &translations, + outlinks, + backlinks, + linked_media, + ), ); } Err(e) => { diff --git a/crates/bds-ui/src/views/media_editor.rs b/crates/bds-ui/src/views/media_editor.rs index 028e286..962103e 100644 --- a/crates/bds-ui/src/views/media_editor.rs +++ b/crates/bds-ui/src/views/media_editor.rs @@ -39,6 +39,7 @@ pub struct MediaEditorState { pub language: String, pub file_path: String, pub tags: Vec, + pub tags_input: String, pub created_at: i64, pub updated_at: i64, pub is_dirty: bool, @@ -79,6 +80,7 @@ impl MediaEditorState { language: canonical_lang.clone(), file_path: media.file_path.clone(), tags: media.tags.clone(), + tags_input: media.tags.join(", "), created_at: media.created_at, updated_at: media.updated_at, is_dirty: false, @@ -169,6 +171,8 @@ pub enum MediaEditorMsg { AltChanged(String), CaptionChanged(String), AuthorChanged(String), + LanguageChanged(String), + TagsChanged(String), SwitchLanguage(String), Save, Delete, @@ -284,7 +288,25 @@ pub fn view<'a>( &state.author, |s| Message::MediaEditor(MediaEditorMsg::AuthorChanged(s)), ); + let tags_input = inputs::labeled_input( + &t(locale, "editor.tags"), + &t(locale, "editor.tagsPlaceholder"), + &state.tags_input, + |s| Message::MediaEditor(MediaEditorMsg::TagsChanged(s)), + ); + let language_options = if state.blog_languages.is_empty() { + vec![state.language.clone()] + } else { + state.blog_languages.clone() + }; + let language_input = inputs::labeled_select( + &t(locale, "editor.language"), + &language_options, + Some(&state.language), + |lang| Message::MediaEditor(MediaEditorMsg::LanguageChanged(lang)), + ); let meta_row2 = row![caption_input, author_input].spacing(16).width(Length::Fill); + let meta_row3 = row![tags_input, language_input].spacing(16).width(Length::Fill); // Footer let footer = row![ @@ -303,6 +325,7 @@ pub fn view<'a>( inputs::section_header(&t(locale, "editor.metadata")), meta_row1, meta_row2, + meta_row3, footer, ] .spacing(12) diff --git a/crates/bds-ui/src/views/mod.rs b/crates/bds-ui/src/views/mod.rs index e9fdbf6..e2349d5 100644 --- a/crates/bds-ui/src/views/mod.rs +++ b/crates/bds-ui/src/views/mod.rs @@ -15,4 +15,3 @@ pub mod script_editor; pub mod tags_view; pub mod settings_view; pub mod dashboard; -pub mod translation_editor; diff --git a/crates/bds-ui/src/views/modal.rs b/crates/bds-ui/src/views/modal.rs index dd6ffc7..aaad555 100644 --- a/crates/bds-ui/src/views/modal.rs +++ b/crates/bds-ui/src/views/modal.rs @@ -1,11 +1,24 @@ -use iced::widget::{button, column, container, row, text, Space}; +use std::path::Path; + use iced::widget::text::Shaping; +use iced::widget::{button, column, container, image, row, text, text_input, Space}; use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; +use bds_core::model::Media; +use bds_core::util::paths::thumbnail_path; use crate::app::Message; use crate::i18n::t; +use crate::views::post_editor::PostEditorMsg; + +#[derive(Debug, Clone)] +pub struct InsertLinkResult { + pub post_id: String, + pub title: String, + pub status: String, + pub canonical_url: String, +} /// Active modal state. Only one modal at a time. #[derive(Debug, Clone)] @@ -22,6 +35,37 @@ pub enum ModalState { message: String, on_confirm: ConfirmAction, }, + /// Per modals.allium InsertPostLinkModal: Ctrl+K link insertion. + PostInsertLink { + post_id: String, + title: String, + results: Vec, + search_query: String, + active_tab: PostInsertLinkTab, + external_url: String, + external_text: String, + }, + /// Per modals.allium InsertMediaModal: grid for inserting media. + InsertMedia { + post_id: String, + title: String, + media_list: Vec, + search_query: String, + link_only: bool, + }, + /// Per modals.allium GalleryOverlay: full-screen media gallery. + PostGallery { + post_id: String, + title: String, + media_list: Vec, + selected_index: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PostInsertLinkTab { + Internal, + External, } /// What action to perform when modal is confirmed. @@ -32,6 +76,7 @@ pub enum ConfirmAction { DeleteMedia(String), DeleteScript(String), DeleteTemplate(String), + DeleteTag(String), MergeTags { source: String, target: String }, } @@ -107,8 +152,51 @@ fn confirm_button_style(_theme: &Theme, status: button::Status) -> button::Style } } +fn resolve_thumbnail_file(data_dir: Option<&Path>, media: &Media) -> Option { + let data_dir = data_dir?; + if !media.mime_type.starts_with("image/") { + return None; + } + let thumb = data_dir.join(thumbnail_path(&media.id, "medium", "webp")); + if thumb.exists() { + Some(thumb.to_string_lossy().to_string()) + } else { + let original = data_dir.join(&media.file_path); + original + .exists() + .then(|| original.to_string_lossy().to_string()) + } +} + +fn resolve_media_file(data_dir: Option<&Path>, media: &Media) -> Option { + let data_dir = data_dir?; + let path = data_dir.join(&media.file_path); + path.exists().then(|| path.to_string_lossy().to_string()) +} + +pub(crate) fn external_link_markdown(url: &str, display_text: &str) -> Option { + let trimmed_url = url.trim(); + if trimmed_url.is_empty() { + return None; + } + let trimmed_text = display_text.trim(); + if trimmed_text.is_empty() { + Some(trimmed_url.to_string()) + } else { + Some(format!("[{trimmed_text}]({trimmed_url})")) + } +} + +#[cfg(test)] +fn gallery_step(current: usize, len: usize, delta: isize) -> usize { + if len == 0 { + return 0; + } + ((current as isize + delta).rem_euclid(len as isize)) as usize +} + /// Render the modal overlay. -pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> { +pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Element<'static, Message> { let modal_content: Element<'static, Message> = match state { ModalState::ConfirmDelete { entity_name, @@ -136,8 +224,7 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> { .color(Color::from_rgb(0.70, 0.70, 0.75)); let mut content_col = column![ - row![warning_icon, Space::with_width(8.0), title] - .align_y(Alignment::Center), + row![warning_icon, Space::with_width(8.0), title].align_y(Alignment::Center), Space::with_height(12.0), entity_label, warning_text, @@ -164,18 +251,18 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> { .size(13) .shaping(Shaping::Advanced) ) - .on_press(Message::DismissModal) - .padding([6, 16]) - .style(cancel_button_style), + .on_press(Message::DismissModal) + .padding([6, 16]) + .style(cancel_button_style), Space::with_width(Length::Fill), button( text(t(locale, "modal.confirmDelete.delete")) .size(13) .shaping(Shaping::Advanced) ) - .on_press(Message::ConfirmModal(on_confirm_clone)) - .padding([6, 16]) - .style(danger_button_style), + .on_press(Message::ConfirmModal(on_confirm_clone)) + .padding([6, 16]) + .style(danger_button_style), ]; content_col = content_col.push(Space::with_height(16.0)); @@ -209,18 +296,18 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> { .size(13) .shaping(Shaping::Advanced) ) - .on_press(Message::DismissModal) - .padding([6, 16]) - .style(cancel_button_style), + .on_press(Message::DismissModal) + .padding([6, 16]) + .style(cancel_button_style), Space::with_width(Length::Fill), button( text(t(locale, "modal.confirm.confirm")) .size(13) .shaping(Shaping::Advanced) ) - .on_press(Message::ConfirmModal(on_confirm_clone)) - .padding([6, 16]) - .style(confirm_button_style), + .on_press(Message::ConfirmModal(on_confirm_clone)) + .padding([6, 16]) + .style(confirm_button_style), ]; let content_col = column![ @@ -237,6 +324,555 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> { .style(modal_box_style) .into() } + + ModalState::PostInsertLink { + post_id: _post_id, + title: _title, + results, + search_query, + active_tab, + external_url, + external_text, + } => { + let internal_tab = button( + text(t(locale, "modal.postInsertLink.tabInternal")) + .size(13) + .shaping(Shaping::Advanced) + .color(if active_tab == PostInsertLinkTab::Internal { + Color::WHITE + } else { + Color::from_rgb(0.60, 0.60, 0.65) + }), + ) + .on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkTabSwitch( + PostInsertLinkTab::Internal, + ))) + .padding([8, 16]) + .style(cancel_button_style); + + let external_tab = button( + text(t(locale, "modal.postInsertLink.tabExternal")) + .size(13) + .shaping(Shaping::Advanced) + .color(if active_tab == PostInsertLinkTab::External { + Color::WHITE + } else { + Color::from_rgb(0.60, 0.60, 0.65) + }), + ) + .on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkTabSwitch( + PostInsertLinkTab::External, + ))) + .padding([8, 16]) + .style(cancel_button_style); + + let tabs = row![internal_tab, external_tab].spacing(4); + + let search_input = text_input( + t(locale, "modal.postInsertLink.searchPlaceholder").as_str(), + &search_query, + ) + .size(13) + .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkSearch(s))) + .padding([6, 10]) + .width(Length::Fill); + + let internal_content: Element<'static, Message> = if results.is_empty() { + column![ + search_input, + Space::with_height(12.0), + text(t(locale, "modal.postInsertLink.emptyState")) + .size(12) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.55, 0.55, 0.60)), + ] + .spacing(4) + .into() + } else { + let mut column = column![search_input, Space::with_height(12.0)]; + for link in results { + column = column.push( + button( + row![ + column![ + text(link.title.clone()) + .size(13) + .shaping(Shaping::Advanced) + .color(Color::WHITE), + text(link.canonical_url.clone()) + .size(11) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.58, 0.58, 0.64)), + ] + .spacing(2) + .width(Length::Fill), + container( + text(link.status.clone()) + .size(10) + .shaping(Shaping::Advanced) + .color(match link.status.as_str() { + "published" => Color::from_rgb(0.52, 0.82, 0.60), + "archived" => Color::from_rgb(0.70, 0.70, 0.74), + _ => Color::from_rgb(0.90, 0.78, 0.35), + }), + ) + .padding([3, 8]) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))), + border: Border { + color: Color::from_rgb(0.30, 0.30, 0.35), + width: 1.0, + radius: 999.0.into(), + }, + ..container::Style::default() + }), + ] + .spacing(12) + .align_y(Alignment::Center), + ) + .on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkSelected( + link.post_id.clone(), + ))) + .padding([8, 12]) + .style(|_: &Theme, _| button::Style { + background: Some(Background::Color(Color::from_rgb(0.22, 0.24, 0.30))), + text_color: Color::from_rgb(0.85, 0.85, 0.90), + border: Border { + color: Color::from_rgb(0.30, 0.30, 0.35), + width: 1.0, + radius: 4.0.into(), + }, + ..button::Style::default() + }), + ); + } + column.spacing(4).into() + }; + + let external_content: Element<'static, Message> = column![ + text_input( + t(locale, "modal.postInsertLink.externalUrlPlaceholder").as_str(), + &external_url, + ) + .size(13) + .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkUrlChanged(s))) + .padding([6, 10]) + .width(Length::Fill), + text_input( + t(locale, "modal.postInsertLink.externalTextPlaceholder").as_str(), + &external_text, + ) + .size(13) + .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkTextChanged(s))) + .padding([6, 10]) + .width(Length::Fill), + Space::with_height(12.0), + row![ + Space::with_width(Length::Fill), + button(text(t(locale, "modal.postInsertLink.insert"))) + .on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkExternalInsert)) + .padding([6, 16]) + .style(confirm_button_style), + ] + ] + .spacing(8) + .into(); + + let create_post_btn: Element<'static, Message> = button( + text(t(locale, "modal.postInsertLink.createPost")) + .size(12) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.60, 0.60, 0.65)), + ) + .on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkCreate)) + .padding([6, 12]) + .style(|_: &Theme, _| button::Style { + background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))), + text_color: Color::from_rgb(0.60, 0.60, 0.65), + border: Border { + color: Color::from_rgb(0.30, 0.30, 0.35), + width: 1.0, + radius: 4.0.into(), + }, + ..button::Style::default() + }) + .into(); + + let cancel_text = text(t(locale, "modal.postInsertLink.cancel")) + .size(13) + .shaping(Shaping::Advanced); + + let trailing_button: Element<'static, Message> = if active_tab == PostInsertLinkTab::Internal { + create_post_btn + } else { + Space::with_width(0.0).into() + }; + + let buttons = row![ + button(cancel_text) + .on_press(Message::DismissModal) + .padding([6, 16]) + .style(cancel_button_style), + Space::with_width(Length::Fill), + trailing_button, + ] + .spacing(8); + + let content = column![ + tabs, + Space::with_height(12.0), + if active_tab == PostInsertLinkTab::Internal { + internal_content + } else { + external_content + }, + Space::with_height(16.0), + buttons, + ] + .spacing(4); + + container(content.padding(20)) + .width(Length::Fixed(480.0)) + .style(modal_box_style) + .into() + } + + ModalState::InsertMedia { + post_id: _post_id, + title: _title, + media_list, + search_query, + link_only: _, + } => { + let title = text(t(locale, "modal.insertMedia.title")) + .size(16) + .shaping(Shaping::Advanced) + .color(Color::WHITE); + + let search_input = text_input( + t(locale, "modal.insertMedia.searchPlaceholder").as_str(), + &search_query, + ) + .size(13) + .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertMediaSearch(s))) + .padding([6, 10]) + .width(Length::Fill); + + let media_items: Vec> = media_list + .iter() + .map(|m| { + let is_image = m.mime_type.starts_with("image/"); + let title = m + .title + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("") + .to_string(); + let original_name = m.original_name.clone(); + let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) { + image(path) + .width(Length::Fixed(120.0)) + .height(Length::Fixed(90.0)) + .into() + } else if is_image { + text("\u{1F5BC}") + .size(32) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.40, 0.40, 0.45)) + .into() + } else { + text("\u{1F4C4}") + .size(32) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.40, 0.40, 0.45)) + .into() + }; + + let media_title = text(title.clone()) + .size(12) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.85, 0.85, 0.90)); + + let media_name = text(original_name) + .size(10) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.55, 0.55, 0.60)); + + let media_col = column![ + container(thumb) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_| container::Style { + background: Some(Background::Color(Color::from_rgb( + 0.18, 0.20, 0.25 + ))), + border: Border { + color: Color::from_rgb(0.30, 0.30, 0.35), + width: 1.0, + radius: 6.0.into(), + }, + ..container::Style::default() + }), + Space::with_height(8.0), + media_title, + media_name, + ] + .spacing(4) + .align_x(Alignment::Center); + + let btn = button(media_col) + .on_press(Message::PostEditor(PostEditorMsg::PostInsertMediaSelected( + m.id.clone(), + ))) + .padding(8) + .style(|_: &Theme, _| button::Style { + background: Some(Background::Color(Color::from_rgb(0.20, 0.22, 0.27))), + border: Border { + color: Color::from_rgb(0.30, 0.30, 0.35), + width: 1.0, + radius: 8.0.into(), + }, + ..button::Style::default() + }); + btn.into() + }) + .collect(); + + let mut grid = column![].spacing(12).width(Length::Fill); + let mut media_items = media_items.into_iter(); + loop { + let mut grid_row = row![].spacing(12).width(Length::Fill); + let mut pushed_any = false; + for item in media_items.by_ref().take(3) { + grid_row = grid_row.push(item); + pushed_any = true; + } + if !pushed_any { + break; + } + grid = grid.push(grid_row); + } + + let media_list_col: Element<'static, Message> = if media_list.is_empty() { + column![ + search_input, + Space::with_height(12.0), + text(t(locale, "modal.postInsertLink.emptyState")) + .size(12) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.55, 0.55, 0.60)), + ] + .spacing(4) + .into() + } else { + column![ + search_input, + Space::with_height(16.0), + container(grid).width(Length::Fill).center_x(Length::Fill), + ] + .spacing(8) + .width(Length::Fill) + .into() + }; + + let cancel_text = text(t(locale, "modal.insertMedia.cancel")) + .size(13) + .shaping(Shaping::Advanced); + + let buttons = row![button(cancel_text) + .on_press(Message::DismissModal) + .padding([6, 16]) + .style(cancel_button_style),]; + + let content = column![ + title, + Space::with_height(12.0), + media_list_col, + Space::with_height(16.0), + buttons, + ] + .spacing(4) + .width(Length::Fill); + + container(content.padding(20)) + .width(Length::Fixed(580.0)) + .height(Length::Fixed(480.0)) + .style(modal_box_style) + .into() + } + + ModalState::PostGallery { + post_id: _post_id, + title: _title, + media_list, + selected_index, + } => { + let image_media: Vec = media_list + .iter() + .filter(|m| m.mime_type.starts_with("image/")) + .cloned() + .collect(); + + let media_items: Vec> = image_media + .iter() + .enumerate() + .map(|(index, m)| { + let title = m + .title + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("") + .to_string(); + let thumb: Element<'static, Message> = if let Some(path) = resolve_thumbnail_file(data_dir, m) { + image(path) + .width(Length::Fixed(180.0)) + .height(Length::Fixed(120.0)) + .into() + } else { + text("\u{1F5BC}") + .size(32) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.40, 0.40, 0.45)) + .into() + }; + + let media_title = text(title) + .size(12) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.85, 0.85, 0.90)); + + let media_col = column![ + container(thumb) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_| container::Style { + background: Some(Background::Color(Color::from_rgb( + 0.18, 0.20, 0.25 + ))), + border: Border { + color: Color::from_rgb(0.30, 0.30, 0.35), + width: 1.0, + radius: 6.0.into(), + }, + ..container::Style::default() + }), + Space::with_height(8.0), + media_title, + ] + .spacing(4) + .align_x(Alignment::Center); + + let btn = button(media_col) + .on_press(Message::PostEditor( + PostEditorMsg::PostGalleryImageSelected(index), + )) + .padding(8) + .style(|_: &Theme, _| button::Style { + background: Some(Background::Color(Color::from_rgb(0.20, 0.22, 0.27))), + border: Border { + color: Color::from_rgb(0.30, 0.30, 0.35), + width: 1.0, + radius: 8.0.into(), + }, + ..button::Style::default() + }); + btn.into() + }) + .collect(); + + let mut grid = column![].spacing(12).width(Length::Fill); + let mut media_items = media_items.into_iter(); + loop { + let mut grid_row = row![].spacing(12).width(Length::Fill); + let mut pushed_any = false; + for item in media_items.by_ref().take(3) { + grid_row = grid_row.push(item); + pushed_any = true; + } + if !pushed_any { + break; + } + grid = grid.push(grid_row); + } + + let close_text = text(t(locale, "modal.postGallery.close")) + .size(13) + .shaping(Shaping::Advanced); + + let close_button = button(close_text) + .on_press(Message::DismissModal) + .padding([6, 16]) + .style(cancel_button_style); + + let lightbox: Element<'static, Message> = if let Some(index) = selected_index { + if let Some(selected) = image_media.get(index) { + let preview: Element<'static, Message> = if let Some(path) = resolve_media_file(data_dir, selected) { + image(path) + .width(Length::Fill) + .height(Length::Fixed(420.0)) + .into() + } else { + text(t(locale, "modal.postGallery.unavailable")) + .size(13) + .shaping(Shaping::Advanced) + .into() + }; + column![ + container(preview) + .width(Length::Fill) + .center_x(Length::Fill), + row![ + button(text("<")) + .on_press(Message::PostEditor(PostEditorMsg::PostGalleryPrevious)) + .padding([6, 12]) + .style(cancel_button_style), + Space::with_width(Length::Fill), + text(format!("{} / {}", index + 1, image_media.len())) + .size(12) + .shaping(Shaping::Advanced), + Space::with_width(Length::Fill), + button(text(">")) + .on_press(Message::PostEditor(PostEditorMsg::PostGalleryNext)) + .padding([6, 12]) + .style(cancel_button_style), + Space::with_width(12.0), + button(text(t(locale, "modal.postGallery.backToGrid"))) + .on_press(Message::PostEditor(PostEditorMsg::PostGalleryCloseLightbox)) + .padding([6, 12]) + .style(cancel_button_style), + ] + .align_y(Alignment::Center) + ] + .spacing(12) + .into() + } else { + Space::with_height(0.0).into() + } + } else { + Space::with_height(0.0).into() + }; + + let content = column![ + if selected_index.is_some() { + lightbox + } else { + container(grid).center_x(Length::Fill).into() + }, + Space::with_height(16.0), + close_button, + ] + .spacing(4); + + container(content.padding(20)) + .width(Length::Fill) + .height(Length::Fill) + .style(modal_box_style) + .into() + } }; // Center the modal in a full-screen backdrop @@ -250,3 +886,25 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> { .style(backdrop_style) .into() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn external_link_markdown_requires_url() { + assert_eq!(external_link_markdown("", "text"), None); + assert_eq!(external_link_markdown("https://example.com", ""), Some("https://example.com".to_string())); + assert_eq!( + external_link_markdown("https://example.com", "Example"), + Some("[Example](https://example.com)".to_string()) + ); + } + + #[test] + fn gallery_step_wraps_in_both_directions() { + assert_eq!(gallery_step(0, 3, -1), 2); + assert_eq!(gallery_step(2, 3, 1), 0); + assert_eq!(gallery_step(1, 3, 1), 2); + } +} diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 107506c..291c3f2 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -1,13 +1,13 @@ use std::cell::RefCell; use std::collections::HashMap; -use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space}; use iced::widget::text::{Shaping, Wrapping}; +use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space}; use iced::{Color, Element, Length, Theme}; use bds_core::i18n::{self, UiLocale}; use bds_core::model::{Post, PostStatus, PostTranslation}; -use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter}; +use bds_editor::{highlighter, CodeEditor, EditorBuffer, EditorMessage}; use crate::app::Message; use crate::components::inputs; @@ -39,6 +39,15 @@ pub struct ResolvedPostLink { pub title: String, } +/// Linked media metadata shown in the post editor metadata panel. +#[derive(Debug, Clone)] +pub struct LinkedMediaItem { + pub media_id: String, + pub name: String, + pub is_image: bool, + pub sort_order: i32, +} + /// State for an open post editor. pub struct PostEditorState { pub post_id: String, @@ -56,6 +65,7 @@ pub struct PostEditorState { pub status: PostStatus, pub created_at: i64, pub updated_at: i64, + pub published_at: Option, pub is_dirty: bool, pub metadata_expanded: bool, pub excerpt_expanded: bool, @@ -76,6 +86,8 @@ pub struct PostEditorState { pub outlinks: Vec, /// Incoming links from other posts to this post. pub backlinks: Vec, + /// Media linked to this post. + pub linked_media: Vec, } impl std::fmt::Debug for PostEditorState { @@ -106,6 +118,7 @@ impl Clone for PostEditorState { status: self.status.clone(), created_at: self.created_at, updated_at: self.updated_at, + published_at: self.published_at, is_dirty: self.is_dirty, metadata_expanded: self.metadata_expanded, excerpt_expanded: self.excerpt_expanded, @@ -118,6 +131,7 @@ impl Clone for PostEditorState { translation_drafts: self.translation_drafts.clone(), outlinks: self.outlinks.clone(), backlinks: self.backlinks.clone(), + linked_media: self.linked_media.clone(), } } } @@ -129,6 +143,7 @@ impl PostEditorState { translations: &[PostTranslation], outlinks: Vec, backlinks: Vec, + linked_media: Vec, ) -> Self { let title = post.title.clone(); let content = post.content.clone().unwrap_or_default(); @@ -136,13 +151,16 @@ impl PostEditorState { let mut translation_drafts = HashMap::new(); for tr in translations { - translation_drafts.insert(tr.language.clone(), TranslationDraft { - title: tr.title.clone(), - excerpt: tr.excerpt.clone().unwrap_or_default(), - content: tr.content.clone().unwrap_or_default(), - status: tr.status.clone(), - is_dirty: false, - }); + translation_drafts.insert( + tr.language.clone(), + TranslationDraft { + title: tr.title.clone(), + excerpt: tr.excerpt.clone().unwrap_or_default(), + content: tr.content.clone().unwrap_or_default(), + status: tr.status.clone(), + is_dirty: false, + }, + ); } Self { @@ -160,6 +178,7 @@ impl PostEditorState { status: post.status.clone(), created_at: post.created_at, updated_at: post.updated_at, + published_at: post.published_at, is_dirty: false, metadata_expanded: title.is_empty(), excerpt_expanded: false, @@ -172,10 +191,21 @@ impl PostEditorState { translation_drafts, outlinks, backlinks, + linked_media, title, } } + pub fn insert_markdown_at_cursor(&mut self, markdown: &str) { + let new_content = { + let mut buffer = self.editor_buffer.borrow_mut(); + buffer.insert(markdown); + buffer.text() + }; + self.content = new_content; + self.is_dirty = true; + } + /// Switch the editor to display a different language. /// Saves current fields and loads the target language's draft. pub fn switch_language(&mut self, target_lang: &str) { @@ -195,13 +225,16 @@ impl PostEditorState { }); } else { // Switching away from a translation — save to drafts - self.translation_drafts.insert(self.active_language.clone(), TranslationDraft { - title: self.title.clone(), - excerpt: self.excerpt.clone(), - content: self.content.clone(), - status: PostStatus::Draft, - is_dirty: self.is_dirty, - }); + self.translation_drafts.insert( + self.active_language.clone(), + TranslationDraft { + title: self.title.clone(), + excerpt: self.excerpt.clone(), + content: self.content.clone(), + status: PostStatus::Draft, + is_dirty: self.is_dirty, + }, + ); } // Load target fields @@ -281,6 +314,7 @@ pub enum PostEditorMsg { ExcerptChanged(String), ContentChanged(String), AuthorChanged(String), + LanguageChanged(String), TemplateSlugChanged(String), ToggleDoNotTranslate(bool), ToggleMetadata, @@ -294,7 +328,28 @@ pub enum PostEditorMsg { RemoveCategory(String), Save, Publish, + Duplicate, + Discard, Delete, + InsertLink, + InsertMedia, + Gallery, + LinkExistingMedia, + OpenLinkedMedia(String), + UnlinkLinkedMedia(String), + PostInsertLinkSelected(String), + PostInsertLinkCreate, + PostInsertMediaSelected(String), + PostGalleryImageSelected(usize), + PostInsertLinkTabSwitch(crate::views::modal::PostInsertLinkTab), + PostInsertLinkSearch(String), + PostInsertLinkUrlChanged(String), + PostInsertLinkTextChanged(String), + PostInsertLinkExternalInsert, + PostInsertMediaSearch(String), + PostGalleryPrevious, + PostGalleryNext, + PostGalleryCloseLightbox, } /// Render the post editor view. @@ -303,6 +358,8 @@ pub fn view<'a>( locale: UiLocale, word_wrap: bool, ) -> Element<'a, Message> { + let on_translation = state.active_language != state.canonical_language; + // ── Header bar ── let dirty_indicator = if state.is_dirty { " \u{25CF}" } else { "" }; let title_display = if state.title.is_empty() { @@ -312,34 +369,68 @@ pub fn view<'a>( }; let header = inputs::toolbar( - vec![ - text(title_display) - .size(18) - .wrapping(Wrapping::None) - .shaping(Shaping::Advanced) - .into(), - ], + vec![text(title_display) + .size(18) + .wrapping(Wrapping::None) + .shaping(Shaping::Advanced) + .into()], vec![ status_badge(&state.status), - button(text(t(locale, "common.save")).size(13).shaping(Shaping::Advanced)) - .on_press(Message::PostEditor(PostEditorMsg::Save)) - .style(inputs::primary_button) + button( + text(t(locale, "common.save")) + .size(13) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::Save)) + .style(inputs::primary_button) + .padding([6, 16]) + .into(), + if !on_translation { + button( + text(t(locale, "editor.duplicate")) + .size(13) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::Duplicate)) .padding([6, 16]) - .into(), - if state.status == PostStatus::Draft { - button(text(t(locale, "editor.publish")).size(13).shaping(Shaping::Advanced)) - .on_press(Message::PostEditor(PostEditorMsg::Publish)) - .style(inputs::primary_button) - .padding([6, 16]) - .into() + .into() } else { Space::new(0, 0).into() }, - button(text(t(locale, "modal.confirmDelete.delete")).size(13).shaping(Shaping::Advanced)) - .on_press(Message::PostEditor(PostEditorMsg::Delete)) - .style(inputs::danger_button) + if state.status == PostStatus::Draft { + button( + text(t(locale, "editor.publish")) + .size(13) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::Publish)) + .style(inputs::primary_button) .padding([6, 16]) - .into(), + .into() + } else { + Space::new(0, 0).into() + }, + if !on_translation && state.status == PostStatus::Draft && state.published_at.is_some() { + button( + text(t(locale, "editor.discard")) + .size(13) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::Discard)) + .padding([6, 16]) + .into() + } else { + Space::new(0, 0).into() + }, + button( + text(t(locale, "modal.confirmDelete.delete")) + .size(13) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::Delete)) + .style(inputs::danger_button) + .padding([6, 16]) + .into(), ], ); @@ -350,7 +441,10 @@ pub fn view<'a>( format!("\u{25B6} {}", t(locale, "editor.metadata")) }; let meta_toggle = button( - text(meta_toggle_label).size(12).color(inputs::SECTION_COLOR).shaping(Shaping::Advanced), + text(meta_toggle_label) + .size(12) + .color(inputs::SECTION_COLOR) + .shaping(Shaping::Advanced), ) .on_press(Message::PostEditor(PostEditorMsg::ToggleMetadata)) .padding([4, 0]) @@ -361,7 +455,6 @@ pub fn view<'a>( // ── Translation Flags Bar (inline with metadata toggle) ── let flags = state.translation_flags(); - let on_translation = state.active_language != state.canonical_language; let meta_toggle_row: Element<'a, Message> = if flags.is_empty() { meta_toggle.into() } else { @@ -369,12 +462,14 @@ pub fn view<'a>( for flag in &flags { let lang = flag.language.clone(); let label = format!("{}", flag.flag_emoji); - let btn = button( - text(label).size(14).shaping(Shaping::Advanced), - ) - .on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang))) - .padding([2, 4]) - .style(if flag.is_active { flag_active_style } else { flag_inactive_style }); + let btn = button(text(label).size(14).shaping(Shaping::Advanced)) + .on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang))) + .padding([2, 4]) + .style(if flag.is_active { + flag_active_style + } else { + flag_inactive_style + }); flag_row = flag_row.push(btn); } row![meta_toggle, Space::with_width(Length::Fill), flag_row] @@ -400,11 +495,20 @@ pub fn view<'a>( .spacing(16) .width(Length::Fill); - let author_input = inputs::labeled_input( - &t(locale, "editor.author"), - "", - &state.author, - |s| Message::PostEditor(PostEditorMsg::AuthorChanged(s)), + let author_input = + inputs::labeled_input(&t(locale, "editor.author"), "", &state.author, |s| { + Message::PostEditor(PostEditorMsg::AuthorChanged(s)) + }); + let language_options = if state.blog_languages.is_empty() { + vec![state.language.clone()] + } else { + state.blog_languages.clone() + }; + let language_input = inputs::labeled_select( + &t(locale, "editor.language"), + &language_options, + Some(&state.language), + |lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)), ); let template_input = inputs::labeled_input( &t(locale, "editor.templateSlug"), @@ -417,7 +521,7 @@ pub fn view<'a>( state.do_not_translate, |b| Message::PostEditor(PostEditorMsg::ToggleDoNotTranslate(b)), ); - let meta_row2 = row![author_input, template_input, dnt] + let meta_row2 = row![author_input, language_input, template_input, dnt] .spacing(16) .width(Length::Fill); @@ -447,16 +551,18 @@ pub fn view<'a>( let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() { Space::new(0, 0).into() } else { - let mut items: Vec> = vec![ - text(t(locale, "editor.outlinks")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced).into(), - ]; + let mut items: Vec> = vec![text(t(locale, "editor.outlinks")) + .size(12) + .color(inputs::LABEL_COLOR) + .shaping(Shaping::Advanced) + .into()]; for link in &state.outlinks { items.push( text(format!("\u{2192} {}", link.title)) .size(12) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.55, 0.70, 0.90)) - .into() + .into(), ); } Column::with_children(items).spacing(2).into() @@ -465,25 +571,118 @@ pub fn view<'a>( let backlinks_section: Element<'a, Message> = if state.backlinks.is_empty() { Space::new(0, 0).into() } else { - let mut items: Vec> = vec![ - text(t(locale, "editor.backlinks")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced).into(), - ]; + let mut items: Vec> = vec![text(t(locale, "editor.backlinks")) + .size(12) + .color(inputs::LABEL_COLOR) + .shaping(Shaping::Advanced) + .into()]; for link in &state.backlinks { items.push( text(format!("\u{2190} {}", link.title)) .size(12) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.55, 0.70, 0.90)) - .into() + .into(), ); } Column::with_children(items).spacing(2).into() }; - column![meta_row1, meta_row2, tags_section, categories_section, outlinks_section, backlinks_section] - .spacing(8) - .width(Length::Fill) + let link_existing_button: Element<'a, Message> = button( + text(t(locale, "editor.linkExistingMedia")) + .size(11) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::LinkExistingMedia)) + .padding([4, 10]) + .style(|_: &Theme, _| button::Style::default()) + .into(); + + let linked_media_header: Element<'a, Message> = row![ + text(t(locale, "editor.linkedMedia")) + .size(12) + .color(inputs::LABEL_COLOR) + .shaping(Shaping::Advanced), + Space::with_width(Length::Fill), + link_existing_button, + ] + .align_y(iced::Alignment::Center) + .into(); + + let linked_media_section: Element<'a, Message> = if state.linked_media.is_empty() { + column![ + linked_media_header, + text(t(locale, "editor.linkedMediaEmpty")) + .size(12) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.55, 0.55, 0.60)), + ] + .spacing(4) .into() + } else { + let mut items: Vec> = vec![linked_media_header]; + for media in &state.linked_media { + let media_id = media.media_id.clone(); + let open_id = media.media_id.clone(); + let kind_label = if media.is_image { + t(locale, "editor.linkedMediaKindImage") + } else { + t(locale, "editor.linkedMediaKindFile") + }; + items.push( + container( + row![ + column![ + text(media.name.clone()) + .size(12) + .shaping(Shaping::Advanced) + .color(Color::WHITE), + text(format!("{} {}", kind_label, media.sort_order + 1)) + .size(10) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.55, 0.55, 0.60)), + ] + .spacing(2) + .width(Length::Fill), + button(text(t(locale, "common.open")).size(11).shaping(Shaping::Advanced)) + .on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id))) + .padding([4, 10]), + button(text(t(locale, "editor.unlinkMedia")).size(11).shaping(Shaping::Advanced)) + .on_press(Message::PostEditor(PostEditorMsg::UnlinkLinkedMedia(media_id))) + .padding([4, 10]) + .style(inputs::danger_button), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + ) + .padding(8) + .style(|_: &Theme| container::Style { + background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))), + border: iced::Border { + color: Color::from_rgb(0.28, 0.28, 0.32), + width: 1.0, + radius: 6.0.into(), + }, + ..container::Style::default() + }) + .into(), + ); + } + Column::with_children(items).spacing(6).into() + }; + + column![ + meta_row1, + meta_row2, + tags_section, + categories_section, + outlinks_section, + backlinks_section, + linked_media_section, + ] + .spacing(8) + .width(Length::Fill) + .into() } else { Space::new(0, 0).into() }; @@ -495,7 +694,10 @@ pub fn view<'a>( format!("\u{25B6} {}", t(locale, "editor.excerpt")) }; let excerpt_toggle = button( - text(excerpt_toggle_label).size(12).color(inputs::SECTION_COLOR).shaping(Shaping::Advanced), + text(excerpt_toggle_label) + .size(12) + .color(inputs::SECTION_COLOR) + .shaping(Shaping::Advanced), ) .on_press(Message::PostEditor(PostEditorMsg::ToggleExcerpt)) .padding([4, 0]) @@ -517,23 +719,64 @@ pub fn view<'a>( // ── Content section (fills remaining space) ── let content_label = inputs::section_header(&t(locale, "editor.content")); - let editor_widget: Element<'a, Message> = CodeEditor::new( - &state.editor_buffer, - highlighter(), - "md", - ) - .word_wrap(word_wrap) - .on_change(|msg| match msg { - EditorMessage::ContentChanged(s) => Message::PostEditor(PostEditorMsg::ContentChanged(s)), - EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save), - }) - .into(); + let body_toolbar = inputs::toolbar( + vec![content_label.into()], + vec![ + button( + text(t(locale, "editor.insertLink")) + .size(13) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::InsertLink)) + .padding([6, 16]) + .into(), + button( + text(t(locale, "editor.insertMedia")) + .size(13) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::InsertMedia)) + .padding([6, 16]) + .into(), + button( + text(t(locale, "editor.gallery")) + .size(13) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::Gallery)) + .padding([6, 16]) + .into(), + ], + ); + let editor_widget: Element<'a, Message> = + CodeEditor::new(&state.editor_buffer, highlighter(), "md") + .word_wrap(word_wrap) + .on_change(|msg| match msg { + EditorMessage::ContentChanged(s) => { + Message::PostEditor(PostEditorMsg::ContentChanged(s)) + } + EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save), + }) + .into(); // ── Footer ── + let published_gap: Element<'a, Message> = if state.published_at.is_some() { + Space::with_width(Length::Fixed(24.0)).into() + } else { + Space::new(0, 0).into() + }; + let published_label: Element<'a, Message> = if let Some(published_at) = state.published_at { + inputs::date_label(&t(locale, "editor.publishedAt"), published_at) + } else { + Space::new(0, 0).into() + }; + let footer = row![ inputs::date_label(&t(locale, "editor.createdAt"), state.created_at), Space::with_width(Length::Fixed(24.0)), inputs::date_label(&t(locale, "editor.updatedAt"), state.updated_at), + published_gap, + published_label, ] .padding(8); @@ -547,22 +790,17 @@ pub fn view<'a>( excerpt_section, ] .spacing(4) - .width(Length::Fill) + .width(Length::Fill), ) .height(Length::Shrink); // ── Full layout: top pane (shrink), editor (fill), footer (shrink) ── - column![ - top_pane, - content_label, - editor_widget, - footer, - ] - .spacing(4) - .padding(16) - .width(Length::Fill) - .height(Length::Fill) - .into() + column![top_pane, body_toolbar, editor_widget, footer,] + .spacing(4) + .padding(16) + .width(Length::Fill) + .height(Length::Fill) + .into() } /// Chip input: shows existing chips as removable buttons + a text input to add new ones. @@ -594,7 +832,10 @@ fn chip_input_field<'a>( } column![ - text(label.to_string()).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced), + text(label.to_string()) + .size(12) + .color(inputs::LABEL_COLOR) + .shaping(Shaping::Advanced), chip_row.wrap(), text_input(placeholder, input_value) .on_input(on_input) @@ -678,3 +919,66 @@ fn flag_inactive_style(_theme: &Theme, status: button::Status) -> button::Style ..button::Style::default() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_state() -> PostEditorState { + let post = Post { + id: "post-1".to_string(), + project_id: "project-1".to_string(), + title: "Sample".to_string(), + slug: "sample".to_string(), + excerpt: None, + content: Some("Hello world".to_string()), + status: PostStatus::Draft, + file_path: String::new(), + checksum: None, + created_at: 1, + updated_at: 1, + published_at: None, + published_title: None, + published_content: None, + published_excerpt: None, + published_tags: None, + published_categories: None, + tags: Vec::new(), + categories: Vec::new(), + author: None, + language: Some("en".to_string()), + template_slug: None, + do_not_translate: false, + }; + PostEditorState::from_post( + &post, + &["en".to_string()], + &[], + Vec::new(), + Vec::new(), + Vec::new(), + ) + } + + #[test] + fn insert_markdown_at_cursor_uses_buffer_cursor() { + let mut state = sample_state(); + state.editor_buffer.borrow_mut().set_cursor(0, 5); + + state.insert_markdown_at_cursor(" brave"); + + assert_eq!(state.content, "Hello brave world"); + assert!(state.is_dirty); + } + + #[test] + fn insert_markdown_at_cursor_replaces_selection() { + let mut state = sample_state(); + state.editor_buffer.borrow_mut().set_selection(0, 6, 0, 11); + + state.insert_markdown_at_cursor("Rust"); + + assert_eq!(state.content, "Hello Rust"); + assert!(state.is_dirty); + } +} diff --git a/crates/bds-ui/src/views/translation_editor.rs b/crates/bds-ui/src/views/translation_editor.rs deleted file mode 100644 index 9e80274..0000000 --- a/crates/bds-ui/src/views/translation_editor.rs +++ /dev/null @@ -1,154 +0,0 @@ -use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; -use iced::{Color, Element, Length, Theme}; - -use bds_core::i18n::UiLocale; - -use crate::app::Message; -use crate::components::inputs; -use crate::i18n::t; - -/// State for the translation editor (post translation). -#[derive(Debug, Clone)] -pub struct TranslationEditorState { - pub post_id: String, - pub post_title: String, - pub language: String, - pub title: String, - pub excerpt: String, - pub content: String, - pub status: String, // draft | published - pub created_at: i64, - pub updated_at: i64, - pub is_dirty: bool, -} - -impl TranslationEditorState { - pub fn new(post_id: String, post_title: String, language: String) -> Self { - Self { - post_id, - post_title, - language, - title: String::new(), - excerpt: String::new(), - content: String::new(), - status: "draft".to_string(), - created_at: 0, - updated_at: 0, - is_dirty: false, - } - } -} - -/// Translation editor messages. -#[derive(Debug, Clone)] -pub enum TranslationEditorMsg { - TitleChanged(String), - ExcerptChanged(String), - ContentChanged(String), - Save, - Publish, - Delete, -} - -/// Render the translation editor view. -pub fn view<'a>( - state: &'a TranslationEditorState, - locale: UiLocale, -) -> Element<'a, Message> { - let header = inputs::toolbar( - vec![ - text(format!("{} [{}]", &state.post_title, &state.language)) - .size(18) - .into(), - status_badge(&state.status), - ], - vec![ - button(text(t(locale, "common.save")).size(13)) - .on_press(Message::TranslationEditor(TranslationEditorMsg::Save)) - .style(inputs::primary_button) - .padding([6, 16]) - .into(), - button(text(t(locale, "editor.publish")).size(13)) - .on_press(Message::TranslationEditor(TranslationEditorMsg::Publish)) - .style(inputs::primary_button) - .padding([6, 16]) - .into(), - button(text(t(locale, "modal.confirmDelete.delete")).size(13)) - .on_press(Message::TranslationEditor(TranslationEditorMsg::Delete)) - .style(inputs::danger_button) - .padding([6, 16]) - .into(), - ], - ); - - let title_input = inputs::labeled_input( - &t(locale, "editor.title"), - &t(locale, "editor.titlePlaceholder"), - &state.title, - |s| Message::TranslationEditor(TranslationEditorMsg::TitleChanged(s)), - ); - - let excerpt_input = inputs::labeled_input( - &t(locale, "editor.excerpt"), - &t(locale, "editor.excerptPlaceholder"), - &state.excerpt, - |s| Message::TranslationEditor(TranslationEditorMsg::ExcerptChanged(s)), - ); - - let content_section: Element<'a, Message> = column![ - inputs::section_header(&t(locale, "editor.content")), - container( - text_input(&t(locale, "editor.contentPlaceholder"), &state.content) - .on_input(|s| Message::TranslationEditor(TranslationEditorMsg::ContentChanged(s))) - .size(14) - ) - .width(Length::Fill) - .height(Length::Fixed(300.0)), - ] - .spacing(8) - .width(Length::Fill) - .into(); - - let footer = row![ - inputs::date_label(&t(locale, "editor.createdAt"), state.created_at), - Space::with_width(Length::Fixed(24.0)), - inputs::date_label(&t(locale, "editor.updatedAt"), state.updated_at), - ] - .padding(8); - - let body = scrollable( - column![ - header, - title_input, - excerpt_input, - content_section, - footer, - ] - .spacing(12) - .padding(16) - .width(Length::Fill), - ); - - container(body) - .width(Length::Fill) - .height(Length::Fill) - .into() -} - -fn status_badge<'a>(status: &str) -> Element<'a, Message> { - let (label, color) = match status { - "published" => ("Published", Color::from_rgb(0.2, 0.7, 0.3)), - _ => ("Draft", Color::from_rgb(0.8, 0.7, 0.2)), - }; - container(text(label).size(11).color(color)) - .padding([2, 8]) - .style(move |_: &Theme| container::Style { - border: iced::Border { - radius: 4.0.into(), - width: 1.0, - color, - }, - ..container::Style::default() - }) - .into() -} diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index b4cfbcb..f28d2ed 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use iced::widget::{button, column, container, mouse_area, row, stack, text, Space}; use iced::widget::text::Shaping; +use iced::widget::{button, column, container, mouse_area, row, stack, text, Space}; use iced::{Alignment, Background, Color, Element, Length, Padding, Theme}; use bds_core::i18n::UiLocale; @@ -10,18 +10,22 @@ use bds_core::model::{Media, Post, Project, Script, Template}; use crate::app::Message; use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot}; -use crate::state::sidebar_filter::{PostFilter, MediaFilter}; +use crate::state::sidebar_filter::{MediaFilter, PostFilter}; use crate::state::tabs::{Tab, TabType}; use crate::state::toast::Toast; use crate::views::{ - activity_bar, modal, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome, - post_editor::{self, PostEditorState}, - media_editor::{self, MediaEditorState}, - template_editor::{self, TemplateEditorState}, - script_editor::{self, ScriptEditorState}, - tags_view::{self, TagsViewState}, - settings_view::{self, SettingsViewState}, + activity_bar, dashboard::DashboardState, + media_editor::{self, MediaEditorState}, + modal, panel, + post_editor::{self, PostEditorState}, + project_selector, + script_editor::{self, ScriptEditorState}, + settings_view::{self, SettingsViewState}, + sidebar, status_bar, tab_bar, + tags_view::{self, TagsViewState}, + template_editor::{self, TemplateEditorState}, + toast, welcome, }; /// Main content area background. @@ -94,7 +98,7 @@ pub fn view<'a>( // Toasts toasts: &'a [Toast], // Modal - active_modal: Option<&'a modal::ModalState>, + active_modal: Option, // Data directory (for thumbnail paths) data_dir: Option<&'a Path>, // Editor states @@ -114,18 +118,29 @@ pub fn view<'a>( // Content area — route based on active tab type let content_area = route_content_area( - tabs, active_tab, locale, data_dir, - post_editors, media_editors, template_editors, script_editors, - tags_view_state, settings_state, dashboard_state, + tabs, + active_tab, + locale, + data_dir, + post_editors, + media_editors, + template_editors, + script_editors, + tags_view_state, + settings_state, + dashboard_state, ); // Right column: tab bar + content + panel let mut right_col = column![tabs_el, content_area]; if panel_visible { // Determine active tab type for panel tab availability (per layout.allium PanelTabAvailability) - let active_tab_type = active_tab.and_then(|id| tabs.iter().find(|t| t.id == id)).map(|t| &t.tab_type); + let active_tab_type = active_tab + .and_then(|id| tabs.iter().find(|t| t.id == id)) + .map(|t| &t.tab_type); let active_tab_is_post = active_tab_type == Some(&TabType::Post); - let active_tab_is_post_or_media = active_tab_is_post || active_tab_type == Some(&TabType::Media); + let active_tab_is_post_or_media = + active_tab_is_post || active_tab_type == Some(&TabType::Media); right_col = right_col.push(separator_h()); right_col = right_col.push(panel::view( @@ -146,7 +161,22 @@ pub fn view<'a>( let mut main_row = row![activity]; if sidebar_visible { - main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, post_filter, media_filter, sidebar_media_thumbs, sidebar_posts_has_more, sidebar_media_has_more, sidebar_width, active_tab, locale, data_dir)); + main_row = main_row.push(sidebar::view( + sidebar_view, + sidebar_posts, + sidebar_media, + sidebar_scripts, + sidebar_templates, + post_filter, + media_filter, + sidebar_media_thumbs, + sidebar_posts_has_more, + sidebar_media_has_more, + sidebar_width, + active_tab, + locale, + data_dir, + )); // Resize drag handle: 4px wide strip between sidebar and content let handle = container(Space::new(0, 0)) @@ -168,17 +198,21 @@ pub fn view<'a>( let main_row = main_row.height(Length::Fill); // Status bar at bottom — determine active post status for status dot - let active_post_status: Option = active_tab.and_then(|id| { - tabs.iter().find(|t| t.id == id && t.tab_type == TabType::Post) - }).and_then(|tab| { - sidebar_posts.iter().find(|p| p.id == tab.id).map(|p| { - match p.status { - bds_core::model::PostStatus::Draft => "draft".to_string(), - bds_core::model::PostStatus::Published => "published".to_string(), - bds_core::model::PostStatus::Archived => "archived".to_string(), - } + let active_post_status: Option = active_tab + .and_then(|id| { + tabs.iter() + .find(|t| t.id == id && t.tab_type == TabType::Post) }) - }); + .and_then(|tab| { + sidebar_posts + .iter() + .find(|p| p.id == tab.id) + .map(|p| match p.status { + bds_core::model::PostStatus::Draft => "draft".to_string(), + bds_core::model::PostStatus::Published => "published".to_string(), + bds_core::model::PostStatus::Archived => "archived".to_string(), + }) + }); let status = status_bar::view( active_project_name, @@ -201,9 +235,7 @@ pub fn view<'a>( let items: Vec> = UiLocale::all() .iter() .map(|&l| { - let flag_text = text(l.flag_emoji()) - .size(16) - .shaping(Shaping::Advanced); + let flag_text = text(l.flag_emoji()).size(16).shaping(Shaping::Advanced); button(flag_text) .on_press(Message::SetUiLocale(l)) @@ -214,28 +246,33 @@ pub fn view<'a>( .collect(); let dropdown_menu = container( - iced::widget::Column::with_children(items).spacing(2).padding(4), + iced::widget::Column::with_children(items) + .spacing(2) + .padding(4), ) .style(status_bar::dropdown_bg); // Position at bottom-right, above status bar Some( container( - container( - row![ - Space::with_width(Length::Fill), - dropdown_menu, - Space::with_width(Length::Fixed(40.0)), - ] - ) + container(row![ + Space::with_width(Length::Fill), + dropdown_menu, + Space::with_width(Length::Fixed(40.0)), + ]) .width(Length::Fill) .align_y(Alignment::End) - .padding(Padding { top: 0.0, right: 0.0, bottom: 25.0, left: 0.0 }) + .padding(Padding { + top: 0.0, + right: 0.0, + bottom: 25.0, + left: 0.0, + }), ) .width(Length::Fill) .height(Length::Fill) .align_y(Alignment::End) - .into() + .into(), ) } else if project_dropdown_open { let dropdown = project_selector::view(projects, active_project_id, locale); @@ -243,21 +280,24 @@ pub fn view<'a>( // Position at bottom-left, above status bar Some( container( - container( - row![ - Space::with_width(Length::Fixed(8.0)), - dropdown, - Space::with_width(Length::Fill), - ] - ) + container(row![ + Space::with_width(Length::Fixed(8.0)), + dropdown, + Space::with_width(Length::Fill), + ]) .width(Length::Fill) .align_y(Alignment::End) - .padding(Padding { top: 0.0, right: 0.0, bottom: 25.0, left: 0.0 }) + .padding(Padding { + top: 0.0, + right: 0.0, + bottom: 25.0, + left: 0.0, + }), ) .width(Length::Fill) .height(Length::Fill) .align_y(Alignment::End) - .into() + .into(), ) } else { None @@ -276,7 +316,7 @@ pub fn view<'a>( // Modal overlay (highest z-index) if let Some(modal_state) = active_modal { - overlays.push(modal::view(modal_state, locale)); + overlays.push(modal::view(modal_state, locale, data_dir)); } if overlays.is_empty() { @@ -363,7 +403,9 @@ fn route_content_area<'a>( fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> { use crate::i18n::t; container( - text(t(locale, "tabBar.loading")).size(14).color(Color::from_rgb(0.5, 0.5, 0.5)), + text(t(locale, "tabBar.loading")) + .size(14) + .color(Color::from_rgb(0.5, 0.5, 0.5)), ) .width(Length::Fill) .height(Length::Fill) diff --git a/locales/ui/de.json b/locales/ui/de.json index 2b696ca..746b56c 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -42,6 +42,7 @@ "activity.import": "Importieren", "activity.sourceControl": "Versionskontrolle", "common.save": "Speichern", + "common.open": "Öffnen", "common.cancel": "Abbrechen", "common.settings": "Einstellungen", "common.tasks": "Aufgaben", @@ -166,9 +167,37 @@ "modal.confirm.title": "Bestätigen", "modal.confirm.cancel": "Abbrechen", "modal.confirm.confirm": "Bestätigen", + "modal.postInsertLink.title": "Link einfügen", + "modal.postInsertLink.description": "Wählen Sie einen Beitrag, den Sie verlinken möchten.", + "modal.postInsertLink.cancel": "Abbrechen", + "modal.postInsertLink.tabInternal": "Intern", + "modal.postInsertLink.tabExternal": "Extern", + "modal.postInsertLink.searchPlaceholder": "Beiträge durchsuchen...", + "modal.postInsertLink.emptyState": "Tippen Sie, um Beiträge zu suchen oder verwenden Sie verwandte Beiträge", + "modal.postInsertLink.createPost": "Beitrag erstellen", + "modal.postInsertLink.insert": "Einfügen", + "modal.postInsertLink.externalUrlPlaceholder": "https://example.com", + "modal.postInsertLink.externalTextPlaceholder": "Anzeigetext (optional)", + "modal.postInsertLink.urlRequired": "Eine URL ist erforderlich.", + "modal.postInsertLink.titleRequired": "Geben Sie einen Titel ein, um einen verlinkten Beitrag zu erstellen.", + "modal.postInsertLink.createFailed": "Der verlinkte Beitrag konnte nicht erstellt werden.", + "modal.postInsertLink.loadFailed": "Der ausgewählte Beitrag konnte nicht geladen werden.", + "modal.insertMedia.title": "Medien einfügen", + "modal.insertMedia.description": "Wählen Sie ein Medium zum Einfügen.", + "modal.insertMedia.cancel": "Abbrechen", + "modal.insertMedia.searchPlaceholder": "Medien durchsuchen...", + "modal.insertMedia.loadFailed": "Das ausgewählte Medium konnte nicht geladen werden.", + "modal.postGallery.title": "Mediengalerie", + "modal.postGallery.description": "Klicken Sie auf ein Bild, um es in voller Größe anzuzeigen.", + "modal.postGallery.close": "Schließen", + "modal.postGallery.backToGrid": "Zurück zur Übersicht", + "modal.postGallery.unavailable": "Vorschau nicht verfügbar", "sidebar.filter.search": "Suchen...", "sidebar.filter.toggle": "Filter", "sidebar.filter.clearAll": "Alle Filter zurücksetzen", + "editor.insertLink": "Link einfügen", + "editor.insertMedia": "Medium einfügen", + "editor.gallery": "Galerie", "sidebar.filter.tags": "Tags", "sidebar.filter.categories": "Kategorien", "sidebar.filter.calendar": "Archiv", @@ -182,9 +211,12 @@ "editor.excerpt": "Auszug", "editor.excerptPlaceholder": "Kurze Zusammenfassung...", "editor.author": "Autor", + "editor.language": "Sprache", "editor.templateSlug": "Vorlage", "editor.doNotTranslate": "Nicht übersetzen", "editor.publish": "Veröffentlichen", + "editor.discard": "Verwerfen", + "editor.duplicate": "Duplizieren", "editor.validate": "Validieren", "editor.run": "Ausführen", "editor.checkSyntax": "Syntax prüfen", @@ -201,9 +233,16 @@ "editor.categoriesPlaceholder": "Kategorie hinzufügen...", "editor.outlinks": "Ausgehende Links", "editor.backlinks": "Eingehende Links", + "editor.linkedMedia": "Verknüpfte Medien", + "editor.linkedMediaEmpty": "Noch keine verknüpften Medien.", + "editor.linkExistingMedia": "Vorhandenes verknüpfen", + "editor.unlinkMedia": "Verknüpfung lösen", + "editor.linkedMediaKindImage": "Bild", + "editor.linkedMediaKindFile": "Datei", "editor.untitled": "Ohne Titel", "editor.createdAt": "Erstellt", "editor.updatedAt": "Aktualisiert", + "editor.publishedAt": "Veröffentlicht", "tags.noTags": "Noch keine Tags", "tags.editTag": "Tag bearbeiten", "tags.name": "Name", diff --git a/locales/ui/en.json b/locales/ui/en.json index e8d15cb..6c3d5e8 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -42,6 +42,7 @@ "activity.import": "Import", "activity.sourceControl": "Source Control", "common.save": "Save", + "common.open": "Open", "common.cancel": "Cancel", "common.settings": "Settings", "common.tasks": "Tasks", @@ -166,6 +167,31 @@ "modal.confirm.title": "Confirm", "modal.confirm.cancel": "Cancel", "modal.confirm.confirm": "Confirm", + "modal.postInsertLink.title": "Insert Link", + "modal.postInsertLink.description": "Select a post to link to.", + "modal.postInsertLink.cancel": "Cancel", + "modal.postInsertLink.tabInternal": "Internal", + "modal.postInsertLink.tabExternal": "External", + "modal.postInsertLink.searchPlaceholder": "Search posts...", + "modal.postInsertLink.emptyState": "Start typing to search posts or use related posts", + "modal.postInsertLink.createPost": "Create Post", + "modal.postInsertLink.insert": "Insert", + "modal.postInsertLink.externalUrlPlaceholder": "https://example.com", + "modal.postInsertLink.externalTextPlaceholder": "Display text (optional)", + "modal.postInsertLink.urlRequired": "A URL is required.", + "modal.postInsertLink.titleRequired": "Enter a title to create a linked post.", + "modal.postInsertLink.createFailed": "Failed to create the linked post.", + "modal.postInsertLink.loadFailed": "Failed to load the selected post.", + "modal.insertMedia.title": "Insert Media", + "modal.insertMedia.description": "Select a media item to insert.", + "modal.insertMedia.cancel": "Cancel", + "modal.insertMedia.searchPlaceholder": "Search media...", + "modal.insertMedia.loadFailed": "Failed to load the selected media item.", + "modal.postGallery.title": "Media Gallery", + "modal.postGallery.description": "Click an image to view full size.", + "modal.postGallery.close": "Close", + "modal.postGallery.backToGrid": "Back to Grid", + "modal.postGallery.unavailable": "Preview unavailable", "sidebar.filter.search": "Search...", "sidebar.filter.toggle": "Filters", "sidebar.filter.clearAll": "Clear All Filters", @@ -182,9 +208,12 @@ "editor.excerpt": "Excerpt", "editor.excerptPlaceholder": "Brief summary...", "editor.author": "Author", + "editor.language": "Language", "editor.templateSlug": "Template", "editor.doNotTranslate": "Do Not Translate", "editor.publish": "Publish", + "editor.discard": "Discard", + "editor.duplicate": "Duplicate", "editor.validate": "Validate", "editor.run": "Run", "editor.checkSyntax": "Check Syntax", @@ -201,9 +230,19 @@ "editor.categoriesPlaceholder": "Add category...", "editor.outlinks": "Outgoing Links", "editor.backlinks": "Incoming Links", + "editor.linkedMedia": "Linked Media", + "editor.linkedMediaEmpty": "No linked media yet.", + "editor.linkExistingMedia": "Link Existing", + "editor.unlinkMedia": "Unlink", + "editor.linkedMediaKindImage": "Image", + "editor.linkedMediaKindFile": "File", "editor.untitled": "Untitled", "editor.createdAt": "Created", "editor.updatedAt": "Updated", + "editor.publishedAt": "Published", + "editor.insertLink": "Insert Link", + "editor.insertMedia": "Insert Media", + "editor.gallery": "Gallery", "tags.noTags": "No tags yet", "tags.editTag": "Edit Tag", "tags.name": "Name", diff --git a/locales/ui/es.json b/locales/ui/es.json index 59d12d1..5669b96 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -42,6 +42,7 @@ "activity.import": "Importar", "activity.sourceControl": "Control de código fuente", "common.save": "Guardar", + "common.open": "Abrir", "common.cancel": "Cancelar", "common.settings": "Configuración", "common.tasks": "Tareas", @@ -166,9 +167,37 @@ "modal.confirm.title": "Confirmar", "modal.confirm.cancel": "Cancelar", "modal.confirm.confirm": "Confirmar", + "modal.postInsertLink.title": "Insertar enlace", + "modal.postInsertLink.description": "Selecciona una entrada a la que vincular.", + "modal.postInsertLink.cancel": "Cancelar", + "modal.postInsertLink.tabInternal": "Internos", + "modal.postInsertLink.tabExternal": "Externos", + "modal.postInsertLink.searchPlaceholder": "Buscar entradas...", + "modal.postInsertLink.emptyState": "Escribe para buscar entradas o usa entradas relacionadas", + "modal.postInsertLink.createPost": "Crear entrada", + "modal.postInsertLink.insert": "Insertar", + "modal.postInsertLink.externalUrlPlaceholder": "https://example.com", + "modal.postInsertLink.externalTextPlaceholder": "Texto mostrado (opcional)", + "modal.postInsertLink.urlRequired": "Se requiere una URL.", + "modal.postInsertLink.titleRequired": "Introduce un título para crear una entrada enlazada.", + "modal.postInsertLink.createFailed": "No se pudo crear la entrada enlazada.", + "modal.postInsertLink.loadFailed": "No se pudo cargar la entrada seleccionada.", + "modal.insertMedia.title": "Insertar medio", + "modal.insertMedia.description": "Selecciona un medio para insertar.", + "modal.insertMedia.cancel": "Cancelar", + "modal.insertMedia.searchPlaceholder": "Buscar medios...", + "modal.insertMedia.loadFailed": "No se pudo cargar el medio seleccionado.", + "modal.postGallery.title": "Galería de medios", + "modal.postGallery.description": "Haz clic en una imagen para verla a tamaño completo.", + "modal.postGallery.close": "Cerrar", + "modal.postGallery.backToGrid": "Volver a la cuadrícula", + "modal.postGallery.unavailable": "Vista previa no disponible", "sidebar.filter.search": "Buscar...", "sidebar.filter.toggle": "Filtros", "sidebar.filter.clearAll": "Borrar todos los filtros", + "editor.insertLink": "Insertar enlace", + "editor.insertMedia": "Insertar medio", + "editor.gallery": "Galería", "sidebar.filter.tags": "Etiquetas", "sidebar.filter.categories": "Categorías", "sidebar.filter.calendar": "Archivo", @@ -182,9 +211,12 @@ "editor.excerpt": "Extracto", "editor.excerptPlaceholder": "Breve resumen...", "editor.author": "Autor", + "editor.language": "Idioma", "editor.templateSlug": "Plantilla", "editor.doNotTranslate": "No traducir", "editor.publish": "Publicar", + "editor.discard": "Descartar", + "editor.duplicate": "Duplicar", "editor.validate": "Validar", "editor.run": "Ejecutar", "editor.checkSyntax": "Verificar sintaxis", @@ -201,9 +233,16 @@ "editor.categoriesPlaceholder": "Añadir categoría...", "editor.outlinks": "Enlaces salientes", "editor.backlinks": "Enlaces entrantes", + "editor.linkedMedia": "Medios vinculados", + "editor.linkedMediaEmpty": "Todavía no hay medios vinculados.", + "editor.linkExistingMedia": "Vincular existente", + "editor.unlinkMedia": "Desvincular", + "editor.linkedMediaKindImage": "Imagen", + "editor.linkedMediaKindFile": "Archivo", "editor.untitled": "Sin título", "editor.createdAt": "Creado", "editor.updatedAt": "Actualizado", + "editor.publishedAt": "Publicado", "tags.noTags": "Sin etiquetas", "tags.editTag": "Editar etiqueta", "tags.name": "Nombre", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index 7adae91..be27b57 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -42,6 +42,7 @@ "activity.import": "Importation", "activity.sourceControl": "Contrôle de source", "common.save": "Enregistrer", + "common.open": "Ouvrir", "common.cancel": "Annuler", "common.settings": "Paramètres", "common.tasks": "Tâches", @@ -166,9 +167,37 @@ "modal.confirm.title": "Confirmer", "modal.confirm.cancel": "Annuler", "modal.confirm.confirm": "Confirmer", + "modal.postInsertLink.title": "Insérer un lien", + "modal.postInsertLink.description": "Sélectionnez un article à lier.", + "modal.postInsertLink.cancel": "Annuler", + "modal.postInsertLink.tabInternal": "Interne", + "modal.postInsertLink.tabExternal": "Externe", + "modal.postInsertLink.searchPlaceholder": "Rechercher les articles...", + "modal.postInsertLink.emptyState": "Commencez à taper pour rechercher des articles ou utilisez des articles similaires", + "modal.postInsertLink.createPost": "Créer l'article", + "modal.postInsertLink.insert": "Insérer", + "modal.postInsertLink.externalUrlPlaceholder": "https://example.com", + "modal.postInsertLink.externalTextPlaceholder": "Texte affiché (optionnel)", + "modal.postInsertLink.urlRequired": "Une URL est requise.", + "modal.postInsertLink.titleRequired": "Saisissez un titre pour créer un article lié.", + "modal.postInsertLink.createFailed": "Impossible de créer l'article lié.", + "modal.postInsertLink.loadFailed": "Impossible de charger l'article sélectionné.", + "modal.insertMedia.title": "Insérer un média", + "modal.insertMedia.description": "Sélectionnez un média à insérer.", + "modal.insertMedia.cancel": "Annuler", + "modal.insertMedia.searchPlaceholder": "Rechercher les médias...", + "modal.insertMedia.loadFailed": "Impossible de charger le média sélectionné.", + "modal.postGallery.title": "Galerie média", + "modal.postGallery.description": "Cliquez sur une image pourvoir en taille réelle.", + "modal.postGallery.close": "Fermer", + "modal.postGallery.backToGrid": "Retour à la grille", + "modal.postGallery.unavailable": "Aperçu indisponible", "sidebar.filter.search": "Rechercher...", "sidebar.filter.toggle": "Filtres", "sidebar.filter.clearAll": "Effacer tous les filtres", + "editor.insertLink": "Insérer un lien", + "editor.insertMedia": "Insérer un média", + "editor.gallery": "Galerie", "sidebar.filter.tags": "Tags", "sidebar.filter.categories": "Catégories", "sidebar.filter.calendar": "Archives", @@ -182,9 +211,12 @@ "editor.excerpt": "Extrait", "editor.excerptPlaceholder": "Bref résumé...", "editor.author": "Auteur", + "editor.language": "Langue", "editor.templateSlug": "Modèle", "editor.doNotTranslate": "Ne pas traduire", "editor.publish": "Publier", + "editor.discard": "Annuler les modifications", + "editor.duplicate": "Dupliquer", "editor.validate": "Valider", "editor.run": "Exécuter", "editor.checkSyntax": "Vérifier la syntaxe", @@ -201,9 +233,16 @@ "editor.categoriesPlaceholder": "Ajouter une catégorie...", "editor.outlinks": "Liens sortants", "editor.backlinks": "Liens entrants", + "editor.linkedMedia": "Médias liés", + "editor.linkedMediaEmpty": "Aucun média lié pour le moment.", + "editor.linkExistingMedia": "Lier un existant", + "editor.unlinkMedia": "Dissocier", + "editor.linkedMediaKindImage": "Image", + "editor.linkedMediaKindFile": "Fichier", "editor.untitled": "Sans titre", "editor.createdAt": "Créé", "editor.updatedAt": "Mis à jour", + "editor.publishedAt": "Publié", "tags.noTags": "Aucun tag", "tags.editTag": "Modifier le tag", "tags.name": "Nom", diff --git a/locales/ui/it.json b/locales/ui/it.json index 7ee6a6d..5c97e78 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -42,6 +42,7 @@ "activity.import": "Importa", "activity.sourceControl": "Controllo sorgente", "common.save": "Salva", + "common.open": "Apri", "common.cancel": "Annulla", "common.settings": "Impostazioni", "common.tasks": "Attività", @@ -166,9 +167,37 @@ "modal.confirm.title": "Conferma", "modal.confirm.cancel": "Annulla", "modal.confirm.confirm": "Conferma", + "modal.postInsertLink.title": "Inserisci link", + "modal.postInsertLink.description": "Seleziona un post a cui collegarti.", + "modal.postInsertLink.cancel": "Annulla", + "modal.postInsertLink.tabInternal": "Interno", + "modal.postInsertLink.tabExternal": "Esterno", + "modal.postInsertLink.searchPlaceholder": "Cerca post...", + "modal.postInsertLink.emptyState": "Digita per cercare post o usa post correlati", + "modal.postInsertLink.createPost": "Crea post", + "modal.postInsertLink.insert": "Inserisci", + "modal.postInsertLink.externalUrlPlaceholder": "https://example.com", + "modal.postInsertLink.externalTextPlaceholder": "Testo visualizzato (opzionale)", + "modal.postInsertLink.urlRequired": "È richiesto un URL.", + "modal.postInsertLink.titleRequired": "Inserisci un titolo per creare un post collegato.", + "modal.postInsertLink.createFailed": "Impossibile creare il post collegato.", + "modal.postInsertLink.loadFailed": "Impossibile caricare il post selezionato.", + "modal.insertMedia.title": "Inserisci media", + "modal.insertMedia.description": "Seleziona un elemento media da inserire.", + "modal.insertMedia.cancel": "Annulla", + "modal.insertMedia.searchPlaceholder": "Cerca media...", + "modal.insertMedia.loadFailed": "Impossibile caricare l'elemento media selezionato.", + "modal.postGallery.title": "Galleria media", + "modal.postGallery.description": "Clicca un'immagine per vederla a grandezza piena.", + "modal.postGallery.close": "Chiudi", + "modal.postGallery.backToGrid": "Torna alla griglia", + "modal.postGallery.unavailable": "Anteprima non disponibile", "sidebar.filter.search": "Cerca...", "sidebar.filter.toggle": "Filtri", "sidebar.filter.clearAll": "Cancella tutti i filtri", + "editor.insertLink": "Inserisci link", + "editor.insertMedia": "Inserisci media", + "editor.gallery": "Galleria", "sidebar.filter.tags": "Tag", "sidebar.filter.categories": "Categorie", "sidebar.filter.calendar": "Archivio", @@ -182,9 +211,12 @@ "editor.excerpt": "Estratto", "editor.excerptPlaceholder": "Breve riassunto...", "editor.author": "Autore", + "editor.language": "Lingua", "editor.templateSlug": "Modello", "editor.doNotTranslate": "Non tradurre", "editor.publish": "Pubblica", + "editor.discard": "Scarta", + "editor.duplicate": "Duplica", "editor.validate": "Valida", "editor.run": "Esegui", "editor.checkSyntax": "Controlla sintassi", @@ -201,9 +233,16 @@ "editor.categoriesPlaceholder": "Aggiungi categoria...", "editor.outlinks": "Link in uscita", "editor.backlinks": "Link in entrata", + "editor.linkedMedia": "Media collegati", + "editor.linkedMediaEmpty": "Nessun media collegato.", + "editor.linkExistingMedia": "Collega esistente", + "editor.unlinkMedia": "Scollega", + "editor.linkedMediaKindImage": "Immagine", + "editor.linkedMediaKindFile": "File", "editor.untitled": "Senza titolo", "editor.createdAt": "Creato", "editor.updatedAt": "Aggiornato", + "editor.publishedAt": "Pubblicato", "tags.noTags": "Nessun tag", "tags.editTag": "Modifica tag", "tags.name": "Nome",