fix: more finishing work for M3
This commit is contained in:
@@ -11,10 +11,12 @@ use crate::db::queries::media_translation as qmt;
|
|||||||
use crate::db::queries::post_media as qpm;
|
use crate::db::queries::post_media as qpm;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{Media, MediaTranslation};
|
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::{
|
use crate::util::thumbnail::{
|
||||||
generate_all_thumbnails, image_dimensions, mime_from_extension,
|
generate_all_thumbnails, image_dimensions, mime_from_extension, ThumbnailFormat,
|
||||||
ThumbnailFormat, THUMBNAIL_SIZES,
|
THUMBNAIL_SIZES,
|
||||||
};
|
};
|
||||||
use crate::util::{
|
use crate::util::{
|
||||||
atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
|
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).
|
/// Supported image MIME types for import (per media_processing.allium).
|
||||||
const SUPPORTED_IMAGE_TYPES: &[&str] = &[
|
const SUPPORTED_IMAGE_TYPES: &[&str] = &[
|
||||||
"image/jpeg", "image/png", "image/gif",
|
"image/jpeg",
|
||||||
"image/webp", "image/tiff", "image/bmp",
|
"image/png",
|
||||||
"image/heic", "image/heif",
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/tiff",
|
||||||
|
"image/bmp",
|
||||||
|
"image/heic",
|
||||||
|
"image/heif",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Import a media file (image, etc.) into the project.
|
/// 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.
|
/// Delete a media item and all related artifacts.
|
||||||
pub fn delete_media(
|
pub fn delete_media(conn: &Connection, data_dir: &Path, media_id: &str) -> EngineResult<()> {
|
||||||
conn: &Connection,
|
|
||||||
data_dir: &Path,
|
|
||||||
media_id: &str,
|
|
||||||
) -> EngineResult<()> {
|
|
||||||
let media = qm::get_media_by_id(conn, media_id)?;
|
let media = qm::get_media_by_id(conn, media_id)?;
|
||||||
|
|
||||||
// Delete binary file
|
// Delete binary file
|
||||||
@@ -363,10 +366,7 @@ pub fn rebuild_media_from_filesystem_with_progress(
|
|||||||
let mut canonical_sidecars = Vec::new();
|
let mut canonical_sidecars = Vec::new();
|
||||||
let mut translation_sidecars = Vec::new();
|
let mut translation_sidecars = Vec::new();
|
||||||
|
|
||||||
for entry in WalkDir::new(&media_dir)
|
for entry in WalkDir::new(&media_dir).into_iter().filter_map(|e| e.ok()) {
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
{
|
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
continue;
|
continue;
|
||||||
@@ -931,7 +931,10 @@ mod tests {
|
|||||||
delete_media_translation(db.conn(), dir.path(), &media.id, "de").unwrap();
|
delete_media_translation(db.conn(), dir.path(), &media.id, "de").unwrap();
|
||||||
|
|
||||||
// Sidecar should be gone
|
// 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
|
// DB entry should be gone
|
||||||
assert!(
|
assert!(
|
||||||
@@ -964,7 +967,11 @@ createdAt: 2024-01-15T12:00:00.000Z
|
|||||||
updatedAt: 2024-01-15T12:00:00.000Z
|
updatedAt: 2024-01-15T12:00:00.000Z
|
||||||
tags: [\"test\"]
|
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
|
// Write a translation sidecar
|
||||||
let trans_sidecar_content = "\
|
let trans_sidecar_content = "\
|
||||||
|
|||||||
@@ -182,11 +182,7 @@ pub fn update_post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a post: write file, clear content, set published_at.
|
/// Publish a post: write file, clear content, set published_at.
|
||||||
pub fn publish_post(
|
pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
|
||||||
conn: &Connection,
|
|
||||||
data_dir: &Path,
|
|
||||||
post_id: &str,
|
|
||||||
) -> EngineResult<Post> {
|
|
||||||
let mut post = qp::get_post_by_id(conn, post_id)?;
|
let mut post = qp::get_post_by_id(conn, post_id)?;
|
||||||
|
|
||||||
// Require Draft or Archived status
|
// Require Draft or Archived status
|
||||||
@@ -211,8 +207,7 @@ pub fn publish_post(
|
|||||||
c.clone()
|
c.clone()
|
||||||
} else if abs_path.exists() {
|
} else if abs_path.exists() {
|
||||||
let file_content = fs::read_to_string(&abs_path)?;
|
let file_content = fs::read_to_string(&abs_path)?;
|
||||||
let (_fm, body) = read_post_file(&file_content)
|
let (_fm, body) = read_post_file(&file_content).map_err(|e| EngineError::Parse(e))?;
|
||||||
.map_err(|e| EngineError::Parse(e))?;
|
|
||||||
body
|
body
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -235,8 +230,7 @@ pub fn publish_post(
|
|||||||
|
|
||||||
// Set published snapshot fields
|
// Set published snapshot fields
|
||||||
let tags_json = serde_json::to_string(&post.tags).unwrap_or_else(|_| "[]".into());
|
let tags_json = serde_json::to_string(&post.tags).unwrap_or_else(|_| "[]".into());
|
||||||
let cats_json =
|
let cats_json = serde_json::to_string(&post.categories).unwrap_or_else(|_| "[]".into());
|
||||||
serde_json::to_string(&post.categories).unwrap_or_else(|_| "[]".into());
|
|
||||||
post.published_title = Some(post.title.clone());
|
post.published_title = Some(post.title.clone());
|
||||||
post.published_content = Some(body.clone());
|
post.published_content = Some(body.clone());
|
||||||
post.published_tags = Some(tags_json.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)
|
// 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);
|
let abs_path = data_dir.join(&post.file_path);
|
||||||
if abs_path.exists() {
|
if abs_path.exists() {
|
||||||
if let Ok(file_content) = fs::read_to_string(&abs_path) {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a post and all related data.
|
/// Discard unpublished draft changes and restore the published version.
|
||||||
pub fn delete_post(
|
pub fn discard_post_draft(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
post_id: &str,
|
post_id: &str,
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<Post> {
|
||||||
|
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::<Vec<String>>(s).ok())
|
||||||
|
.unwrap_or_else(|| post.tags.clone()),
|
||||||
|
post.published_categories
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<Vec<String>>(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::<Vec<String>>(s).ok())
|
||||||
|
.unwrap_or_else(|| post.tags.clone()),
|
||||||
|
post.published_categories
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<Vec<String>>(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<Post> {
|
||||||
|
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)?;
|
let post = qp::get_post_by_id(conn, post_id)?;
|
||||||
|
|
||||||
// Delete .md file if exists
|
// 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 canonical_files = Vec::new();
|
||||||
let mut translation_files = Vec::new();
|
let mut translation_files = Vec::new();
|
||||||
|
|
||||||
for entry in WalkDir::new(&posts_dir)
|
for entry in WalkDir::new(&posts_dir).into_iter().filter_map(|e| e.ok()) {
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
{
|
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
continue;
|
continue;
|
||||||
@@ -659,10 +795,14 @@ fn parse_post_links(content: &str) -> Vec<(String, String)> {
|
|||||||
let url = &line[url_start..url_start + paren_end];
|
let url = &line[url_start..url_start + paren_end];
|
||||||
// Check if URL matches /YYYY/MM/DD/slug pattern
|
// Check if URL matches /YYYY/MM/DD/slug pattern
|
||||||
let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect();
|
let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect();
|
||||||
if parts.len() == 5 && parts[0].is_empty()
|
if parts.len() == 5
|
||||||
&& parts[1].len() == 4 && parts[1].chars().all(|c| c.is_ascii_digit())
|
&& parts[0].is_empty()
|
||||||
&& parts[2].len() == 2 && parts[2].chars().all(|c| c.is_ascii_digit())
|
&& parts[1].len() == 4
|
||||||
&& parts[3].len() == 2 && parts[3].chars().all(|c| c.is_ascii_digit())
|
&& 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()));
|
links.push((parts[4].to_string(), link_text.to_string()));
|
||||||
}
|
}
|
||||||
@@ -862,8 +1002,7 @@ fn rebuild_translation(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
) -> EngineResult<bool> {
|
) -> EngineResult<bool> {
|
||||||
let content = fs::read_to_string(path)?;
|
let content = fs::read_to_string(path)?;
|
||||||
let (fm, body) =
|
let (fm, body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
|
||||||
read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
|
|
||||||
|
|
||||||
let rel_path = path
|
let rel_path = path
|
||||||
.strip_prefix(data_dir)
|
.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:  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 = 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<Vec<String>> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
let links = ql::list_links_by_target(conn, post_id)?;
|
||||||
|
Ok(links.into_iter().map(|l| l.source_post_id).collect())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1181,6 +1358,103 @@ mod tests {
|
|||||||
assert!(file_content.contains("Publish Me"));
|
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]
|
#[test]
|
||||||
fn publish_preserves_published_at_on_republish() {
|
fn publish_preserves_published_at_on_republish() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
@@ -1388,8 +1662,7 @@ mod tests {
|
|||||||
fs::write(posts_dir.join("rebuilt-post.de.md"), trans_content).unwrap();
|
fs::write(posts_dir.join("rebuilt-post.de.md"), trans_content).unwrap();
|
||||||
|
|
||||||
// Run rebuild
|
// Run rebuild
|
||||||
let report =
|
let report = rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.posts_created, 1);
|
assert_eq!(report.posts_created, 1);
|
||||||
assert_eq!(report.translations_created, 1);
|
assert_eq!(report.translations_created, 1);
|
||||||
@@ -1402,17 +1675,13 @@ mod tests {
|
|||||||
assert_eq!(post.tags, vec!["test"]);
|
assert_eq!(post.tags, vec!["test"]);
|
||||||
|
|
||||||
// Verify translation in DB
|
// Verify translation in DB
|
||||||
let trans = qt::get_post_translation_by_post_and_language(
|
let trans =
|
||||||
db.conn(),
|
qt::get_post_translation_by_post_and_language(db.conn(), "rebuild-post-1", "de")
|
||||||
"rebuild-post-1",
|
.unwrap();
|
||||||
"de",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(trans.title, "Wiederhergestellter Beitrag");
|
assert_eq!(trans.title, "Wiederhergestellter Beitrag");
|
||||||
|
|
||||||
// Run rebuild again - should update, not create
|
// Run rebuild again - should update, not create
|
||||||
let report2 =
|
let report2 = rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
||||||
rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
||||||
assert_eq!(report2.posts_created, 0);
|
assert_eq!(report2.posts_created, 0);
|
||||||
assert_eq!(report2.posts_updated, 1);
|
assert_eq!(report2.posts_updated, 1);
|
||||||
assert_eq!(report2.translations_created, 0);
|
assert_eq!(report2.translations_created, 0);
|
||||||
@@ -1436,17 +1705,44 @@ mod tests {
|
|||||||
fn do_not_translate_guard_rejects_translation() {
|
fn do_not_translate_guard_rejects_translation() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let post = create_post(
|
let post = create_post(
|
||||||
db.conn(), dir.path(), "p1", "No Translate", Some("body"),
|
db.conn(),
|
||||||
vec![], vec![], None, None, None,
|
dir.path(),
|
||||||
).unwrap();
|
"p1",
|
||||||
|
"No Translate",
|
||||||
|
Some("body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
// Set do_not_translate
|
// Set do_not_translate
|
||||||
update_post(
|
update_post(
|
||||||
db.conn(), dir.path(), &post.id,
|
db.conn(),
|
||||||
None, None, None, None, None, None, None, None, None, Some(true),
|
dir.path(),
|
||||||
).unwrap();
|
&post.id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(true),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let result = upsert_translation(
|
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());
|
assert!(result.is_err());
|
||||||
match result.unwrap_err() {
|
match result.unwrap_err() {
|
||||||
@@ -1459,17 +1755,46 @@ mod tests {
|
|||||||
fn update_post_slug_uniqueness_enforced() {
|
fn update_post_slug_uniqueness_enforced() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
create_post(
|
create_post(
|
||||||
db.conn(), dir.path(), "p1", "First", Some("body"),
|
db.conn(),
|
||||||
vec![], vec![], None, None, None,
|
dir.path(),
|
||||||
).unwrap();
|
"p1",
|
||||||
|
"First",
|
||||||
|
Some("body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let second = create_post(
|
let second = create_post(
|
||||||
db.conn(), dir.path(), "p1", "Second", Some("body"),
|
db.conn(),
|
||||||
vec![], vec![], None, None, None,
|
dir.path(),
|
||||||
).unwrap();
|
"p1",
|
||||||
|
"Second",
|
||||||
|
Some("body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let result = update_post(
|
let result = update_post(
|
||||||
db.conn(), dir.path(), &second.id,
|
db.conn(),
|
||||||
None, Some("first"), None, None, None, None, None, None, None, None,
|
dir.path(),
|
||||||
|
&second.id,
|
||||||
|
None,
|
||||||
|
Some("first"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
@@ -1478,9 +1803,18 @@ mod tests {
|
|||||||
fn update_published_post_transitions_to_draft() {
|
fn update_published_post_transitions_to_draft() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let post = create_post(
|
let post = create_post(
|
||||||
db.conn(), dir.path(), "p1", "Published", Some("body"),
|
db.conn(),
|
||||||
vec![], vec![], None, None, None,
|
dir.path(),
|
||||||
).unwrap();
|
"p1",
|
||||||
|
"Published",
|
||||||
|
Some("body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
// Archive then update to test auto-draft
|
// Archive then update to test auto-draft
|
||||||
@@ -1490,9 +1824,21 @@ mod tests {
|
|||||||
|
|
||||||
// Now update the published post
|
// Now update the published post
|
||||||
let updated = update_post(
|
let updated = update_post(
|
||||||
db.conn(), dir.path(), &post.id,
|
db.conn(),
|
||||||
Some("New Title"), None, None, Some("new body"), None, None, None, None, None, None,
|
dir.path(),
|
||||||
).unwrap();
|
&post.id,
|
||||||
|
Some("New Title"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("new body"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(updated.status, PostStatus::Draft);
|
assert_eq!(updated.status, PostStatus::Draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1507,9 +1853,18 @@ mod tests {
|
|||||||
fn publish_post_already_published_rejected() {
|
fn publish_post_already_published_rejected() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let post = create_post(
|
let post = create_post(
|
||||||
db.conn(), dir.path(), "p1", "Double Pub", Some("body"),
|
db.conn(),
|
||||||
vec![], vec![], None, None, None,
|
dir.path(),
|
||||||
).unwrap();
|
"p1",
|
||||||
|
"Double Pub",
|
||||||
|
Some("body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
let result = publish_post(db.conn(), dir.path(), &post.id);
|
let result = publish_post(db.conn(), dir.path(), &post.id);
|
||||||
@@ -1525,18 +1880,36 @@ mod tests {
|
|||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
// Create target post first
|
// Create target post first
|
||||||
let target = create_post(
|
let target = create_post(
|
||||||
db.conn(), dir.path(), "p1", "Target Post", Some("target body"),
|
db.conn(),
|
||||||
vec![], vec![], None, None, None,
|
dir.path(),
|
||||||
).unwrap();
|
"p1",
|
||||||
|
"Target Post",
|
||||||
|
Some("target body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
publish_post(db.conn(), dir.path(), &target.id).unwrap();
|
publish_post(db.conn(), dir.path(), &target.id).unwrap();
|
||||||
|
|
||||||
// Create source post with a link to target
|
// Create source post with a link to target
|
||||||
let target_url = canonical_url(target.created_at, &target.slug);
|
let target_url = canonical_url(target.created_at, &target.slug);
|
||||||
let body = format!("Check out [this post]({target_url}) for more.");
|
let body = format!("Check out [this post]({target_url}) for more.");
|
||||||
let source = create_post(
|
let source = create_post(
|
||||||
db.conn(), dir.path(), "p1", "Source Post", Some(&body),
|
db.conn(),
|
||||||
vec![], vec![], None, None, None,
|
dir.path(),
|
||||||
).unwrap();
|
"p1",
|
||||||
|
"Source Post",
|
||||||
|
Some(&body),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
publish_post(db.conn(), dir.path(), &source.id).unwrap();
|
publish_post(db.conn(), dir.path(), &source.id).unwrap();
|
||||||
|
|
||||||
// Verify link was created
|
// Verify link was created
|
||||||
@@ -1549,9 +1922,18 @@ mod tests {
|
|||||||
fn archive_from_published_status() {
|
fn archive_from_published_status() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let post = create_post(
|
let post = create_post(
|
||||||
db.conn(), dir.path(), "p1", "To Archive", Some("body"),
|
db.conn(),
|
||||||
vec![], vec![], None, None, None,
|
dir.path(),
|
||||||
).unwrap();
|
"p1",
|
||||||
|
"To Archive",
|
||||||
|
Some("body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
archive_post(db.conn(), dir.path(), &post.id).unwrap();
|
archive_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ use rusqlite::Connection;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::queries::media as qm;
|
use crate::db::queries::media as qm;
|
||||||
|
use crate::db::queries::post as qp;
|
||||||
use crate::db::queries::post_media as qpm;
|
use crate::db::queries::post_media as qpm;
|
||||||
use crate::engine::EngineResult;
|
use crate::engine::EngineResult;
|
||||||
use crate::model::PostMedia;
|
use crate::model::{Media, Post, PostMedia};
|
||||||
use crate::util::sidecar::MediaSidecar;
|
use crate::util::sidecar::MediaSidecar;
|
||||||
use crate::util::{atomic_write_str, now_unix_ms};
|
use crate::util::{atomic_write_str, now_unix_ms};
|
||||||
|
|
||||||
@@ -59,6 +60,30 @@ pub fn reorder_post_media(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List media items currently linked to a post.
|
||||||
|
pub fn list_media_for_post(conn: &Connection, post_id: &str) -> EngineResult<Vec<Media>> {
|
||||||
|
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<Vec<Post>> {
|
||||||
|
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
|
/// Rebuild the media sidecar file so that `linkedPostIds` reflects the current
|
||||||
/// set of posts linked to this media item.
|
/// set of posts linked to this media item.
|
||||||
fn sync_sidecar_linked_post_ids(
|
fn sync_sidecar_linked_post_ids(
|
||||||
@@ -193,4 +218,28 @@ mod tests {
|
|||||||
assert!(ids.contains(&"post1".to_string()));
|
assert!(ids.contains(&"post1".to_string()));
|
||||||
assert!(ids.contains(&"post2".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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 SCROLLBAR_THUMB_HOVER: Color = Color::from_rgba(0.60, 0.63, 0.70, 0.9);
|
||||||
const MIN_THUMB_HEIGHT: f32 = 20.0;
|
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.
|
/// Convert syntect RGBA color to Iced Color.
|
||||||
fn syntect_to_iced(c: syntect::highlighting::Color) -> Color {
|
fn syntect_to_iced(c: syntect::highlighting::Color) -> Color {
|
||||||
Color::from_rgba(
|
Color::from_rgba(
|
||||||
@@ -761,12 +774,13 @@ where
|
|||||||
return Status::Captured;
|
return Status::Captured;
|
||||||
}
|
}
|
||||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
key, modifiers, ..
|
key, modifiers, text, ..
|
||||||
}) if state.is_focused => {
|
}) if state.is_focused => {
|
||||||
let vis = (bounds.height / metrics.line_height) as usize;
|
let vis = (bounds.height / metrics.line_height) as usize;
|
||||||
let is_cmd = modifiers.command();
|
let is_cmd = modifiers.command();
|
||||||
let is_shift = modifiers.shift();
|
let is_shift = modifiers.shift();
|
||||||
let is_alt = modifiers.alt();
|
let is_alt = modifiers.alt();
|
||||||
|
let committed_text = committed_text_input(text.as_deref(), is_cmd);
|
||||||
|
|
||||||
// Helper: ensure cursor visible accounting for word wrap.
|
// Helper: ensure cursor visible accounting for word wrap.
|
||||||
// Must be called while buf is still borrowed mutably.
|
// Must be called while buf is still borrowed mutably.
|
||||||
@@ -953,10 +967,10 @@ where
|
|||||||
}
|
}
|
||||||
self.emit_change(shell);
|
self.emit_change(shell);
|
||||||
}
|
}
|
||||||
keyboard::Key::Character(ref c) if !is_cmd => {
|
_ if committed_text.is_some() => {
|
||||||
{
|
{
|
||||||
let mut buf = self.buffer.borrow_mut();
|
let mut buf = self.buffer.borrow_mut();
|
||||||
buf.insert(c);
|
buf.insert(committed_text.expect("committed text already checked"));
|
||||||
ensure_vis!(buf);
|
ensure_vis!(buf);
|
||||||
}
|
}
|
||||||
self.emit_change(shell);
|
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<CodeEditor<'a, Message>> for Element<'a, Message, Theme, Renderer>
|
impl<'a, Message, Renderer> From<CodeEditor<'a, Message>> for Element<'a, Message, Theme, Renderer>
|
||||||
where
|
where
|
||||||
Renderer: renderer::Renderer + text::Renderer<Font = iced::Font> + 'a,
|
Renderer: renderer::Renderer + text::Renderer<Font = iced::Font> + 'a,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ pub struct MediaEditorState {
|
|||||||
pub language: String,
|
pub language: String,
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
|
pub tags_input: String,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
pub is_dirty: bool,
|
pub is_dirty: bool,
|
||||||
@@ -79,6 +80,7 @@ impl MediaEditorState {
|
|||||||
language: canonical_lang.clone(),
|
language: canonical_lang.clone(),
|
||||||
file_path: media.file_path.clone(),
|
file_path: media.file_path.clone(),
|
||||||
tags: media.tags.clone(),
|
tags: media.tags.clone(),
|
||||||
|
tags_input: media.tags.join(", "),
|
||||||
created_at: media.created_at,
|
created_at: media.created_at,
|
||||||
updated_at: media.updated_at,
|
updated_at: media.updated_at,
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
@@ -169,6 +171,8 @@ pub enum MediaEditorMsg {
|
|||||||
AltChanged(String),
|
AltChanged(String),
|
||||||
CaptionChanged(String),
|
CaptionChanged(String),
|
||||||
AuthorChanged(String),
|
AuthorChanged(String),
|
||||||
|
LanguageChanged(String),
|
||||||
|
TagsChanged(String),
|
||||||
SwitchLanguage(String),
|
SwitchLanguage(String),
|
||||||
Save,
|
Save,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -284,7 +288,25 @@ pub fn view<'a>(
|
|||||||
&state.author,
|
&state.author,
|
||||||
|s| Message::MediaEditor(MediaEditorMsg::AuthorChanged(s)),
|
|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_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
|
// Footer
|
||||||
let footer = row![
|
let footer = row![
|
||||||
@@ -303,6 +325,7 @@ pub fn view<'a>(
|
|||||||
inputs::section_header(&t(locale, "editor.metadata")),
|
inputs::section_header(&t(locale, "editor.metadata")),
|
||||||
meta_row1,
|
meta_row1,
|
||||||
meta_row2,
|
meta_row2,
|
||||||
|
meta_row3,
|
||||||
footer,
|
footer,
|
||||||
]
|
]
|
||||||
.spacing(12)
|
.spacing(12)
|
||||||
|
|||||||
@@ -15,4 +15,3 @@ pub mod script_editor;
|
|||||||
pub mod tags_view;
|
pub mod tags_view;
|
||||||
pub mod settings_view;
|
pub mod settings_view;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod translation_editor;
|
|
||||||
|
|||||||
@@ -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::text::Shaping;
|
||||||
|
use iced::widget::{button, column, container, image, row, text, text_input, Space};
|
||||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
|
use bds_core::model::Media;
|
||||||
|
use bds_core::util::paths::thumbnail_path;
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::i18n::t;
|
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.
|
/// Active modal state. Only one modal at a time.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -22,6 +35,37 @@ pub enum ModalState {
|
|||||||
message: String,
|
message: String,
|
||||||
on_confirm: ConfirmAction,
|
on_confirm: ConfirmAction,
|
||||||
},
|
},
|
||||||
|
/// Per modals.allium InsertPostLinkModal: Ctrl+K link insertion.
|
||||||
|
PostInsertLink {
|
||||||
|
post_id: String,
|
||||||
|
title: String,
|
||||||
|
results: Vec<InsertLinkResult>,
|
||||||
|
search_query: String,
|
||||||
|
active_tab: PostInsertLinkTab,
|
||||||
|
external_url: String,
|
||||||
|
external_text: String,
|
||||||
|
},
|
||||||
|
/// Per modals.allium InsertMediaModal: grid for inserting media.
|
||||||
|
InsertMedia {
|
||||||
|
post_id: String,
|
||||||
|
title: String,
|
||||||
|
media_list: Vec<bds_core::model::Media>,
|
||||||
|
search_query: String,
|
||||||
|
link_only: bool,
|
||||||
|
},
|
||||||
|
/// Per modals.allium GalleryOverlay: full-screen media gallery.
|
||||||
|
PostGallery {
|
||||||
|
post_id: String,
|
||||||
|
title: String,
|
||||||
|
media_list: Vec<bds_core::model::Media>,
|
||||||
|
selected_index: Option<usize>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum PostInsertLinkTab {
|
||||||
|
Internal,
|
||||||
|
External,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What action to perform when modal is confirmed.
|
/// What action to perform when modal is confirmed.
|
||||||
@@ -32,6 +76,7 @@ pub enum ConfirmAction {
|
|||||||
DeleteMedia(String),
|
DeleteMedia(String),
|
||||||
DeleteScript(String),
|
DeleteScript(String),
|
||||||
DeleteTemplate(String),
|
DeleteTemplate(String),
|
||||||
|
DeleteTag(String),
|
||||||
MergeTags { source: String, target: 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<String> {
|
||||||
|
let data_dir = data_dir?;
|
||||||
|
if !media.mime_type.starts_with("image/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let thumb = data_dir.join(thumbnail_path(&media.id, "medium", "webp"));
|
||||||
|
if thumb.exists() {
|
||||||
|
Some(thumb.to_string_lossy().to_string())
|
||||||
|
} else {
|
||||||
|
let original = data_dir.join(&media.file_path);
|
||||||
|
original
|
||||||
|
.exists()
|
||||||
|
.then(|| original.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_media_file(data_dir: Option<&Path>, media: &Media) -> Option<String> {
|
||||||
|
let data_dir = data_dir?;
|
||||||
|
let path = data_dir.join(&media.file_path);
|
||||||
|
path.exists().then(|| path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn external_link_markdown(url: &str, display_text: &str) -> Option<String> {
|
||||||
|
let trimmed_url = url.trim();
|
||||||
|
if trimmed_url.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let trimmed_text = display_text.trim();
|
||||||
|
if trimmed_text.is_empty() {
|
||||||
|
Some(trimmed_url.to_string())
|
||||||
|
} else {
|
||||||
|
Some(format!("[{trimmed_text}]({trimmed_url})"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn gallery_step(current: usize, len: usize, delta: isize) -> usize {
|
||||||
|
if len == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
((current as isize + delta).rem_euclid(len as isize)) as usize
|
||||||
|
}
|
||||||
|
|
||||||
/// Render the modal overlay.
|
/// 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 {
|
let modal_content: Element<'static, Message> = match state {
|
||||||
ModalState::ConfirmDelete {
|
ModalState::ConfirmDelete {
|
||||||
entity_name,
|
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));
|
.color(Color::from_rgb(0.70, 0.70, 0.75));
|
||||||
|
|
||||||
let mut content_col = column![
|
let mut content_col = column![
|
||||||
row![warning_icon, Space::with_width(8.0), title]
|
row![warning_icon, Space::with_width(8.0), title].align_y(Alignment::Center),
|
||||||
.align_y(Alignment::Center),
|
|
||||||
Space::with_height(12.0),
|
Space::with_height(12.0),
|
||||||
entity_label,
|
entity_label,
|
||||||
warning_text,
|
warning_text,
|
||||||
@@ -164,18 +251,18 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> {
|
|||||||
.size(13)
|
.size(13)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
)
|
)
|
||||||
.on_press(Message::DismissModal)
|
.on_press(Message::DismissModal)
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.style(cancel_button_style),
|
.style(cancel_button_style),
|
||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
button(
|
button(
|
||||||
text(t(locale, "modal.confirmDelete.delete"))
|
text(t(locale, "modal.confirmDelete.delete"))
|
||||||
.size(13)
|
.size(13)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
)
|
)
|
||||||
.on_press(Message::ConfirmModal(on_confirm_clone))
|
.on_press(Message::ConfirmModal(on_confirm_clone))
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.style(danger_button_style),
|
.style(danger_button_style),
|
||||||
];
|
];
|
||||||
|
|
||||||
content_col = content_col.push(Space::with_height(16.0));
|
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)
|
.size(13)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
)
|
)
|
||||||
.on_press(Message::DismissModal)
|
.on_press(Message::DismissModal)
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.style(cancel_button_style),
|
.style(cancel_button_style),
|
||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
button(
|
button(
|
||||||
text(t(locale, "modal.confirm.confirm"))
|
text(t(locale, "modal.confirm.confirm"))
|
||||||
.size(13)
|
.size(13)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
)
|
)
|
||||||
.on_press(Message::ConfirmModal(on_confirm_clone))
|
.on_press(Message::ConfirmModal(on_confirm_clone))
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.style(confirm_button_style),
|
.style(confirm_button_style),
|
||||||
];
|
];
|
||||||
|
|
||||||
let content_col = column![
|
let content_col = column![
|
||||||
@@ -237,6 +324,555 @@ pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> {
|
|||||||
.style(modal_box_style)
|
.style(modal_box_style)
|
||||||
.into()
|
.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<Element<'static, Message>> = 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> = media_list
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.mime_type.starts_with("image/"))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let media_items: Vec<Element<'static, Message>> = image_media
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, m)| {
|
||||||
|
let title = m
|
||||||
|
.title
|
||||||
|
.as_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
|
// 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)
|
.style(backdrop_style)
|
||||||
.into()
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
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::text::{Shaping, Wrapping};
|
||||||
|
use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space};
|
||||||
use iced::{Color, Element, Length, Theme};
|
use iced::{Color, Element, Length, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::{self, UiLocale};
|
use bds_core::i18n::{self, UiLocale};
|
||||||
use bds_core::model::{Post, PostStatus, PostTranslation};
|
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::app::Message;
|
||||||
use crate::components::inputs;
|
use crate::components::inputs;
|
||||||
@@ -39,6 +39,15 @@ pub struct ResolvedPostLink {
|
|||||||
pub title: String,
|
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.
|
/// State for an open post editor.
|
||||||
pub struct PostEditorState {
|
pub struct PostEditorState {
|
||||||
pub post_id: String,
|
pub post_id: String,
|
||||||
@@ -56,6 +65,7 @@ pub struct PostEditorState {
|
|||||||
pub status: PostStatus,
|
pub status: PostStatus,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
|
pub published_at: Option<i64>,
|
||||||
pub is_dirty: bool,
|
pub is_dirty: bool,
|
||||||
pub metadata_expanded: bool,
|
pub metadata_expanded: bool,
|
||||||
pub excerpt_expanded: bool,
|
pub excerpt_expanded: bool,
|
||||||
@@ -76,6 +86,8 @@ pub struct PostEditorState {
|
|||||||
pub outlinks: Vec<ResolvedPostLink>,
|
pub outlinks: Vec<ResolvedPostLink>,
|
||||||
/// Incoming links from other posts to this post.
|
/// Incoming links from other posts to this post.
|
||||||
pub backlinks: Vec<ResolvedPostLink>,
|
pub backlinks: Vec<ResolvedPostLink>,
|
||||||
|
/// Media linked to this post.
|
||||||
|
pub linked_media: Vec<LinkedMediaItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for PostEditorState {
|
impl std::fmt::Debug for PostEditorState {
|
||||||
@@ -106,6 +118,7 @@ impl Clone for PostEditorState {
|
|||||||
status: self.status.clone(),
|
status: self.status.clone(),
|
||||||
created_at: self.created_at,
|
created_at: self.created_at,
|
||||||
updated_at: self.updated_at,
|
updated_at: self.updated_at,
|
||||||
|
published_at: self.published_at,
|
||||||
is_dirty: self.is_dirty,
|
is_dirty: self.is_dirty,
|
||||||
metadata_expanded: self.metadata_expanded,
|
metadata_expanded: self.metadata_expanded,
|
||||||
excerpt_expanded: self.excerpt_expanded,
|
excerpt_expanded: self.excerpt_expanded,
|
||||||
@@ -118,6 +131,7 @@ impl Clone for PostEditorState {
|
|||||||
translation_drafts: self.translation_drafts.clone(),
|
translation_drafts: self.translation_drafts.clone(),
|
||||||
outlinks: self.outlinks.clone(),
|
outlinks: self.outlinks.clone(),
|
||||||
backlinks: self.backlinks.clone(),
|
backlinks: self.backlinks.clone(),
|
||||||
|
linked_media: self.linked_media.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +143,7 @@ impl PostEditorState {
|
|||||||
translations: &[PostTranslation],
|
translations: &[PostTranslation],
|
||||||
outlinks: Vec<ResolvedPostLink>,
|
outlinks: Vec<ResolvedPostLink>,
|
||||||
backlinks: Vec<ResolvedPostLink>,
|
backlinks: Vec<ResolvedPostLink>,
|
||||||
|
linked_media: Vec<LinkedMediaItem>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let title = post.title.clone();
|
let title = post.title.clone();
|
||||||
let content = post.content.clone().unwrap_or_default();
|
let content = post.content.clone().unwrap_or_default();
|
||||||
@@ -136,13 +151,16 @@ impl PostEditorState {
|
|||||||
|
|
||||||
let mut translation_drafts = HashMap::new();
|
let mut translation_drafts = HashMap::new();
|
||||||
for tr in translations {
|
for tr in translations {
|
||||||
translation_drafts.insert(tr.language.clone(), TranslationDraft {
|
translation_drafts.insert(
|
||||||
title: tr.title.clone(),
|
tr.language.clone(),
|
||||||
excerpt: tr.excerpt.clone().unwrap_or_default(),
|
TranslationDraft {
|
||||||
content: tr.content.clone().unwrap_or_default(),
|
title: tr.title.clone(),
|
||||||
status: tr.status.clone(),
|
excerpt: tr.excerpt.clone().unwrap_or_default(),
|
||||||
is_dirty: false,
|
content: tr.content.clone().unwrap_or_default(),
|
||||||
});
|
status: tr.status.clone(),
|
||||||
|
is_dirty: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -160,6 +178,7 @@ impl PostEditorState {
|
|||||||
status: post.status.clone(),
|
status: post.status.clone(),
|
||||||
created_at: post.created_at,
|
created_at: post.created_at,
|
||||||
updated_at: post.updated_at,
|
updated_at: post.updated_at,
|
||||||
|
published_at: post.published_at,
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
metadata_expanded: title.is_empty(),
|
metadata_expanded: title.is_empty(),
|
||||||
excerpt_expanded: false,
|
excerpt_expanded: false,
|
||||||
@@ -172,10 +191,21 @@ impl PostEditorState {
|
|||||||
translation_drafts,
|
translation_drafts,
|
||||||
outlinks,
|
outlinks,
|
||||||
backlinks,
|
backlinks,
|
||||||
|
linked_media,
|
||||||
title,
|
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.
|
/// Switch the editor to display a different language.
|
||||||
/// Saves current fields and loads the target language's draft.
|
/// Saves current fields and loads the target language's draft.
|
||||||
pub fn switch_language(&mut self, target_lang: &str) {
|
pub fn switch_language(&mut self, target_lang: &str) {
|
||||||
@@ -195,13 +225,16 @@ impl PostEditorState {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Switching away from a translation — save to drafts
|
// Switching away from a translation — save to drafts
|
||||||
self.translation_drafts.insert(self.active_language.clone(), TranslationDraft {
|
self.translation_drafts.insert(
|
||||||
title: self.title.clone(),
|
self.active_language.clone(),
|
||||||
excerpt: self.excerpt.clone(),
|
TranslationDraft {
|
||||||
content: self.content.clone(),
|
title: self.title.clone(),
|
||||||
status: PostStatus::Draft,
|
excerpt: self.excerpt.clone(),
|
||||||
is_dirty: self.is_dirty,
|
content: self.content.clone(),
|
||||||
});
|
status: PostStatus::Draft,
|
||||||
|
is_dirty: self.is_dirty,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load target fields
|
// Load target fields
|
||||||
@@ -281,6 +314,7 @@ pub enum PostEditorMsg {
|
|||||||
ExcerptChanged(String),
|
ExcerptChanged(String),
|
||||||
ContentChanged(String),
|
ContentChanged(String),
|
||||||
AuthorChanged(String),
|
AuthorChanged(String),
|
||||||
|
LanguageChanged(String),
|
||||||
TemplateSlugChanged(String),
|
TemplateSlugChanged(String),
|
||||||
ToggleDoNotTranslate(bool),
|
ToggleDoNotTranslate(bool),
|
||||||
ToggleMetadata,
|
ToggleMetadata,
|
||||||
@@ -294,7 +328,28 @@ pub enum PostEditorMsg {
|
|||||||
RemoveCategory(String),
|
RemoveCategory(String),
|
||||||
Save,
|
Save,
|
||||||
Publish,
|
Publish,
|
||||||
|
Duplicate,
|
||||||
|
Discard,
|
||||||
Delete,
|
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.
|
/// Render the post editor view.
|
||||||
@@ -303,6 +358,8 @@ pub fn view<'a>(
|
|||||||
locale: UiLocale,
|
locale: UiLocale,
|
||||||
word_wrap: bool,
|
word_wrap: bool,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
|
let on_translation = state.active_language != state.canonical_language;
|
||||||
|
|
||||||
// ── Header bar ──
|
// ── Header bar ──
|
||||||
let dirty_indicator = if state.is_dirty { " \u{25CF}" } else { "" };
|
let dirty_indicator = if state.is_dirty { " \u{25CF}" } else { "" };
|
||||||
let title_display = if state.title.is_empty() {
|
let title_display = if state.title.is_empty() {
|
||||||
@@ -312,34 +369,68 @@ pub fn view<'a>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let header = inputs::toolbar(
|
let header = inputs::toolbar(
|
||||||
vec![
|
vec![text(title_display)
|
||||||
text(title_display)
|
.size(18)
|
||||||
.size(18)
|
.wrapping(Wrapping::None)
|
||||||
.wrapping(Wrapping::None)
|
.shaping(Shaping::Advanced)
|
||||||
.shaping(Shaping::Advanced)
|
.into()],
|
||||||
.into(),
|
|
||||||
],
|
|
||||||
vec![
|
vec![
|
||||||
status_badge(&state.status),
|
status_badge(&state.status),
|
||||||
button(text(t(locale, "common.save")).size(13).shaping(Shaping::Advanced))
|
button(
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::Save))
|
text(t(locale, "common.save"))
|
||||||
.style(inputs::primary_button)
|
.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])
|
.padding([6, 16])
|
||||||
.into(),
|
.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()
|
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new(0, 0).into()
|
||||||
},
|
},
|
||||||
button(text(t(locale, "modal.confirmDelete.delete")).size(13).shaping(Shaping::Advanced))
|
if state.status == PostStatus::Draft {
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::Delete))
|
button(
|
||||||
.style(inputs::danger_button)
|
text(t(locale, "editor.publish"))
|
||||||
|
.size(13)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
)
|
||||||
|
.on_press(Message::PostEditor(PostEditorMsg::Publish))
|
||||||
|
.style(inputs::primary_button)
|
||||||
.padding([6, 16])
|
.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"))
|
format!("\u{25B6} {}", t(locale, "editor.metadata"))
|
||||||
};
|
};
|
||||||
let meta_toggle = button(
|
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))
|
.on_press(Message::PostEditor(PostEditorMsg::ToggleMetadata))
|
||||||
.padding([4, 0])
|
.padding([4, 0])
|
||||||
@@ -361,7 +455,6 @@ pub fn view<'a>(
|
|||||||
|
|
||||||
// ── Translation Flags Bar (inline with metadata toggle) ──
|
// ── Translation Flags Bar (inline with metadata toggle) ──
|
||||||
let flags = state.translation_flags();
|
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() {
|
let meta_toggle_row: Element<'a, Message> = if flags.is_empty() {
|
||||||
meta_toggle.into()
|
meta_toggle.into()
|
||||||
} else {
|
} else {
|
||||||
@@ -369,12 +462,14 @@ pub fn view<'a>(
|
|||||||
for flag in &flags {
|
for flag in &flags {
|
||||||
let lang = flag.language.clone();
|
let lang = flag.language.clone();
|
||||||
let label = format!("{}", flag.flag_emoji);
|
let label = format!("{}", flag.flag_emoji);
|
||||||
let btn = button(
|
let btn = button(text(label).size(14).shaping(Shaping::Advanced))
|
||||||
text(label).size(14).shaping(Shaping::Advanced),
|
.on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang)))
|
||||||
)
|
.padding([2, 4])
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang)))
|
.style(if flag.is_active {
|
||||||
.padding([2, 4])
|
flag_active_style
|
||||||
.style(if flag.is_active { flag_active_style } else { flag_inactive_style });
|
} else {
|
||||||
|
flag_inactive_style
|
||||||
|
});
|
||||||
flag_row = flag_row.push(btn);
|
flag_row = flag_row.push(btn);
|
||||||
}
|
}
|
||||||
row![meta_toggle, Space::with_width(Length::Fill), flag_row]
|
row![meta_toggle, Space::with_width(Length::Fill), flag_row]
|
||||||
@@ -400,11 +495,20 @@ pub fn view<'a>(
|
|||||||
.spacing(16)
|
.spacing(16)
|
||||||
.width(Length::Fill);
|
.width(Length::Fill);
|
||||||
|
|
||||||
let author_input = inputs::labeled_input(
|
let author_input =
|
||||||
&t(locale, "editor.author"),
|
inputs::labeled_input(&t(locale, "editor.author"), "", &state.author, |s| {
|
||||||
"",
|
Message::PostEditor(PostEditorMsg::AuthorChanged(s))
|
||||||
&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(
|
let template_input = inputs::labeled_input(
|
||||||
&t(locale, "editor.templateSlug"),
|
&t(locale, "editor.templateSlug"),
|
||||||
@@ -417,7 +521,7 @@ pub fn view<'a>(
|
|||||||
state.do_not_translate,
|
state.do_not_translate,
|
||||||
|b| Message::PostEditor(PostEditorMsg::ToggleDoNotTranslate(b)),
|
|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)
|
.spacing(16)
|
||||||
.width(Length::Fill);
|
.width(Length::Fill);
|
||||||
|
|
||||||
@@ -447,16 +551,18 @@ pub fn view<'a>(
|
|||||||
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
|
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
|
||||||
Space::new(0, 0).into()
|
Space::new(0, 0).into()
|
||||||
} else {
|
} else {
|
||||||
let mut items: Vec<Element<'a, Message>> = vec![
|
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.outlinks"))
|
||||||
text(t(locale, "editor.outlinks")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced).into(),
|
.size(12)
|
||||||
];
|
.color(inputs::LABEL_COLOR)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.into()];
|
||||||
for link in &state.outlinks {
|
for link in &state.outlinks {
|
||||||
items.push(
|
items.push(
|
||||||
text(format!("\u{2192} {}", link.title))
|
text(format!("\u{2192} {}", link.title))
|
||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.color(Color::from_rgb(0.55, 0.70, 0.90))
|
.color(Color::from_rgb(0.55, 0.70, 0.90))
|
||||||
.into()
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Column::with_children(items).spacing(2).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() {
|
let backlinks_section: Element<'a, Message> = if state.backlinks.is_empty() {
|
||||||
Space::new(0, 0).into()
|
Space::new(0, 0).into()
|
||||||
} else {
|
} else {
|
||||||
let mut items: Vec<Element<'a, Message>> = vec![
|
let mut items: Vec<Element<'a, Message>> = vec![text(t(locale, "editor.backlinks"))
|
||||||
text(t(locale, "editor.backlinks")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced).into(),
|
.size(12)
|
||||||
];
|
.color(inputs::LABEL_COLOR)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.into()];
|
||||||
for link in &state.backlinks {
|
for link in &state.backlinks {
|
||||||
items.push(
|
items.push(
|
||||||
text(format!("\u{2190} {}", link.title))
|
text(format!("\u{2190} {}", link.title))
|
||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.color(Color::from_rgb(0.55, 0.70, 0.90))
|
.color(Color::from_rgb(0.55, 0.70, 0.90))
|
||||||
.into()
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Column::with_children(items).spacing(2).into()
|
Column::with_children(items).spacing(2).into()
|
||||||
};
|
};
|
||||||
|
|
||||||
column![meta_row1, meta_row2, tags_section, categories_section, outlinks_section, backlinks_section]
|
let link_existing_button: Element<'a, Message> = button(
|
||||||
.spacing(8)
|
text(t(locale, "editor.linkExistingMedia"))
|
||||||
.width(Length::Fill)
|
.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()
|
.into()
|
||||||
|
} else {
|
||||||
|
let mut items: Vec<Element<'a, Message>> = 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 {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new(0, 0).into()
|
||||||
};
|
};
|
||||||
@@ -495,7 +694,10 @@ pub fn view<'a>(
|
|||||||
format!("\u{25B6} {}", t(locale, "editor.excerpt"))
|
format!("\u{25B6} {}", t(locale, "editor.excerpt"))
|
||||||
};
|
};
|
||||||
let excerpt_toggle = button(
|
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))
|
.on_press(Message::PostEditor(PostEditorMsg::ToggleExcerpt))
|
||||||
.padding([4, 0])
|
.padding([4, 0])
|
||||||
@@ -517,23 +719,64 @@ pub fn view<'a>(
|
|||||||
|
|
||||||
// ── Content section (fills remaining space) ──
|
// ── Content section (fills remaining space) ──
|
||||||
let content_label = inputs::section_header(&t(locale, "editor.content"));
|
let content_label = inputs::section_header(&t(locale, "editor.content"));
|
||||||
let editor_widget: Element<'a, Message> = CodeEditor::new(
|
let body_toolbar = inputs::toolbar(
|
||||||
&state.editor_buffer,
|
vec![content_label.into()],
|
||||||
highlighter(),
|
vec![
|
||||||
"md",
|
button(
|
||||||
)
|
text(t(locale, "editor.insertLink"))
|
||||||
.word_wrap(word_wrap)
|
.size(13)
|
||||||
.on_change(|msg| match msg {
|
.shaping(Shaping::Advanced),
|
||||||
EditorMessage::ContentChanged(s) => Message::PostEditor(PostEditorMsg::ContentChanged(s)),
|
)
|
||||||
EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save),
|
.on_press(Message::PostEditor(PostEditorMsg::InsertLink))
|
||||||
})
|
.padding([6, 16])
|
||||||
.into();
|
.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 ──
|
// ── 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![
|
let footer = row![
|
||||||
inputs::date_label(&t(locale, "editor.createdAt"), state.created_at),
|
inputs::date_label(&t(locale, "editor.createdAt"), state.created_at),
|
||||||
Space::with_width(Length::Fixed(24.0)),
|
Space::with_width(Length::Fixed(24.0)),
|
||||||
inputs::date_label(&t(locale, "editor.updatedAt"), state.updated_at),
|
inputs::date_label(&t(locale, "editor.updatedAt"), state.updated_at),
|
||||||
|
published_gap,
|
||||||
|
published_label,
|
||||||
]
|
]
|
||||||
.padding(8);
|
.padding(8);
|
||||||
|
|
||||||
@@ -547,22 +790,17 @@ pub fn view<'a>(
|
|||||||
excerpt_section,
|
excerpt_section,
|
||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill),
|
||||||
)
|
)
|
||||||
.height(Length::Shrink);
|
.height(Length::Shrink);
|
||||||
|
|
||||||
// ── Full layout: top pane (shrink), editor (fill), footer (shrink) ──
|
// ── Full layout: top pane (shrink), editor (fill), footer (shrink) ──
|
||||||
column![
|
column![top_pane, body_toolbar, editor_widget, footer,]
|
||||||
top_pane,
|
.spacing(4)
|
||||||
content_label,
|
.padding(16)
|
||||||
editor_widget,
|
.width(Length::Fill)
|
||||||
footer,
|
.height(Length::Fill)
|
||||||
]
|
.into()
|
||||||
.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.
|
/// 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![
|
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(),
|
chip_row.wrap(),
|
||||||
text_input(placeholder, input_value)
|
text_input(placeholder, input_value)
|
||||||
.on_input(on_input)
|
.on_input(on_input)
|
||||||
@@ -678,3 +919,66 @@ fn flag_inactive_style(_theme: &Theme, status: button::Status) -> button::Style
|
|||||||
..button::Style::default()
|
..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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
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::text::Shaping;
|
||||||
|
use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
|
||||||
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
|
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
@@ -10,18 +10,22 @@ use bds_core::model::{Media, Post, Project, Script, Template};
|
|||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
|
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::tabs::{Tab, TabType};
|
||||||
use crate::state::toast::Toast;
|
use crate::state::toast::Toast;
|
||||||
use crate::views::{
|
use crate::views::{
|
||||||
activity_bar, modal, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome,
|
activity_bar,
|
||||||
post_editor::{self, PostEditorState},
|
|
||||||
media_editor::{self, MediaEditorState},
|
|
||||||
template_editor::{self, TemplateEditorState},
|
|
||||||
script_editor::{self, ScriptEditorState},
|
|
||||||
tags_view::{self, TagsViewState},
|
|
||||||
settings_view::{self, SettingsViewState},
|
|
||||||
dashboard::DashboardState,
|
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.
|
/// Main content area background.
|
||||||
@@ -94,7 +98,7 @@ pub fn view<'a>(
|
|||||||
// Toasts
|
// Toasts
|
||||||
toasts: &'a [Toast],
|
toasts: &'a [Toast],
|
||||||
// Modal
|
// Modal
|
||||||
active_modal: Option<&'a modal::ModalState>,
|
active_modal: Option<modal::ModalState>,
|
||||||
// Data directory (for thumbnail paths)
|
// Data directory (for thumbnail paths)
|
||||||
data_dir: Option<&'a Path>,
|
data_dir: Option<&'a Path>,
|
||||||
// Editor states
|
// Editor states
|
||||||
@@ -114,18 +118,29 @@ pub fn view<'a>(
|
|||||||
|
|
||||||
// Content area — route based on active tab type
|
// Content area — route based on active tab type
|
||||||
let content_area = route_content_area(
|
let content_area = route_content_area(
|
||||||
tabs, active_tab, locale, data_dir,
|
tabs,
|
||||||
post_editors, media_editors, template_editors, script_editors,
|
active_tab,
|
||||||
tags_view_state, settings_state, dashboard_state,
|
locale,
|
||||||
|
data_dir,
|
||||||
|
post_editors,
|
||||||
|
media_editors,
|
||||||
|
template_editors,
|
||||||
|
script_editors,
|
||||||
|
tags_view_state,
|
||||||
|
settings_state,
|
||||||
|
dashboard_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Right column: tab bar + content + panel
|
// Right column: tab bar + content + panel
|
||||||
let mut right_col = column![tabs_el, content_area];
|
let mut right_col = column![tabs_el, content_area];
|
||||||
if panel_visible {
|
if panel_visible {
|
||||||
// Determine active tab type for panel tab availability (per layout.allium PanelTabAvailability)
|
// 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 = 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(separator_h());
|
||||||
right_col = right_col.push(panel::view(
|
right_col = right_col.push(panel::view(
|
||||||
@@ -146,7 +161,22 @@ pub fn view<'a>(
|
|||||||
let mut main_row = row![activity];
|
let mut main_row = row![activity];
|
||||||
|
|
||||||
if sidebar_visible {
|
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
|
// Resize drag handle: 4px wide strip between sidebar and content
|
||||||
let handle = container(Space::new(0, 0))
|
let handle = container(Space::new(0, 0))
|
||||||
@@ -168,17 +198,21 @@ pub fn view<'a>(
|
|||||||
let main_row = main_row.height(Length::Fill);
|
let main_row = main_row.height(Length::Fill);
|
||||||
|
|
||||||
// Status bar at bottom — determine active post status for status dot
|
// Status bar at bottom — determine active post status for status dot
|
||||||
let active_post_status: Option<String> = active_tab.and_then(|id| {
|
let active_post_status: Option<String> = active_tab
|
||||||
tabs.iter().find(|t| t.id == id && t.tab_type == TabType::Post)
|
.and_then(|id| {
|
||||||
}).and_then(|tab| {
|
tabs.iter()
|
||||||
sidebar_posts.iter().find(|p| p.id == tab.id).map(|p| {
|
.find(|t| t.id == id && t.tab_type == TabType::Post)
|
||||||
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(),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
});
|
.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(
|
let status = status_bar::view(
|
||||||
active_project_name,
|
active_project_name,
|
||||||
@@ -201,9 +235,7 @@ pub fn view<'a>(
|
|||||||
let items: Vec<Element<'a, Message>> = UiLocale::all()
|
let items: Vec<Element<'a, Message>> = UiLocale::all()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&l| {
|
.map(|&l| {
|
||||||
let flag_text = text(l.flag_emoji())
|
let flag_text = text(l.flag_emoji()).size(16).shaping(Shaping::Advanced);
|
||||||
.size(16)
|
|
||||||
.shaping(Shaping::Advanced);
|
|
||||||
|
|
||||||
button(flag_text)
|
button(flag_text)
|
||||||
.on_press(Message::SetUiLocale(l))
|
.on_press(Message::SetUiLocale(l))
|
||||||
@@ -214,28 +246,33 @@ pub fn view<'a>(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let dropdown_menu = container(
|
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);
|
.style(status_bar::dropdown_bg);
|
||||||
|
|
||||||
// Position at bottom-right, above status bar
|
// Position at bottom-right, above status bar
|
||||||
Some(
|
Some(
|
||||||
container(
|
container(
|
||||||
container(
|
container(row![
|
||||||
row![
|
Space::with_width(Length::Fill),
|
||||||
Space::with_width(Length::Fill),
|
dropdown_menu,
|
||||||
dropdown_menu,
|
Space::with_width(Length::Fixed(40.0)),
|
||||||
Space::with_width(Length::Fixed(40.0)),
|
])
|
||||||
]
|
|
||||||
)
|
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.align_y(Alignment::End)
|
.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)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.align_y(Alignment::End)
|
.align_y(Alignment::End)
|
||||||
.into()
|
.into(),
|
||||||
)
|
)
|
||||||
} else if project_dropdown_open {
|
} else if project_dropdown_open {
|
||||||
let dropdown = project_selector::view(projects, active_project_id, locale);
|
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
|
// Position at bottom-left, above status bar
|
||||||
Some(
|
Some(
|
||||||
container(
|
container(
|
||||||
container(
|
container(row![
|
||||||
row![
|
Space::with_width(Length::Fixed(8.0)),
|
||||||
Space::with_width(Length::Fixed(8.0)),
|
dropdown,
|
||||||
dropdown,
|
Space::with_width(Length::Fill),
|
||||||
Space::with_width(Length::Fill),
|
])
|
||||||
]
|
|
||||||
)
|
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.align_y(Alignment::End)
|
.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)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.align_y(Alignment::End)
|
.align_y(Alignment::End)
|
||||||
.into()
|
.into(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -276,7 +316,7 @@ pub fn view<'a>(
|
|||||||
|
|
||||||
// Modal overlay (highest z-index)
|
// Modal overlay (highest z-index)
|
||||||
if let Some(modal_state) = active_modal {
|
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() {
|
if overlays.is_empty() {
|
||||||
@@ -363,7 +403,9 @@ fn route_content_area<'a>(
|
|||||||
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
|
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||||
use crate::i18n::t;
|
use crate::i18n::t;
|
||||||
container(
|
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)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"activity.import": "Importieren",
|
"activity.import": "Importieren",
|
||||||
"activity.sourceControl": "Versionskontrolle",
|
"activity.sourceControl": "Versionskontrolle",
|
||||||
"common.save": "Speichern",
|
"common.save": "Speichern",
|
||||||
|
"common.open": "Öffnen",
|
||||||
"common.cancel": "Abbrechen",
|
"common.cancel": "Abbrechen",
|
||||||
"common.settings": "Einstellungen",
|
"common.settings": "Einstellungen",
|
||||||
"common.tasks": "Aufgaben",
|
"common.tasks": "Aufgaben",
|
||||||
@@ -166,9 +167,37 @@
|
|||||||
"modal.confirm.title": "Bestätigen",
|
"modal.confirm.title": "Bestätigen",
|
||||||
"modal.confirm.cancel": "Abbrechen",
|
"modal.confirm.cancel": "Abbrechen",
|
||||||
"modal.confirm.confirm": "Bestätigen",
|
"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.search": "Suchen...",
|
||||||
"sidebar.filter.toggle": "Filter",
|
"sidebar.filter.toggle": "Filter",
|
||||||
"sidebar.filter.clearAll": "Alle Filter zurücksetzen",
|
"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.tags": "Tags",
|
||||||
"sidebar.filter.categories": "Kategorien",
|
"sidebar.filter.categories": "Kategorien",
|
||||||
"sidebar.filter.calendar": "Archiv",
|
"sidebar.filter.calendar": "Archiv",
|
||||||
@@ -182,9 +211,12 @@
|
|||||||
"editor.excerpt": "Auszug",
|
"editor.excerpt": "Auszug",
|
||||||
"editor.excerptPlaceholder": "Kurze Zusammenfassung...",
|
"editor.excerptPlaceholder": "Kurze Zusammenfassung...",
|
||||||
"editor.author": "Autor",
|
"editor.author": "Autor",
|
||||||
|
"editor.language": "Sprache",
|
||||||
"editor.templateSlug": "Vorlage",
|
"editor.templateSlug": "Vorlage",
|
||||||
"editor.doNotTranslate": "Nicht übersetzen",
|
"editor.doNotTranslate": "Nicht übersetzen",
|
||||||
"editor.publish": "Veröffentlichen",
|
"editor.publish": "Veröffentlichen",
|
||||||
|
"editor.discard": "Verwerfen",
|
||||||
|
"editor.duplicate": "Duplizieren",
|
||||||
"editor.validate": "Validieren",
|
"editor.validate": "Validieren",
|
||||||
"editor.run": "Ausführen",
|
"editor.run": "Ausführen",
|
||||||
"editor.checkSyntax": "Syntax prüfen",
|
"editor.checkSyntax": "Syntax prüfen",
|
||||||
@@ -201,9 +233,16 @@
|
|||||||
"editor.categoriesPlaceholder": "Kategorie hinzufügen...",
|
"editor.categoriesPlaceholder": "Kategorie hinzufügen...",
|
||||||
"editor.outlinks": "Ausgehende Links",
|
"editor.outlinks": "Ausgehende Links",
|
||||||
"editor.backlinks": "Eingehende 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.untitled": "Ohne Titel",
|
||||||
"editor.createdAt": "Erstellt",
|
"editor.createdAt": "Erstellt",
|
||||||
"editor.updatedAt": "Aktualisiert",
|
"editor.updatedAt": "Aktualisiert",
|
||||||
|
"editor.publishedAt": "Veröffentlicht",
|
||||||
"tags.noTags": "Noch keine Tags",
|
"tags.noTags": "Noch keine Tags",
|
||||||
"tags.editTag": "Tag bearbeiten",
|
"tags.editTag": "Tag bearbeiten",
|
||||||
"tags.name": "Name",
|
"tags.name": "Name",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"activity.import": "Import",
|
"activity.import": "Import",
|
||||||
"activity.sourceControl": "Source Control",
|
"activity.sourceControl": "Source Control",
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
|
"common.open": "Open",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
"common.settings": "Settings",
|
"common.settings": "Settings",
|
||||||
"common.tasks": "Tasks",
|
"common.tasks": "Tasks",
|
||||||
@@ -166,6 +167,31 @@
|
|||||||
"modal.confirm.title": "Confirm",
|
"modal.confirm.title": "Confirm",
|
||||||
"modal.confirm.cancel": "Cancel",
|
"modal.confirm.cancel": "Cancel",
|
||||||
"modal.confirm.confirm": "Confirm",
|
"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.search": "Search...",
|
||||||
"sidebar.filter.toggle": "Filters",
|
"sidebar.filter.toggle": "Filters",
|
||||||
"sidebar.filter.clearAll": "Clear All Filters",
|
"sidebar.filter.clearAll": "Clear All Filters",
|
||||||
@@ -182,9 +208,12 @@
|
|||||||
"editor.excerpt": "Excerpt",
|
"editor.excerpt": "Excerpt",
|
||||||
"editor.excerptPlaceholder": "Brief summary...",
|
"editor.excerptPlaceholder": "Brief summary...",
|
||||||
"editor.author": "Author",
|
"editor.author": "Author",
|
||||||
|
"editor.language": "Language",
|
||||||
"editor.templateSlug": "Template",
|
"editor.templateSlug": "Template",
|
||||||
"editor.doNotTranslate": "Do Not Translate",
|
"editor.doNotTranslate": "Do Not Translate",
|
||||||
"editor.publish": "Publish",
|
"editor.publish": "Publish",
|
||||||
|
"editor.discard": "Discard",
|
||||||
|
"editor.duplicate": "Duplicate",
|
||||||
"editor.validate": "Validate",
|
"editor.validate": "Validate",
|
||||||
"editor.run": "Run",
|
"editor.run": "Run",
|
||||||
"editor.checkSyntax": "Check Syntax",
|
"editor.checkSyntax": "Check Syntax",
|
||||||
@@ -201,9 +230,19 @@
|
|||||||
"editor.categoriesPlaceholder": "Add category...",
|
"editor.categoriesPlaceholder": "Add category...",
|
||||||
"editor.outlinks": "Outgoing Links",
|
"editor.outlinks": "Outgoing Links",
|
||||||
"editor.backlinks": "Incoming 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.untitled": "Untitled",
|
||||||
"editor.createdAt": "Created",
|
"editor.createdAt": "Created",
|
||||||
"editor.updatedAt": "Updated",
|
"editor.updatedAt": "Updated",
|
||||||
|
"editor.publishedAt": "Published",
|
||||||
|
"editor.insertLink": "Insert Link",
|
||||||
|
"editor.insertMedia": "Insert Media",
|
||||||
|
"editor.gallery": "Gallery",
|
||||||
"tags.noTags": "No tags yet",
|
"tags.noTags": "No tags yet",
|
||||||
"tags.editTag": "Edit Tag",
|
"tags.editTag": "Edit Tag",
|
||||||
"tags.name": "Name",
|
"tags.name": "Name",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"activity.import": "Importar",
|
"activity.import": "Importar",
|
||||||
"activity.sourceControl": "Control de código fuente",
|
"activity.sourceControl": "Control de código fuente",
|
||||||
"common.save": "Guardar",
|
"common.save": "Guardar",
|
||||||
|
"common.open": "Abrir",
|
||||||
"common.cancel": "Cancelar",
|
"common.cancel": "Cancelar",
|
||||||
"common.settings": "Configuración",
|
"common.settings": "Configuración",
|
||||||
"common.tasks": "Tareas",
|
"common.tasks": "Tareas",
|
||||||
@@ -166,9 +167,37 @@
|
|||||||
"modal.confirm.title": "Confirmar",
|
"modal.confirm.title": "Confirmar",
|
||||||
"modal.confirm.cancel": "Cancelar",
|
"modal.confirm.cancel": "Cancelar",
|
||||||
"modal.confirm.confirm": "Confirmar",
|
"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.search": "Buscar...",
|
||||||
"sidebar.filter.toggle": "Filtros",
|
"sidebar.filter.toggle": "Filtros",
|
||||||
"sidebar.filter.clearAll": "Borrar todos los 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.tags": "Etiquetas",
|
||||||
"sidebar.filter.categories": "Categorías",
|
"sidebar.filter.categories": "Categorías",
|
||||||
"sidebar.filter.calendar": "Archivo",
|
"sidebar.filter.calendar": "Archivo",
|
||||||
@@ -182,9 +211,12 @@
|
|||||||
"editor.excerpt": "Extracto",
|
"editor.excerpt": "Extracto",
|
||||||
"editor.excerptPlaceholder": "Breve resumen...",
|
"editor.excerptPlaceholder": "Breve resumen...",
|
||||||
"editor.author": "Autor",
|
"editor.author": "Autor",
|
||||||
|
"editor.language": "Idioma",
|
||||||
"editor.templateSlug": "Plantilla",
|
"editor.templateSlug": "Plantilla",
|
||||||
"editor.doNotTranslate": "No traducir",
|
"editor.doNotTranslate": "No traducir",
|
||||||
"editor.publish": "Publicar",
|
"editor.publish": "Publicar",
|
||||||
|
"editor.discard": "Descartar",
|
||||||
|
"editor.duplicate": "Duplicar",
|
||||||
"editor.validate": "Validar",
|
"editor.validate": "Validar",
|
||||||
"editor.run": "Ejecutar",
|
"editor.run": "Ejecutar",
|
||||||
"editor.checkSyntax": "Verificar sintaxis",
|
"editor.checkSyntax": "Verificar sintaxis",
|
||||||
@@ -201,9 +233,16 @@
|
|||||||
"editor.categoriesPlaceholder": "Añadir categoría...",
|
"editor.categoriesPlaceholder": "Añadir categoría...",
|
||||||
"editor.outlinks": "Enlaces salientes",
|
"editor.outlinks": "Enlaces salientes",
|
||||||
"editor.backlinks": "Enlaces entrantes",
|
"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.untitled": "Sin título",
|
||||||
"editor.createdAt": "Creado",
|
"editor.createdAt": "Creado",
|
||||||
"editor.updatedAt": "Actualizado",
|
"editor.updatedAt": "Actualizado",
|
||||||
|
"editor.publishedAt": "Publicado",
|
||||||
"tags.noTags": "Sin etiquetas",
|
"tags.noTags": "Sin etiquetas",
|
||||||
"tags.editTag": "Editar etiqueta",
|
"tags.editTag": "Editar etiqueta",
|
||||||
"tags.name": "Nombre",
|
"tags.name": "Nombre",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"activity.import": "Importation",
|
"activity.import": "Importation",
|
||||||
"activity.sourceControl": "Contrôle de source",
|
"activity.sourceControl": "Contrôle de source",
|
||||||
"common.save": "Enregistrer",
|
"common.save": "Enregistrer",
|
||||||
|
"common.open": "Ouvrir",
|
||||||
"common.cancel": "Annuler",
|
"common.cancel": "Annuler",
|
||||||
"common.settings": "Paramètres",
|
"common.settings": "Paramètres",
|
||||||
"common.tasks": "Tâches",
|
"common.tasks": "Tâches",
|
||||||
@@ -166,9 +167,37 @@
|
|||||||
"modal.confirm.title": "Confirmer",
|
"modal.confirm.title": "Confirmer",
|
||||||
"modal.confirm.cancel": "Annuler",
|
"modal.confirm.cancel": "Annuler",
|
||||||
"modal.confirm.confirm": "Confirmer",
|
"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.search": "Rechercher...",
|
||||||
"sidebar.filter.toggle": "Filtres",
|
"sidebar.filter.toggle": "Filtres",
|
||||||
"sidebar.filter.clearAll": "Effacer tous les 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.tags": "Tags",
|
||||||
"sidebar.filter.categories": "Catégories",
|
"sidebar.filter.categories": "Catégories",
|
||||||
"sidebar.filter.calendar": "Archives",
|
"sidebar.filter.calendar": "Archives",
|
||||||
@@ -182,9 +211,12 @@
|
|||||||
"editor.excerpt": "Extrait",
|
"editor.excerpt": "Extrait",
|
||||||
"editor.excerptPlaceholder": "Bref résumé...",
|
"editor.excerptPlaceholder": "Bref résumé...",
|
||||||
"editor.author": "Auteur",
|
"editor.author": "Auteur",
|
||||||
|
"editor.language": "Langue",
|
||||||
"editor.templateSlug": "Modèle",
|
"editor.templateSlug": "Modèle",
|
||||||
"editor.doNotTranslate": "Ne pas traduire",
|
"editor.doNotTranslate": "Ne pas traduire",
|
||||||
"editor.publish": "Publier",
|
"editor.publish": "Publier",
|
||||||
|
"editor.discard": "Annuler les modifications",
|
||||||
|
"editor.duplicate": "Dupliquer",
|
||||||
"editor.validate": "Valider",
|
"editor.validate": "Valider",
|
||||||
"editor.run": "Exécuter",
|
"editor.run": "Exécuter",
|
||||||
"editor.checkSyntax": "Vérifier la syntaxe",
|
"editor.checkSyntax": "Vérifier la syntaxe",
|
||||||
@@ -201,9 +233,16 @@
|
|||||||
"editor.categoriesPlaceholder": "Ajouter une catégorie...",
|
"editor.categoriesPlaceholder": "Ajouter une catégorie...",
|
||||||
"editor.outlinks": "Liens sortants",
|
"editor.outlinks": "Liens sortants",
|
||||||
"editor.backlinks": "Liens entrants",
|
"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.untitled": "Sans titre",
|
||||||
"editor.createdAt": "Créé",
|
"editor.createdAt": "Créé",
|
||||||
"editor.updatedAt": "Mis à jour",
|
"editor.updatedAt": "Mis à jour",
|
||||||
|
"editor.publishedAt": "Publié",
|
||||||
"tags.noTags": "Aucun tag",
|
"tags.noTags": "Aucun tag",
|
||||||
"tags.editTag": "Modifier le tag",
|
"tags.editTag": "Modifier le tag",
|
||||||
"tags.name": "Nom",
|
"tags.name": "Nom",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"activity.import": "Importa",
|
"activity.import": "Importa",
|
||||||
"activity.sourceControl": "Controllo sorgente",
|
"activity.sourceControl": "Controllo sorgente",
|
||||||
"common.save": "Salva",
|
"common.save": "Salva",
|
||||||
|
"common.open": "Apri",
|
||||||
"common.cancel": "Annulla",
|
"common.cancel": "Annulla",
|
||||||
"common.settings": "Impostazioni",
|
"common.settings": "Impostazioni",
|
||||||
"common.tasks": "Attività",
|
"common.tasks": "Attività",
|
||||||
@@ -166,9 +167,37 @@
|
|||||||
"modal.confirm.title": "Conferma",
|
"modal.confirm.title": "Conferma",
|
||||||
"modal.confirm.cancel": "Annulla",
|
"modal.confirm.cancel": "Annulla",
|
||||||
"modal.confirm.confirm": "Conferma",
|
"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.search": "Cerca...",
|
||||||
"sidebar.filter.toggle": "Filtri",
|
"sidebar.filter.toggle": "Filtri",
|
||||||
"sidebar.filter.clearAll": "Cancella tutti i 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.tags": "Tag",
|
||||||
"sidebar.filter.categories": "Categorie",
|
"sidebar.filter.categories": "Categorie",
|
||||||
"sidebar.filter.calendar": "Archivio",
|
"sidebar.filter.calendar": "Archivio",
|
||||||
@@ -182,9 +211,12 @@
|
|||||||
"editor.excerpt": "Estratto",
|
"editor.excerpt": "Estratto",
|
||||||
"editor.excerptPlaceholder": "Breve riassunto...",
|
"editor.excerptPlaceholder": "Breve riassunto...",
|
||||||
"editor.author": "Autore",
|
"editor.author": "Autore",
|
||||||
|
"editor.language": "Lingua",
|
||||||
"editor.templateSlug": "Modello",
|
"editor.templateSlug": "Modello",
|
||||||
"editor.doNotTranslate": "Non tradurre",
|
"editor.doNotTranslate": "Non tradurre",
|
||||||
"editor.publish": "Pubblica",
|
"editor.publish": "Pubblica",
|
||||||
|
"editor.discard": "Scarta",
|
||||||
|
"editor.duplicate": "Duplica",
|
||||||
"editor.validate": "Valida",
|
"editor.validate": "Valida",
|
||||||
"editor.run": "Esegui",
|
"editor.run": "Esegui",
|
||||||
"editor.checkSyntax": "Controlla sintassi",
|
"editor.checkSyntax": "Controlla sintassi",
|
||||||
@@ -201,9 +233,16 @@
|
|||||||
"editor.categoriesPlaceholder": "Aggiungi categoria...",
|
"editor.categoriesPlaceholder": "Aggiungi categoria...",
|
||||||
"editor.outlinks": "Link in uscita",
|
"editor.outlinks": "Link in uscita",
|
||||||
"editor.backlinks": "Link in entrata",
|
"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.untitled": "Senza titolo",
|
||||||
"editor.createdAt": "Creato",
|
"editor.createdAt": "Creato",
|
||||||
"editor.updatedAt": "Aggiornato",
|
"editor.updatedAt": "Aggiornato",
|
||||||
|
"editor.publishedAt": "Pubblicato",
|
||||||
"tags.noTags": "Nessun tag",
|
"tags.noTags": "Nessun tag",
|
||||||
"tags.editTag": "Modifica tag",
|
"tags.editTag": "Modifica tag",
|
||||||
"tags.name": "Nome",
|
"tags.name": "Nome",
|
||||||
|
|||||||
Reference in New Issue
Block a user