fix: database migration
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,6 +24,9 @@
|
|||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
crates/bds-core/projects/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -44,4 +44,5 @@ Invariants and behaviours in the allium spec should be covered by unit tests of
|
|||||||
- always make sure you follow proper i18n best practices. no untranslated string constants.
|
- always make sure you follow proper i18n best practices. no untranslated string constants.
|
||||||
- when creating rust source code, always follow what the allium spec is saying for that part
|
- when creating rust source code, always follow what the allium spec is saying for that part
|
||||||
- when tending the allium spec, make sure you validate the spec with the installed command line utility
|
- when tending the allium spec, make sure you validate the spec with the installed command line utility
|
||||||
|
- don't be lazy. don't defer or skip implementations just because you have to write code for that, that is ridiculous. if the spec says something has to be there, it has to be there.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- Fix script and template default status from 'published' to 'draft'
|
||||||
|
-- and make tag name uniqueness case-insensitive.
|
||||||
|
--
|
||||||
|
-- SQLite cannot ALTER COLUMN defaults, so we recreate the affected tables.
|
||||||
|
|
||||||
|
-- ── Scripts: default status 'published' → 'draft' ──
|
||||||
|
|
||||||
|
CREATE TABLE scripts_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'utility',
|
||||||
|
entrypoint TEXT NOT NULL DEFAULT 'render',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
content TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO scripts_new SELECT * FROM scripts;
|
||||||
|
DROP TABLE scripts;
|
||||||
|
ALTER TABLE scripts_new RENAME TO scripts;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS scripts_project_slug_idx
|
||||||
|
ON scripts(project_id, slug);
|
||||||
|
|
||||||
|
-- ── Templates: default status 'published' → 'draft' ──
|
||||||
|
|
||||||
|
CREATE TABLE templates_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'post',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
content TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO templates_new SELECT * FROM templates;
|
||||||
|
DROP TABLE templates;
|
||||||
|
ALTER TABLE templates_new RENAME TO templates;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS templates_project_slug_idx
|
||||||
|
ON templates(project_id, slug);
|
||||||
|
|
||||||
|
-- ── Tags: case-insensitive unique index ──
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS tags_project_name_idx;
|
||||||
|
CREATE UNIQUE INDEX tags_project_name_idx ON tags(project_id, name COLLATE NOCASE);
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
"article",
|
|
||||||
"aside",
|
|
||||||
"page",
|
|
||||||
"picture"
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "P",
|
|
||||||
"maxPostsPerPage": 50,
|
|
||||||
"semanticSimilarityEnabled": false,
|
|
||||||
"blogLanguages": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
"article",
|
|
||||||
"aside",
|
|
||||||
"page",
|
|
||||||
"picture"
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "P",
|
|
||||||
"maxPostsPerPage": 50,
|
|
||||||
"semanticSimilarityEnabled": false,
|
|
||||||
"blogLanguages": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
"article",
|
|
||||||
"aside",
|
|
||||||
"page",
|
|
||||||
"picture"
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "P",
|
|
||||||
"maxPostsPerPage": 50,
|
|
||||||
"semanticSimilarityEnabled": false,
|
|
||||||
"blogLanguages": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
"article",
|
|
||||||
"aside",
|
|
||||||
"page",
|
|
||||||
"picture"
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "P",
|
|
||||||
"maxPostsPerPage": 50,
|
|
||||||
"semanticSimilarityEnabled": false,
|
|
||||||
"blogLanguages": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
"article",
|
|
||||||
"aside",
|
|
||||||
"page",
|
|
||||||
"picture"
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "P",
|
|
||||||
"maxPostsPerPage": 50,
|
|
||||||
"semanticSimilarityEnabled": false,
|
|
||||||
"blogLanguages": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
"article",
|
|
||||||
"aside",
|
|
||||||
"page",
|
|
||||||
"picture"
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "P",
|
|
||||||
"maxPostsPerPage": 50,
|
|
||||||
"semanticSimilarityEnabled": false,
|
|
||||||
"blogLanguages": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
"article",
|
|
||||||
"aside",
|
|
||||||
"page",
|
|
||||||
"picture"
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "P",
|
|
||||||
"maxPostsPerPage": 50,
|
|
||||||
"semanticSimilarityEnabled": false,
|
|
||||||
"blogLanguages": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -63,9 +63,25 @@ pub fn stem_text(text: &str, language: &str) -> String {
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Structured translation data for FTS indexing of posts.
|
||||||
|
pub struct PostTranslationFts {
|
||||||
|
pub title: String,
|
||||||
|
pub excerpt: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub language: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structured translation data for FTS indexing of media.
|
||||||
|
pub struct MediaTranslationFts {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub alt: Option<String>,
|
||||||
|
pub caption: Option<String>,
|
||||||
|
pub language: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Index a post in the FTS table with separate columns per spec.
|
/// Index a post in the FTS table with separate columns per spec.
|
||||||
///
|
///
|
||||||
/// Concatenates translation text into the content column (stemmed per-language).
|
/// Translation titles go to the title column, excerpts to excerpt, content to content.
|
||||||
pub fn index_post(
|
pub fn index_post(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
post_id: &str,
|
post_id: &str,
|
||||||
@@ -74,22 +90,37 @@ pub fn index_post(
|
|||||||
content: Option<&str>,
|
content: Option<&str>,
|
||||||
tags: &[String],
|
tags: &[String],
|
||||||
categories: &[String],
|
categories: &[String],
|
||||||
translations: &[(String, String)], // (text, language) pairs
|
translations: &[PostTranslationFts],
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> rusqlite::Result<()> {
|
) -> rusqlite::Result<()> {
|
||||||
// Remove existing entry
|
// Remove existing entry
|
||||||
remove_post_from_index(conn, post_id)?;
|
remove_post_from_index(conn, post_id)?;
|
||||||
|
|
||||||
let stemmed_title = stem_text(title, language);
|
// Title column: post title + all translation titles
|
||||||
let stemmed_excerpt = stem_text(excerpt.unwrap_or(""), language);
|
let mut title_parts = vec![stem_text(title, language)];
|
||||||
|
for t in translations {
|
||||||
|
title_parts.push(stem_text(&t.title, &t.language));
|
||||||
|
}
|
||||||
|
let stemmed_title = title_parts.join(" ");
|
||||||
|
|
||||||
// Content column: post content + all translation texts
|
// Excerpt column: post excerpt + all translation excerpts
|
||||||
|
let mut excerpt_parts = vec![stem_text(excerpt.unwrap_or(""), language)];
|
||||||
|
for t in translations {
|
||||||
|
if let Some(ref exc) = t.excerpt {
|
||||||
|
excerpt_parts.push(stem_text(exc, &t.language));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stemmed_excerpt = excerpt_parts.join(" ");
|
||||||
|
|
||||||
|
// Content column: post content + all translation content
|
||||||
let mut content_parts = Vec::new();
|
let mut content_parts = Vec::new();
|
||||||
if let Some(cnt) = content {
|
if let Some(cnt) = content {
|
||||||
content_parts.push(stem_text(cnt, language));
|
content_parts.push(stem_text(cnt, language));
|
||||||
}
|
}
|
||||||
for (text, trans_lang) in translations {
|
for t in translations {
|
||||||
content_parts.push(stem_text(text, trans_lang));
|
if let Some(ref cnt) = t.content {
|
||||||
|
content_parts.push(stem_text(cnt, &t.language));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let stemmed_content = content_parts.join(" ");
|
let stemmed_content = content_parts.join(" ");
|
||||||
|
|
||||||
@@ -104,6 +135,8 @@ pub fn index_post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Index a media item in the FTS table with separate columns per spec.
|
/// Index a media item in the FTS table with separate columns per spec.
|
||||||
|
///
|
||||||
|
/// Translation titles go to the title column, alts to alt, captions to caption.
|
||||||
pub fn index_media(
|
pub fn index_media(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
media_id: &str,
|
media_id: &str,
|
||||||
@@ -112,21 +145,38 @@ pub fn index_media(
|
|||||||
caption: Option<&str>,
|
caption: Option<&str>,
|
||||||
original_name: &str,
|
original_name: &str,
|
||||||
tags: &[String],
|
tags: &[String],
|
||||||
translations: &[(String, String)], // (text, language) pairs
|
translations: &[MediaTranslationFts],
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> rusqlite::Result<()> {
|
) -> rusqlite::Result<()> {
|
||||||
remove_media_from_index(conn, media_id)?;
|
remove_media_from_index(conn, media_id)?;
|
||||||
|
|
||||||
let stemmed_title = stem_text(title.unwrap_or(""), language);
|
// Title column: media title + all translation titles
|
||||||
let stemmed_alt = stem_text(alt.unwrap_or(""), language);
|
let mut title_parts = vec![stem_text(title.unwrap_or(""), language)];
|
||||||
|
for t in translations {
|
||||||
|
if let Some(ref ttl) = t.title {
|
||||||
|
title_parts.push(stem_text(ttl, &t.language));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stemmed_title = title_parts.join(" ");
|
||||||
|
|
||||||
// Caption column: media caption + all translation texts
|
// Alt column: media alt + all translation alts
|
||||||
|
let mut alt_parts = vec![stem_text(alt.unwrap_or(""), language)];
|
||||||
|
for t in translations {
|
||||||
|
if let Some(ref a) = t.alt {
|
||||||
|
alt_parts.push(stem_text(a, &t.language));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stemmed_alt = alt_parts.join(" ");
|
||||||
|
|
||||||
|
// Caption column: media caption + all translation captions
|
||||||
let mut caption_parts = Vec::new();
|
let mut caption_parts = Vec::new();
|
||||||
if let Some(c) = caption {
|
if let Some(c) = caption {
|
||||||
caption_parts.push(stem_text(c, language));
|
caption_parts.push(stem_text(c, language));
|
||||||
}
|
}
|
||||||
for (text, trans_lang) in translations {
|
for t in translations {
|
||||||
caption_parts.push(stem_text(text, trans_lang));
|
if let Some(ref cap) = t.caption {
|
||||||
|
caption_parts.push(stem_text(cap, &t.language));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let stemmed_caption = caption_parts.join(" ");
|
let stemmed_caption = caption_parts.join(" ");
|
||||||
|
|
||||||
@@ -176,8 +226,12 @@ pub struct PostSearchFilters<'a> {
|
|||||||
pub status: Option<&'a str>,
|
pub status: Option<&'a str>,
|
||||||
pub tags: Option<&'a [String]>,
|
pub tags: Option<&'a [String]>,
|
||||||
pub categories: Option<&'a [String]>,
|
pub categories: Option<&'a [String]>,
|
||||||
|
pub language: Option<&'a str>,
|
||||||
|
pub missing_translation_language: Option<&'a str>,
|
||||||
pub year: Option<i32>,
|
pub year: Option<i32>,
|
||||||
pub month: Option<u32>,
|
pub month: Option<u32>,
|
||||||
|
pub from: Option<i64>,
|
||||||
|
pub to: Option<i64>,
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
pub offset: Option<usize>,
|
pub offset: Option<usize>,
|
||||||
}
|
}
|
||||||
@@ -263,6 +317,32 @@ pub fn search_posts_filtered(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(lang) = filters.language {
|
||||||
|
sql.push_str(&format!("AND (language = ?{param_idx} OR language IS NULL) "));
|
||||||
|
params.push(Box::new(lang.to_string()));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(missing_lang) = filters.missing_translation_language {
|
||||||
|
sql.push_str(&format!(
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM post_translations WHERE post_translations.translation_for = posts.id AND post_translations.language = ?{param_idx}) "
|
||||||
|
));
|
||||||
|
params.push(Box::new(missing_lang.to_string()));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(from_ts) = filters.from {
|
||||||
|
sql.push_str(&format!("AND created_at >= ?{param_idx} "));
|
||||||
|
params.push(Box::new(from_ts));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(to_ts) = filters.to {
|
||||||
|
sql.push_str(&format!("AND created_at <= ?{param_idx} "));
|
||||||
|
params.push(Box::new(to_ts));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
let _ = param_idx; // suppress unused warning
|
let _ = param_idx; // suppress unused warning
|
||||||
|
|
||||||
// First get total count (without LIMIT/OFFSET)
|
// First get total count (without LIMIT/OFFSET)
|
||||||
@@ -439,7 +519,12 @@ mod tests {
|
|||||||
Some("Beautiful spider web"),
|
Some("Beautiful spider web"),
|
||||||
&["fotografie".into(), "natur".into()],
|
&["fotografie".into(), "natur".into()],
|
||||||
&["picture".into()],
|
&["picture".into()],
|
||||||
&[("Esmeralda English".into(), "en".into())],
|
&[PostTranslationFts {
|
||||||
|
title: "Esmeralda English".into(),
|
||||||
|
excerpt: None,
|
||||||
|
content: None,
|
||||||
|
language: "en".into(),
|
||||||
|
}],
|
||||||
"en",
|
"en",
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
@@ -503,7 +588,12 @@ mod tests {
|
|||||||
index_post(
|
index_post(
|
||||||
db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"),
|
db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"),
|
||||||
&[], &[],
|
&[], &[],
|
||||||
&[("English development programming".into(), "en".into())],
|
&[PostTranslationFts {
|
||||||
|
title: "English development programming".into(),
|
||||||
|
excerpt: None,
|
||||||
|
content: Some("English development programming".into()),
|
||||||
|
language: "en".into(),
|
||||||
|
}],
|
||||||
"de",
|
"de",
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -745,7 +745,7 @@ mod tests {
|
|||||||
assert_eq!(kind, "post", "default kind must be 'post'");
|
assert_eq!(kind, "post", "default kind must be 'post'");
|
||||||
assert_eq!(enabled, 1, "default enabled must be 1");
|
assert_eq!(enabled, 1, "default enabled must be 1");
|
||||||
assert_eq!(version, 1, "default version must be 1");
|
assert_eq!(version, 1, "default version must be 1");
|
||||||
assert_eq!(status, "published", "default status must be 'published'");
|
assert_eq!(status, "draft", "default status must be 'draft'");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -766,6 +766,6 @@ mod tests {
|
|||||||
assert_eq!(ep, "render", "default entrypoint must be 'render'");
|
assert_eq!(ep, "render", "default entrypoint must be 'render'");
|
||||||
assert_eq!(enabled, 1, "default enabled must be 1");
|
assert_eq!(enabled, 1, "default enabled must be 1");
|
||||||
assert_eq!(version, 1, "default version must be 1");
|
assert_eq!(version, 1, "default version must be 1");
|
||||||
assert_eq!(status, "published", "default status must be 'published'");
|
assert_eq!(status, "draft", "default status must be 'draft'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -444,20 +444,13 @@ pub fn rebuild_media_from_filesystem_with_progress(
|
|||||||
/// Index a media item in FTS, gathering translation texts.
|
/// Index a media item in FTS, gathering translation texts.
|
||||||
fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
|
fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
|
||||||
let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default();
|
let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default();
|
||||||
let translation_data: Vec<(String, String)> = translations
|
let translation_data: Vec<fts::MediaTranslationFts> = translations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| {
|
.map(|t| fts::MediaTranslationFts {
|
||||||
let mut parts = Vec::new();
|
title: t.title.clone(),
|
||||||
if let Some(ref title) = t.title {
|
alt: t.alt.clone(),
|
||||||
parts.push(title.clone());
|
caption: t.caption.clone(),
|
||||||
}
|
language: t.language.clone(),
|
||||||
if let Some(ref alt) = t.alt {
|
|
||||||
parts.push(alt.clone());
|
|
||||||
}
|
|
||||||
if let Some(ref caption) = t.caption {
|
|
||||||
parts.push(caption.clone());
|
|
||||||
}
|
|
||||||
(parts.join(" "), t.language.clone())
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -156,8 +156,19 @@ pub fn update_post(
|
|||||||
post.do_not_translate = dnt;
|
post.do_not_translate = dnt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-transition published post back to draft on content/metadata change
|
// Auto-transition published or archived post back to draft on content/metadata change
|
||||||
if post.status == PostStatus::Published {
|
if post.status == PostStatus::Published || post.status == PostStatus::Archived {
|
||||||
|
// Reload content from filesystem if content field is NULL (published state)
|
||||||
|
if post.content.is_none() && !post.file_path.is_empty() {
|
||||||
|
let abs_path = _data_dir.join(&post.file_path);
|
||||||
|
if abs_path.exists() {
|
||||||
|
if let Ok(file_content) = fs::read_to_string(&abs_path) {
|
||||||
|
if let Ok((_fm, body)) = read_post_file(&file_content) {
|
||||||
|
post.content = Some(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
post.status = PostStatus::Draft;
|
post.status = PostStatus::Draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,10 +305,24 @@ pub fn publish_post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Archive a post.
|
/// Archive a post.
|
||||||
pub fn archive_post(conn: &Connection, post_id: &str) -> EngineResult<()> {
|
pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<()> {
|
||||||
let post = qp::get_post_by_id(conn, post_id)?;
|
let mut post = qp::get_post_by_id(conn, post_id)?;
|
||||||
if post.status == PostStatus::Archived {
|
if post.status == PostStatus::Archived {
|
||||||
return Ok(());
|
return Err(EngineError::Conflict(
|
||||||
|
"post is already archived".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// 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() {
|
||||||
|
let abs_path = data_dir.join(&post.file_path);
|
||||||
|
if abs_path.exists() {
|
||||||
|
if let Ok(file_content) = fs::read_to_string(&abs_path) {
|
||||||
|
if let Ok((_fm, body)) = read_post_file(&file_content) {
|
||||||
|
post.content = Some(body);
|
||||||
|
qp::update_post(conn, &post)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let now = now_unix_ms();
|
let now = now_unix_ms();
|
||||||
qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?;
|
qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?;
|
||||||
@@ -646,17 +671,13 @@ fn publish_translation(
|
|||||||
/// Index a post in FTS, gathering translation texts.
|
/// Index a post in FTS, gathering translation texts.
|
||||||
fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> {
|
fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> {
|
||||||
let translations = qt::list_post_translations_by_post(conn, &post.id).unwrap_or_default();
|
let translations = qt::list_post_translations_by_post(conn, &post.id).unwrap_or_default();
|
||||||
let translation_data: Vec<(String, String)> = translations
|
let translation_data: Vec<fts::PostTranslationFts> = translations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| {
|
.map(|t| fts::PostTranslationFts {
|
||||||
let mut parts = vec![t.title.clone()];
|
title: t.title.clone(),
|
||||||
if let Some(ref exc) = t.excerpt {
|
excerpt: t.excerpt.clone(),
|
||||||
parts.push(exc.clone());
|
content: t.content.clone(),
|
||||||
}
|
language: t.language.clone(),
|
||||||
if let Some(ref cnt) = t.content {
|
|
||||||
parts.push(cnt.clone());
|
|
||||||
}
|
|
||||||
(parts.join(" "), t.language.clone())
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -1031,7 +1052,7 @@ mod tests {
|
|||||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
// Archive so we can try updating
|
// Archive so we can try updating
|
||||||
archive_post(db.conn(), &post.id).unwrap();
|
archive_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
// Try to change slug - should fail
|
// Try to change slug - should fail
|
||||||
let result = update_post(
|
let result = update_post(
|
||||||
@@ -1124,7 +1145,7 @@ mod tests {
|
|||||||
let original_published_at = published.published_at.unwrap();
|
let original_published_at = published.published_at.unwrap();
|
||||||
|
|
||||||
// Archive then re-publish
|
// Archive then re-publish
|
||||||
archive_post(db.conn(), &post.id).unwrap();
|
archive_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
// Brief delay to ensure now() is different
|
// Brief delay to ensure now() is different
|
||||||
let republished = publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
let republished = publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
@@ -1406,7 +1427,7 @@ mod tests {
|
|||||||
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
|
||||||
archive_post(db.conn(), &post.id).unwrap();
|
archive_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
// Re-publish
|
// Re-publish
|
||||||
let _ = publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
let _ = publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
@@ -1475,7 +1496,7 @@ mod tests {
|
|||||||
vec![], vec![], None, None, None,
|
vec![], vec![], None, None, None,
|
||||||
).unwrap();
|
).unwrap();
|
||||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
archive_post(db.conn(), &post.id).unwrap();
|
archive_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap();
|
let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap();
|
||||||
assert_eq!(from_db.status, PostStatus::Archived);
|
assert_eq!(from_db.status, PostStatus::Archived);
|
||||||
|
|||||||
@@ -298,16 +298,24 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn delete_project_removes_row() {
|
fn delete_project_removes_row() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
// Project with no custom data_path → uses internal directory
|
// Simulate an internal project (no custom data_path) by inserting directly
|
||||||
let p = create_project(
|
let now = crate::util::now_unix_ms();
|
||||||
db.conn(),
|
let project = Project {
|
||||||
"P",
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
None,
|
name: "P".to_string(),
|
||||||
)
|
slug: "p".to_string(),
|
||||||
.unwrap();
|
description: None,
|
||||||
let internal_dir = dir.path().join("projects").join(&p.id);
|
data_path: None,
|
||||||
let _ = std::fs::create_dir_all(&internal_dir);
|
is_active: false,
|
||||||
delete_project(db.conn(), &p.id, Some(&internal_dir)).unwrap();
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
crate::db::queries::project::insert_project(db.conn(), &project).unwrap();
|
||||||
|
// Create the internal directory structure under the temp dir
|
||||||
|
let internal_dir = dir.path().join("projects").join(&project.id);
|
||||||
|
create_directory_structure(&internal_dir).unwrap();
|
||||||
|
assert!(internal_dir.join("posts").is_dir());
|
||||||
|
delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap();
|
||||||
assert!(list_projects(db.conn()).unwrap().is_empty());
|
assert!(list_projects(db.conn()).unwrap().is_empty());
|
||||||
assert!(!internal_dir.exists(), "internal directory should be cleaned up");
|
assert!(!internal_dir.exists(), "internal directory should be cleaned up");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,16 +50,13 @@ pub fn reindex_all_with_progress(
|
|||||||
}
|
}
|
||||||
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
|
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
|
||||||
|
|
||||||
let trans_pairs: Vec<(String, String)> = translations
|
let trans_data: Vec<fts::PostTranslationFts> = translations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| {
|
.map(|t| fts::PostTranslationFts {
|
||||||
let text = [
|
title: t.title.clone(),
|
||||||
t.title.as_str(),
|
excerpt: t.excerpt.clone(),
|
||||||
t.excerpt.as_deref().unwrap_or(""),
|
content: t.content.clone(),
|
||||||
t.content.as_deref().unwrap_or(""),
|
language: t.language.clone(),
|
||||||
]
|
|
||||||
.join(" ");
|
|
||||||
(text, t.language.clone())
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -72,7 +69,7 @@ pub fn reindex_all_with_progress(
|
|||||||
post.content.as_deref(),
|
post.content.as_deref(),
|
||||||
&post.tags,
|
&post.tags,
|
||||||
&post.categories,
|
&post.categories,
|
||||||
&trans_pairs,
|
&trans_data,
|
||||||
language,
|
language,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -87,16 +84,13 @@ pub fn reindex_all_with_progress(
|
|||||||
}
|
}
|
||||||
let translations = media_translation::list_media_translations_by_media(conn, &m.id)?;
|
let translations = media_translation::list_media_translations_by_media(conn, &m.id)?;
|
||||||
|
|
||||||
let trans_pairs: Vec<(String, String)> = translations
|
let trans_data: Vec<fts::MediaTranslationFts> = translations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| {
|
.map(|t| fts::MediaTranslationFts {
|
||||||
let text = [
|
title: t.title.clone(),
|
||||||
t.title.as_deref().unwrap_or(""),
|
alt: t.alt.clone(),
|
||||||
t.alt.as_deref().unwrap_or(""),
|
caption: t.caption.clone(),
|
||||||
t.caption.as_deref().unwrap_or(""),
|
language: t.language.clone(),
|
||||||
]
|
|
||||||
.join(" ");
|
|
||||||
(text, t.language.clone())
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -109,7 +103,7 @@ pub fn reindex_all_with_progress(
|
|||||||
m.caption.as_deref(),
|
m.caption.as_deref(),
|
||||||
&m.original_name,
|
&m.original_name,
|
||||||
&m.tags,
|
&m.tags,
|
||||||
&trans_pairs,
|
&trans_data,
|
||||||
language,
|
language,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ pub type TaskId = u64;
|
|||||||
/// Task status.
|
/// Task status.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum TaskStatus {
|
pub enum TaskStatus {
|
||||||
Queued,
|
Pending,
|
||||||
Running,
|
Running,
|
||||||
Completed,
|
Completed,
|
||||||
Failed(String),
|
Failed(String),
|
||||||
@@ -28,11 +28,14 @@ pub struct TaskProgress {
|
|||||||
struct TaskEntry {
|
struct TaskEntry {
|
||||||
id: TaskId,
|
id: TaskId,
|
||||||
label: String,
|
label: String,
|
||||||
|
group_id: Option<String>,
|
||||||
|
group_name: Option<String>,
|
||||||
status: TaskStatus,
|
status: TaskStatus,
|
||||||
cancel_flag: Arc<AtomicBool>,
|
cancel_flag: Arc<AtomicBool>,
|
||||||
progress: Option<f32>,
|
progress: Option<f32>,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
created_at: Instant,
|
created_at: Instant,
|
||||||
|
last_progress_report: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages concurrent tasks with a max concurrency limit and FIFO queue.
|
/// Manages concurrent tasks with a max concurrency limit and FIFO queue.
|
||||||
@@ -61,11 +64,14 @@ impl TaskManager {
|
|||||||
let entry = TaskEntry {
|
let entry = TaskEntry {
|
||||||
id,
|
id,
|
||||||
label: label.to_owned(),
|
label: label.to_owned(),
|
||||||
status: TaskStatus::Queued,
|
group_id: None,
|
||||||
|
group_name: None,
|
||||||
|
status: TaskStatus::Pending,
|
||||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||||
progress: None,
|
progress: None,
|
||||||
message: None,
|
message: None,
|
||||||
created_at: Instant::now(),
|
created_at: Instant::now(),
|
||||||
|
last_progress_report: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
@@ -73,13 +79,24 @@ impl TaskManager {
|
|||||||
// Auto-start if under capacity
|
// Auto-start if under capacity
|
||||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
||||||
if running < self.max_concurrent {
|
if running < self.max_concurrent {
|
||||||
if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Queued) {
|
if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Pending) {
|
||||||
t.status = TaskStatus::Running;
|
t.status = TaskStatus::Running;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Submit a new task within a group. Returns its unique identifier.
|
||||||
|
pub fn submit_grouped(&self, label: &str, group_id: &str, group_name: &str) -> TaskId {
|
||||||
|
let id = self.submit(label);
|
||||||
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
|
if let Some(entry) = tasks.iter_mut().find(|t| t.id == id) {
|
||||||
|
entry.group_id = Some(group_id.to_owned());
|
||||||
|
entry.group_name = Some(group_name.to_owned());
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
/// Try to start a queued task. Returns true if the task was moved to
|
/// Try to start a queued task. Returns true if the task was moved to
|
||||||
/// Running, false if concurrency is at capacity or the task is not Queued.
|
/// Running, false if concurrency is at capacity or the task is not Queued.
|
||||||
pub fn try_start(&self, task_id: TaskId) -> bool {
|
pub fn try_start(&self, task_id: TaskId) -> bool {
|
||||||
@@ -89,7 +106,7 @@ impl TaskManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||||
if entry.status == TaskStatus::Queued {
|
if entry.status == TaskStatus::Pending {
|
||||||
entry.status = TaskStatus::Running;
|
entry.status = TaskStatus::Running;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -125,7 +142,7 @@ impl TaskManager {
|
|||||||
pub fn cancel(&self, task_id: TaskId) {
|
pub fn cancel(&self, task_id: TaskId) {
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||||
if matches!(entry.status, TaskStatus::Running | TaskStatus::Queued) {
|
if matches!(entry.status, TaskStatus::Running | TaskStatus::Pending) {
|
||||||
entry.cancel_flag.store(true, Ordering::Release);
|
entry.cancel_flag.store(true, Ordering::Release);
|
||||||
entry.status = TaskStatus::Cancelled;
|
entry.status = TaskStatus::Cancelled;
|
||||||
}
|
}
|
||||||
@@ -152,7 +169,7 @@ impl TaskManager {
|
|||||||
/// Count tasks that are still queued.
|
/// Count tasks that are still queued.
|
||||||
pub fn pending_count(&self) -> usize {
|
pub fn pending_count(&self) -> usize {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
tasks.iter().filter(|t| t.status == TaskStatus::Queued).count()
|
tasks.iter().filter(|t| t.status == TaskStatus::Pending).count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count tasks that are currently running.
|
/// Count tasks that are currently running.
|
||||||
@@ -164,7 +181,7 @@ impl TaskManager {
|
|||||||
/// Remove all completed, failed, and cancelled tasks.
|
/// Remove all completed, failed, and cancelled tasks.
|
||||||
pub fn drain_completed(&self) {
|
pub fn drain_completed(&self) {
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
tasks.retain(|t| matches!(t.status, TaskStatus::Queued | TaskStatus::Running));
|
tasks.retain(|t| matches!(t.status, TaskStatus::Pending | TaskStatus::Running));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the label of a task.
|
/// Return the label of a task.
|
||||||
@@ -176,16 +193,24 @@ impl TaskManager {
|
|||||||
/// Return the id of the first queued task (FIFO order).
|
/// Return the id of the first queued task (FIFO order).
|
||||||
pub fn next_queued(&self) -> Option<TaskId> {
|
pub fn next_queued(&self) -> Option<TaskId> {
|
||||||
let tasks = self.tasks.lock().unwrap();
|
let tasks = self.tasks.lock().unwrap();
|
||||||
tasks.iter().find(|t| t.status == TaskStatus::Queued).map(|t| t.id)
|
tasks.iter().find(|t| t.status == TaskStatus::Pending).map(|t| t.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update progress for a running task.
|
/// Update progress for a running task. Throttled to at most once per 250ms.
|
||||||
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
|
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
|
||||||
let mut tasks = self.tasks.lock().unwrap();
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||||
if entry.status == TaskStatus::Running {
|
if entry.status == TaskStatus::Running {
|
||||||
|
let now = Instant::now();
|
||||||
|
let should_report = match entry.last_progress_report {
|
||||||
|
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if should_report {
|
||||||
entry.progress = progress;
|
entry.progress = progress;
|
||||||
entry.message = message;
|
entry.message = message;
|
||||||
|
entry.last_progress_report = Some(now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +240,7 @@ impl TaskManager {
|
|||||||
fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) {
|
fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) {
|
||||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
||||||
if running < max_concurrent {
|
if running < max_concurrent {
|
||||||
if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Queued) {
|
if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Pending) {
|
||||||
t.status = TaskStatus::Running;
|
t.status = TaskStatus::Running;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,7 +304,7 @@ mod tests {
|
|||||||
|
|
||||||
// First 3 auto-started, 4th stays queued
|
// First 3 auto-started, 4th stays queued
|
||||||
assert_eq!(mgr.running_count(), 3);
|
assert_eq!(mgr.running_count(), 3);
|
||||||
assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Queued));
|
assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Pending));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -354,7 +379,7 @@ mod tests {
|
|||||||
let b = mgr.submit("second"); // queued
|
let b = mgr.submit("second"); // queued
|
||||||
|
|
||||||
assert_eq!(mgr.status(a), Some(TaskStatus::Running));
|
assert_eq!(mgr.status(a), Some(TaskStatus::Running));
|
||||||
assert_eq!(mgr.status(b), Some(TaskStatus::Queued));
|
assert_eq!(mgr.status(b), Some(TaskStatus::Pending));
|
||||||
|
|
||||||
mgr.complete(a);
|
mgr.complete(a);
|
||||||
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
|
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
|
||||||
|
|||||||
@@ -58,5 +58,10 @@ pub struct PublishingPreferences {
|
|||||||
pub ssh_user: Option<String>,
|
pub ssh_user: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ssh_remote_path: Option<String>,
|
pub ssh_remote_path: Option<String>,
|
||||||
|
#[serde(default = "default_ssh_mode")]
|
||||||
pub ssh_mode: SshMode,
|
pub ssh_mode: SshMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_ssh_mode() -> SshMode {
|
||||||
|
SshMode::Scp
|
||||||
|
}
|
||||||
|
|||||||
@@ -309,11 +309,11 @@ fn script_defaults_match_spec() {
|
|||||||
assert_eq!(entrypoint, "render", "spec: default entrypoint is 'render'");
|
assert_eq!(entrypoint, "render", "spec: default entrypoint is 'render'");
|
||||||
assert!(enabled, "spec: default enabled is true");
|
assert!(enabled, "spec: default enabled is true");
|
||||||
assert_eq!(version, 1, "spec: default version is 1");
|
assert_eq!(version, 1, "spec: default version is 1");
|
||||||
assert_eq!(status, "published", "spec: default status is published");
|
assert_eq!(status, "draft", "spec: default status is draft");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: schema.allium — Template defaults ──
|
// ── spec: schema.allium — Template defaults ──
|
||||||
// kind default 'post', enabled default true, version default 1, status default 'published'
|
// kind default 'post', enabled default true, version default 1, status default 'draft'
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn template_defaults_match_spec() {
|
fn template_defaults_match_spec() {
|
||||||
@@ -339,7 +339,7 @@ fn template_defaults_match_spec() {
|
|||||||
assert_eq!(kind, "post", "spec: default kind is post");
|
assert_eq!(kind, "post", "spec: default kind is post");
|
||||||
assert!(enabled, "spec: default enabled is true");
|
assert!(enabled, "spec: default enabled is true");
|
||||||
assert_eq!(version, 1, "spec: default version is 1");
|
assert_eq!(version, 1, "spec: default version is 1");
|
||||||
assert_eq!(status, "published", "spec: default status is published");
|
assert_eq!(status, "draft", "spec: default status is draft");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── spec: tag.allium — UniqueTagNamePerProject (case-insensitive) ──
|
// ── spec: tag.allium — UniqueTagNamePerProject (case-insensitive) ──
|
||||||
|
|||||||
@@ -32,11 +32,17 @@ pub enum Message {
|
|||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
TogglePanel,
|
TogglePanel,
|
||||||
|
|
||||||
|
// Sidebar resize
|
||||||
|
SidebarResizeStart,
|
||||||
|
SidebarResizeMove(f32),
|
||||||
|
SidebarResizeEnd,
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
OpenTab(Tab),
|
OpenTab(Tab),
|
||||||
CloseTab(String),
|
CloseTab(String),
|
||||||
SelectTab(String),
|
SelectTab(String),
|
||||||
PinTab(String),
|
PinTab(String),
|
||||||
|
ClearTabs,
|
||||||
|
|
||||||
// Project
|
// Project
|
||||||
ProjectsLoaded(Vec<Project>),
|
ProjectsLoaded(Vec<Project>),
|
||||||
@@ -111,6 +117,8 @@ pub struct BdsApp {
|
|||||||
// Navigation
|
// Navigation
|
||||||
sidebar_view: SidebarView,
|
sidebar_view: SidebarView,
|
||||||
sidebar_visible: bool,
|
sidebar_visible: bool,
|
||||||
|
sidebar_width: f32,
|
||||||
|
sidebar_dragging: bool,
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
tabs: Vec<Tab>,
|
tabs: Vec<Tab>,
|
||||||
@@ -230,6 +238,8 @@ impl BdsApp {
|
|||||||
sidebar_media: Vec::new(),
|
sidebar_media: Vec::new(),
|
||||||
sidebar_view: SidebarView::Posts,
|
sidebar_view: SidebarView::Posts,
|
||||||
sidebar_visible: true,
|
sidebar_visible: true,
|
||||||
|
sidebar_width: 280.0,
|
||||||
|
sidebar_dragging: false,
|
||||||
tabs: Vec::new(),
|
tabs: Vec::new(),
|
||||||
active_tab: None,
|
active_tab: None,
|
||||||
panel_visible: false,
|
panel_visible: false,
|
||||||
@@ -276,6 +286,22 @@ impl BdsApp {
|
|||||||
self.panel_visible = !self.panel_visible;
|
self.panel_visible = !self.panel_visible;
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
Message::SidebarResizeStart => {
|
||||||
|
self.sidebar_dragging = true;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SidebarResizeMove(x) => {
|
||||||
|
if self.sidebar_dragging {
|
||||||
|
// x is global cursor position; subtract activity bar width (~48px)
|
||||||
|
let effective = x - 48.0;
|
||||||
|
self.sidebar_width = effective.clamp(200.0, 500.0);
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SidebarResizeEnd => {
|
||||||
|
self.sidebar_dragging = false;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tabs ──
|
// ── Tabs ──
|
||||||
Message::OpenTab(tab) => {
|
Message::OpenTab(tab) => {
|
||||||
@@ -308,6 +334,11 @@ impl BdsApp {
|
|||||||
tabs::pin_tab(&mut self.tabs, &id);
|
tabs::pin_tab(&mut self.tabs, &id);
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
Message::ClearTabs => {
|
||||||
|
self.tabs.clear();
|
||||||
|
self.active_tab = None;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Project management ──
|
// ── Project management ──
|
||||||
Message::ProjectsLoaded(projects) => {
|
Message::ProjectsLoaded(projects) => {
|
||||||
@@ -463,6 +494,12 @@ impl BdsApp {
|
|||||||
self.ui_locale = locale;
|
self.ui_locale = locale;
|
||||||
self.locale_dropdown_open = false;
|
self.locale_dropdown_open = false;
|
||||||
menu::update_menu_labels(&self.menu_registry, locale);
|
menu::update_menu_labels(&self.menu_registry, locale);
|
||||||
|
// Re-translate singleton tab titles per tabs.allium
|
||||||
|
for tab in &mut self.tabs {
|
||||||
|
if let Some(key) = tab.tab_type.i18n_key() {
|
||||||
|
tab.title = t(locale, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ToggleLocaleDropdown => {
|
Message::ToggleLocaleDropdown => {
|
||||||
@@ -533,7 +570,7 @@ impl BdsApp {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Message::ValidateTranslations => {
|
Message::ValidateTranslations => {
|
||||||
self.open_singleton_tab(TabType::TranslationValidation, "Translation Validation");
|
self.open_singleton_tab(TabType::TranslationValidation, "tabBar.translationValidation");
|
||||||
self.spawn_engine_task(
|
self.spawn_engine_task(
|
||||||
"engine.validateTranslationsStarted",
|
"engine.validateTranslationsStarted",
|
||||||
|db_path, project_id, data_dir, tm, tid| {
|
|db_path, project_id, data_dir, tm, tid| {
|
||||||
@@ -569,7 +606,7 @@ impl BdsApp {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Message::RunMetadataDiff => {
|
Message::RunMetadataDiff => {
|
||||||
self.open_singleton_tab(TabType::MetadataDiff, "Metadata Diff");
|
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::EngineTaskDone { task_id, label, result } => {
|
Message::EngineTaskDone { task_id, label, result } => {
|
||||||
@@ -617,6 +654,7 @@ impl BdsApp {
|
|||||||
workspace::view(
|
workspace::view(
|
||||||
self.sidebar_view,
|
self.sidebar_view,
|
||||||
self.sidebar_visible,
|
self.sidebar_visible,
|
||||||
|
self.sidebar_width,
|
||||||
&self.tabs,
|
&self.tabs,
|
||||||
self.active_tab.as_deref(),
|
self.active_tab.as_deref(),
|
||||||
self.panel_visible,
|
self.panel_visible,
|
||||||
@@ -664,12 +702,12 @@ impl BdsApp {
|
|||||||
if let (Some(db), Some(project), Some(data_dir)) =
|
if let (Some(db), Some(project), Some(data_dir)) =
|
||||||
(&self.db, &self.active_project, &self.data_dir)
|
(&self.db, &self.active_project, &self.data_dir)
|
||||||
{
|
{
|
||||||
let title = t(self.ui_locale, "post.untitled");
|
let display_title = t(self.ui_locale, "post.untitled");
|
||||||
match engine::post::create_post(
|
match engine::post::create_post(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
data_dir,
|
data_dir,
|
||||||
&project.id,
|
&project.id,
|
||||||
&title,
|
"",
|
||||||
Some(""),
|
Some(""),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -681,7 +719,7 @@ impl BdsApp {
|
|||||||
let tab = Tab {
|
let tab = Tab {
|
||||||
id: post.id.clone(),
|
id: post.id.clone(),
|
||||||
tab_type: TabType::Post,
|
tab_type: TabType::Post,
|
||||||
title: post.title.clone(),
|
title: display_title.to_string(),
|
||||||
is_transient: true,
|
is_transient: true,
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
};
|
};
|
||||||
@@ -713,7 +751,7 @@ impl BdsApp {
|
|||||||
MenuAction::Find => Task::none(),
|
MenuAction::Find => Task::none(),
|
||||||
MenuAction::Replace => Task::none(),
|
MenuAction::Replace => Task::none(),
|
||||||
MenuAction::EditPreferences => {
|
MenuAction::EditPreferences => {
|
||||||
self.open_singleton_tab(TabType::Settings, "Settings");
|
self.open_singleton_tab(TabType::Settings, "common.settings");
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
// View
|
// View
|
||||||
@@ -733,7 +771,7 @@ impl BdsApp {
|
|||||||
MenuAction::PublishSelected => Task::none(), // Disabled in M2
|
MenuAction::PublishSelected => Task::none(), // Disabled in M2
|
||||||
MenuAction::PreviewPost => Task::none(), // Disabled in M2
|
MenuAction::PreviewPost => Task::none(), // Disabled in M2
|
||||||
MenuAction::EditMenu => {
|
MenuAction::EditMenu => {
|
||||||
self.open_singleton_tab(TabType::MenuEditor, "Menu Editor");
|
self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor");
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
|
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
|
||||||
@@ -751,7 +789,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
|
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
|
||||||
MenuAction::ValidateSite => {
|
MenuAction::ValidateSite => {
|
||||||
self.open_singleton_tab(TabType::SiteValidation, "Site Validation");
|
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
MenuAction::UploadSite => {
|
MenuAction::UploadSite => {
|
||||||
@@ -780,7 +818,7 @@ impl BdsApp {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
MenuAction::OpenDocumentation => {
|
MenuAction::OpenDocumentation => {
|
||||||
self.open_singleton_tab(TabType::Documentation, "Documentation");
|
self.open_singleton_tab(TabType::Documentation, "tabBar.documentation");
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
MenuAction::ViewOnGitHub => {
|
MenuAction::ViewOnGitHub => {
|
||||||
@@ -794,11 +832,12 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_singleton_tab(&mut self, tab_type: TabType, title: &str) {
|
fn open_singleton_tab(&mut self, tab_type: TabType, i18n_key: &str) {
|
||||||
|
let title = t(self.ui_locale, i18n_key);
|
||||||
let tab = Tab {
|
let tab = Tab {
|
||||||
id: tab_type.singleton_id().to_string(),
|
id: tab_type.singleton_id().to_string(),
|
||||||
tab_type,
|
tab_type,
|
||||||
title: title.to_string(),
|
title,
|
||||||
is_transient: false,
|
is_transient: false,
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
};
|
};
|
||||||
@@ -806,6 +845,7 @@ impl BdsApp {
|
|||||||
if let Some(t) = self.tabs.get(idx) {
|
if let Some(t) = self.tabs.get(idx) {
|
||||||
self.active_tab = Some(t.id.clone());
|
self.active_tab = Some(t.id.clone());
|
||||||
}
|
}
|
||||||
|
self.enforce_panel_tab_availability();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_task_snapshots(&mut self) {
|
fn refresh_task_snapshots(&mut self) {
|
||||||
@@ -815,7 +855,7 @@ impl BdsApp {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, label, status, progress, message)| {
|
.map(|(id, label, status, progress, message)| {
|
||||||
let status_str = match &status {
|
let status_str = match &status {
|
||||||
TaskStatus::Queued => "queued".to_string(),
|
TaskStatus::Pending => "pending".to_string(),
|
||||||
TaskStatus::Running => "running".to_string(),
|
TaskStatus::Running => "running".to_string(),
|
||||||
TaskStatus::Completed => "completed".to_string(),
|
TaskStatus::Completed => "completed".to_string(),
|
||||||
TaskStatus::Failed(e) => format!("failed: {e}"),
|
TaskStatus::Failed(e) => format!("failed: {e}"),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub enum MenuAction {
|
|||||||
ViewMedia,
|
ViewMedia,
|
||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
TogglePanel,
|
TogglePanel,
|
||||||
// Blog
|
// Window
|
||||||
PublishSelected,
|
PublishSelected,
|
||||||
PreviewPost,
|
PreviewPost,
|
||||||
EditMenu,
|
EditMenu,
|
||||||
@@ -249,28 +249,28 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
|||||||
let _ = view_menu.append(&PredefinedMenuItem::separator());
|
let _ = view_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
|
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
|
||||||
|
|
||||||
// -- Blog --
|
// -- Window --
|
||||||
let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true);
|
let window_menu = Submenu::new(translate(locale, "menu.group.window"), true);
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale,
|
let _ = window_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP))));
|
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP))));
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale,
|
let _ = window_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV))));
|
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV))));
|
||||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
let _ = window_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None));
|
let _ = window_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None));
|
||||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
let _ = window_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None));
|
let _ = window_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None));
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
|
let _ = window_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
|
let _ = window_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
|
||||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
let _ = window_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None));
|
let _ = window_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None));
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None));
|
let _ = window_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None));
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None));
|
let _ = window_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None));
|
||||||
let _ = blog_menu.append(&PredefinedMenuItem::separator());
|
let _ = window_menu.append(&PredefinedMenuItem::separator());
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale,
|
let _ = window_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR))));
|
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR))));
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale,
|
let _ = window_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL))));
|
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL))));
|
||||||
let _ = blog_menu.append(&item(&mut reg, MenuAction::UploadSite, locale,
|
let _ = window_menu.append(&item(&mut reg, MenuAction::UploadSite, locale,
|
||||||
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU))));
|
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU))));
|
||||||
|
|
||||||
// -- Help --
|
// -- Help --
|
||||||
@@ -286,7 +286,7 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
|
|||||||
let _ = menu.append(&file_menu);
|
let _ = menu.append(&file_menu);
|
||||||
let _ = menu.append(&edit_menu);
|
let _ = menu.append(&edit_menu);
|
||||||
let _ = menu.append(&view_menu);
|
let _ = menu.append(&view_menu);
|
||||||
let _ = menu.append(&blog_menu);
|
let _ = menu.append(&window_menu);
|
||||||
let _ = menu.append(&help_menu);
|
let _ = menu.append(&help_menu);
|
||||||
|
|
||||||
(menu, reg)
|
(menu, reg)
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ impl TabType {
|
|||||||
_ => "",
|
_ => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the i18n key for the tab title of a singleton tab type.
|
||||||
|
/// Per tabs.allium: singletons use i18n key lookup.
|
||||||
|
pub fn i18n_key(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::Settings => Some("common.settings"),
|
||||||
|
Self::Style => Some("tabBar.style"),
|
||||||
|
Self::Tags => Some("tabBar.tags"),
|
||||||
|
Self::MenuEditor => Some("tabBar.menuEditor"),
|
||||||
|
Self::MetadataDiff => Some("tabBar.metadataDiff"),
|
||||||
|
Self::Documentation => Some("tabBar.documentation"),
|
||||||
|
Self::ApiDocumentation => Some("tabBar.apiDocumentation"),
|
||||||
|
Self::SiteValidation => Some("tabBar.siteValidation"),
|
||||||
|
Self::TranslationValidation => Some("tabBar.translationValidation"),
|
||||||
|
Self::FindDuplicates => Some("tabBar.findDuplicates"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single tab in the editor area.
|
/// A single tab in the editor area.
|
||||||
|
|||||||
@@ -127,8 +127,11 @@ pub fn view(
|
|||||||
.padding(8)
|
.padding(8)
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
|
// Per layout.allium: last 10 tasks, newest first
|
||||||
let items: Vec<Element<'static, Message>> = task_snapshots
|
let items: Vec<Element<'static, Message>> = task_snapshots
|
||||||
.iter()
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(10)
|
||||||
.map(|snap| {
|
.map(|snap| {
|
||||||
let progress_str = snap.progress
|
let progress_str = snap.progress
|
||||||
.map(|p| format!(" ({:.0}%)", p * 100.0))
|
.map(|p| format!(" ({:.0}%)", p * 100.0))
|
||||||
|
|||||||
@@ -10,15 +10,10 @@ use crate::i18n::t;
|
|||||||
use crate::state::navigation::SidebarView;
|
use crate::state::navigation::SidebarView;
|
||||||
use crate::state::tabs::{Tab, TabType};
|
use crate::state::tabs::{Tab, TabType};
|
||||||
|
|
||||||
/// Sidebar container style — dark background with right border separator.
|
/// Sidebar container style — dark background.
|
||||||
fn sidebar_style(_theme: &Theme) -> container::Style {
|
fn sidebar_style(_theme: &Theme) -> container::Style {
|
||||||
container::Style {
|
container::Style {
|
||||||
background: Some(Background::Color(Color::from_rgb(0.16, 0.16, 0.20))),
|
background: Some(Background::Color(Color::from_rgb(0.16, 0.16, 0.20))),
|
||||||
border: Border {
|
|
||||||
color: Color::from_rgb(0.25, 0.25, 0.30),
|
|
||||||
width: 1.0,
|
|
||||||
radius: 0.0.into(),
|
|
||||||
},
|
|
||||||
..container::Style::default()
|
..container::Style::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +32,20 @@ fn item_style(_theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sidebar item button style — active/selected.
|
||||||
|
fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let bg = match status {
|
||||||
|
button::Status::Hovered => Color::from_rgb(0.26, 0.26, 0.32),
|
||||||
|
_ => Color::from_rgb(0.22, 0.22, 0.28),
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(bg)),
|
||||||
|
text_color: Color::WHITE,
|
||||||
|
border: Border::default(),
|
||||||
|
..button::Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the appropriate empty-state message key for each sidebar view.
|
/// Get the appropriate empty-state message key for each sidebar view.
|
||||||
fn placeholder_key(view: SidebarView) -> &'static str {
|
fn placeholder_key(view: SidebarView) -> &'static str {
|
||||||
match view {
|
match view {
|
||||||
@@ -53,10 +62,26 @@ fn placeholder_key(view: SidebarView) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// sidebar_views.allium media_title_max_length = 60
|
||||||
|
const MEDIA_TITLE_MAX_LEN: usize = 60;
|
||||||
|
|
||||||
|
/// Truncate a media title to the max length, appending "..." if over limit.
|
||||||
|
/// Per sidebar_views.allium: JS hard limit of 60 chars on title (substring + "...").
|
||||||
|
fn truncate_media_title(title: &str) -> String {
|
||||||
|
if title.chars().count() > MEDIA_TITLE_MAX_LEN {
|
||||||
|
let truncated: String = title.chars().take(MEDIA_TITLE_MAX_LEN).collect();
|
||||||
|
format!("{truncated}...")
|
||||||
|
} else {
|
||||||
|
title.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn view(
|
pub fn view(
|
||||||
sidebar_view: SidebarView,
|
sidebar_view: SidebarView,
|
||||||
posts: &[Post],
|
posts: &[Post],
|
||||||
media: &[Media],
|
media: &[Media],
|
||||||
|
width: f32,
|
||||||
|
active_tab: Option<&str>,
|
||||||
locale: UiLocale,
|
locale: UiLocale,
|
||||||
) -> Element<'static, Message> {
|
) -> Element<'static, Message> {
|
||||||
let header_text = t(locale, sidebar_view.i18n_key());
|
let header_text = t(locale, sidebar_view.i18n_key());
|
||||||
@@ -76,16 +101,32 @@ pub fn view(
|
|||||||
.color(muted)
|
.color(muted)
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
let items: Vec<Element<'static, Message>> = posts
|
let section_header = |label: &str| -> Element<'static, Message> {
|
||||||
.iter()
|
text(label.to_string())
|
||||||
.map(|p| {
|
.size(11)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(Color::from_rgb(0.55, 0.55, 0.60))
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let make_post_item = |p: &Post| -> Element<'static, Message> {
|
||||||
|
let is_active = active_tab == Some(p.id.as_str());
|
||||||
let status_indicator = match p.status {
|
let status_indicator = match p.status {
|
||||||
bds_core::model::PostStatus::Draft => "\u{25CB} ", // ○
|
bds_core::model::PostStatus::Draft => "\u{25CB} ",
|
||||||
bds_core::model::PostStatus::Published => "\u{25CF} ", // ●
|
bds_core::model::PostStatus::Published => "\u{25CF} ",
|
||||||
bds_core::model::PostStatus::Archived => "\u{25A1} ", // □
|
bds_core::model::PostStatus::Archived => "\u{25A1} ",
|
||||||
};
|
};
|
||||||
let label = format!("{status_indicator}{}", p.title);
|
let label = format!("{status_indicator}{}", p.title);
|
||||||
button(text(label).size(12).shaping(Shaping::Advanced))
|
let label_text = text(label)
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.wrapping(iced::widget::text::Wrapping::None);
|
||||||
|
let style_fn = if is_active { item_active_style } else { item_style };
|
||||||
|
button(
|
||||||
|
container(label_text)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.clip(true)
|
||||||
|
)
|
||||||
.on_press(Message::OpenTab(Tab {
|
.on_press(Message::OpenTab(Tab {
|
||||||
id: p.id.clone(),
|
id: p.id.clone(),
|
||||||
tab_type: TabType::Post,
|
tab_type: TabType::Post,
|
||||||
@@ -95,11 +136,42 @@ pub fn view(
|
|||||||
}))
|
}))
|
||||||
.padding([3, 6])
|
.padding([3, 6])
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.style(item_style)
|
.style(style_fn)
|
||||||
.into()
|
.into()
|
||||||
})
|
};
|
||||||
.collect();
|
|
||||||
iced::widget::Column::with_children(items)
|
let mut sections: Vec<Element<'static, Message>> = Vec::new();
|
||||||
|
|
||||||
|
// Draft section
|
||||||
|
let drafts: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Draft).collect();
|
||||||
|
if !drafts.is_empty() {
|
||||||
|
sections.push(section_header(&t(locale, "sidebar.drafts")));
|
||||||
|
for p in &drafts {
|
||||||
|
sections.push(make_post_item(p));
|
||||||
|
}
|
||||||
|
sections.push(Space::with_height(6.0).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Published section
|
||||||
|
let published: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Published).collect();
|
||||||
|
if !published.is_empty() {
|
||||||
|
sections.push(section_header(&t(locale, "sidebar.published")));
|
||||||
|
for p in &published {
|
||||||
|
sections.push(make_post_item(p));
|
||||||
|
}
|
||||||
|
sections.push(Space::with_height(6.0).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archived section
|
||||||
|
let archived: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Archived).collect();
|
||||||
|
if !archived.is_empty() {
|
||||||
|
sections.push(section_header(&t(locale, "sidebar.archived")));
|
||||||
|
for p in &archived {
|
||||||
|
sections.push(make_post_item(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
iced::widget::Column::with_children(sections)
|
||||||
.spacing(1)
|
.spacing(1)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -115,20 +187,34 @@ pub fn view(
|
|||||||
let items: Vec<Element<'static, Message>> = media
|
let items: Vec<Element<'static, Message>> = media
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
let display_name = m.title.as_deref()
|
let is_active = active_tab == Some(m.id.as_str());
|
||||||
.unwrap_or(&m.original_name);
|
// Per sidebar_views.allium MediaGridItem: title truncated to 60 chars + "..."
|
||||||
|
// if over limit; fallback originalName (no truncation).
|
||||||
|
let display_name = match m.title.as_deref() {
|
||||||
|
Some(title) => truncate_media_title(title),
|
||||||
|
None => m.original_name.clone(),
|
||||||
|
};
|
||||||
let label = format!("\u{1F5BC} {display_name}");
|
let label = format!("\u{1F5BC} {display_name}");
|
||||||
button(text(label).size(12).shaping(Shaping::Advanced))
|
let label_text = text(label)
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.wrapping(iced::widget::text::Wrapping::None);
|
||||||
|
let style_fn = if is_active { item_active_style } else { item_style };
|
||||||
|
button(
|
||||||
|
container(label_text)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.clip(true)
|
||||||
|
)
|
||||||
.on_press(Message::OpenTab(Tab {
|
.on_press(Message::OpenTab(Tab {
|
||||||
id: m.id.clone(),
|
id: m.id.clone(),
|
||||||
tab_type: TabType::Media,
|
tab_type: TabType::Media,
|
||||||
title: display_name.to_string(),
|
title: display_name.clone(),
|
||||||
is_transient: true,
|
is_transient: true,
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
}))
|
}))
|
||||||
.padding([3, 6])
|
.padding([3, 6])
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.style(item_style)
|
.style(style_fn)
|
||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -154,9 +240,41 @@ pub fn view(
|
|||||||
.spacing(4)
|
.spacing(4)
|
||||||
.padding(12);
|
.padding(12);
|
||||||
|
|
||||||
|
// layout.allium: sidebar width is resizable, passed as parameter
|
||||||
container(scrollable(content))
|
container(scrollable(content))
|
||||||
.width(Length::Fixed(280.0))
|
.width(Length::Fixed(width))
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.style(sidebar_style)
|
.style(sidebar_style)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_media_title_short() {
|
||||||
|
assert_eq!(truncate_media_title("short title"), "short title");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_media_title_exact_60() {
|
||||||
|
let title: String = "a".repeat(60);
|
||||||
|
assert_eq!(truncate_media_title(&title), title);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_media_title_over_60() {
|
||||||
|
let title: String = "a".repeat(65);
|
||||||
|
let expected = format!("{}...", "a".repeat(60));
|
||||||
|
assert_eq!(truncate_media_title(&title), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_media_title_unicode() {
|
||||||
|
// 61 Unicode chars should trigger truncation
|
||||||
|
let title: String = "\u{00FC}".repeat(61); // ü × 61
|
||||||
|
let expected = format!("{}...", "\u{00FC}".repeat(60));
|
||||||
|
assert_eq!(truncate_media_title(&title), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
use iced::widget::{button, container, row, text, Space};
|
use iced::widget::{button, container, row, scrollable, text, tooltip, Space};
|
||||||
|
use iced::widget::scrollable::Direction;
|
||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
|
use iced::widget::tooltip::Position;
|
||||||
use iced::{Background, Border, Color, Element, Font, Length, Theme};
|
use iced::{Background, Border, Color, Element, Font, Length, Theme};
|
||||||
|
|
||||||
|
use bds_core::i18n::UiLocale;
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
|
use crate::i18n::t;
|
||||||
use crate::state::tabs::Tab;
|
use crate::state::tabs::Tab;
|
||||||
|
|
||||||
|
/// tabs.allium config: tab_min_width=100, tab_max_width=160
|
||||||
|
/// In a scrollable tab bar, tabs use the max width since they never need to shrink.
|
||||||
|
const TAB_WIDTH: f32 = 160.0;
|
||||||
|
const CHAT_TITLE_MAX_LEN: usize = 18;
|
||||||
|
|
||||||
/// Tab bar background.
|
/// Tab bar background.
|
||||||
fn bar_style(_theme: &Theme) -> container::Style {
|
fn bar_style(_theme: &Theme) -> container::Style {
|
||||||
container::Style {
|
container::Style {
|
||||||
@@ -64,9 +74,49 @@ fn close_style(_theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tooltip style.
|
||||||
|
fn tooltip_style(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb(0.20, 0.20, 0.24))),
|
||||||
|
border: Border {
|
||||||
|
color: Color::from_rgb(0.35, 0.35, 0.40),
|
||||||
|
width: 1.0,
|
||||||
|
radius: 4.0.into(),
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate chat title per tabs.allium: chat_title_max_length = 18.
|
||||||
|
fn truncate_chat_title(title: &str) -> String {
|
||||||
|
if title.chars().count() > CHAT_TITLE_MAX_LEN {
|
||||||
|
let truncated: String = title.chars().take(CHAT_TITLE_MAX_LEN).collect();
|
||||||
|
format!("{truncated}...")
|
||||||
|
} else {
|
||||||
|
title.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build tooltip text per tabs.allium:
|
||||||
|
/// Base: tab title. If transient: append " (Preview)". If dirty: append " * Modified".
|
||||||
|
fn build_tooltip_text(tab: &Tab, locale: UiLocale) -> String {
|
||||||
|
let mut tip = tab.title.clone();
|
||||||
|
if tab.is_transient {
|
||||||
|
tip.push_str(" (");
|
||||||
|
tip.push_str(&t(locale, "tabBar.preview"));
|
||||||
|
tip.push(')');
|
||||||
|
}
|
||||||
|
if tab.is_dirty {
|
||||||
|
tip.push_str(" * ");
|
||||||
|
tip.push_str(&t(locale, "tabBar.modified"));
|
||||||
|
}
|
||||||
|
tip
|
||||||
|
}
|
||||||
|
|
||||||
pub fn view(
|
pub fn view(
|
||||||
tabs: &[Tab],
|
tabs: &[Tab],
|
||||||
active_tab: Option<&str>,
|
active_tab: Option<&str>,
|
||||||
|
locale: UiLocale,
|
||||||
) -> Element<'static, Message> {
|
) -> Element<'static, Message> {
|
||||||
// Per tabs.allium: "Hidden when no tabs exist."
|
// Per tabs.allium: "Hidden when no tabs exist."
|
||||||
if tabs.is_empty() {
|
if tabs.is_empty() {
|
||||||
@@ -80,16 +130,25 @@ pub fn view(
|
|||||||
let tab_id = tab.id.clone();
|
let tab_id = tab.id.clone();
|
||||||
let close_id = tab.id.clone();
|
let close_id = tab.id.clone();
|
||||||
|
|
||||||
|
// Per tabs.allium: chat titles are JS-truncated to 18 chars + "..."
|
||||||
|
let display_title = if tab.tab_type == crate::state::tabs::TabType::Chat {
|
||||||
|
truncate_chat_title(&tab.title)
|
||||||
|
} else {
|
||||||
|
tab.title.clone()
|
||||||
|
};
|
||||||
|
|
||||||
// Per tabs.allium: transient tabs show italic title
|
// Per tabs.allium: transient tabs show italic title
|
||||||
let title_label = if tab.is_transient {
|
let title_label = if tab.is_transient {
|
||||||
text(tab.title.clone())
|
text(display_title)
|
||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT })
|
.font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT })
|
||||||
|
.wrapping(iced::widget::text::Wrapping::None)
|
||||||
} else {
|
} else {
|
||||||
text(tab.title.clone())
|
text(display_title)
|
||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
|
.wrapping(iced::widget::text::Wrapping::None)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Per tabs.allium DirtyIndicator: dot for dirty post tabs
|
// Per tabs.allium DirtyIndicator: dot for dirty post tabs
|
||||||
@@ -100,32 +159,85 @@ pub fn view(
|
|||||||
text("").size(10)
|
text("").size(10)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Title + dirty indicator clipped to enforce ellipsis-like truncation
|
||||||
|
let title_area = container(
|
||||||
|
row![title_label, dirty_indicator]
|
||||||
|
.spacing(2)
|
||||||
|
.align_y(iced::Alignment::Center)
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.clip(true);
|
||||||
|
|
||||||
let label = row![
|
let label = row![
|
||||||
title_label,
|
title_area,
|
||||||
dirty_indicator,
|
|
||||||
button(text("\u{2715}").size(10).shaping(Shaping::Advanced))
|
button(text("\u{2715}").size(10).shaping(Shaping::Advanced))
|
||||||
.on_press(Message::CloseTab(close_id))
|
.on_press(Message::CloseTab(close_id))
|
||||||
.padding(2)
|
.padding(2)
|
||||||
.style(close_style),
|
.style(close_style),
|
||||||
]
|
]
|
||||||
.spacing(6)
|
.spacing(4)
|
||||||
.align_y(iced::Alignment::Center);
|
.align_y(iced::Alignment::Center);
|
||||||
|
|
||||||
button(label)
|
// tabs.allium: tab_min_width=100, tab_max_width=160
|
||||||
|
let tab_btn = button(label)
|
||||||
.on_press(Message::SelectTab(tab_id))
|
.on_press(Message::SelectTab(tab_id))
|
||||||
.padding([6, 12])
|
.padding([6, 8])
|
||||||
.style(if is_active { tab_active } else { tab_inactive })
|
.width(Length::Fixed(TAB_WIDTH))
|
||||||
.into()
|
.style(if is_active { tab_active } else { tab_inactive });
|
||||||
|
|
||||||
|
// tabs.allium tooltip: title + "(Preview)" if transient + "* Modified" if dirty
|
||||||
|
let tooltip_text = build_tooltip_text(tab, locale);
|
||||||
|
let tip: Element<'static, Message> = tooltip(
|
||||||
|
tab_btn,
|
||||||
|
text(tooltip_text).size(11).shaping(Shaping::Advanced),
|
||||||
|
Position::Bottom,
|
||||||
|
)
|
||||||
|
.gap(4)
|
||||||
|
.style(tooltip_style)
|
||||||
|
.into();
|
||||||
|
|
||||||
|
tip
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
container(
|
// tabs.allium: horizontal strip with overflow scroll
|
||||||
iced::widget::Row::with_children(tab_buttons)
|
let tab_row = iced::widget::Row::with_children(tab_buttons)
|
||||||
.spacing(1)
|
.spacing(1)
|
||||||
.height(Length::Fixed(35.0)),
|
.height(Length::Fixed(35.0));
|
||||||
)
|
|
||||||
|
let scrollable_tabs = scrollable(tab_row)
|
||||||
|
.direction(Direction::Horizontal(
|
||||||
|
scrollable::Scrollbar::new().width(0).scroller_width(0),
|
||||||
|
))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fixed(35.0));
|
||||||
|
|
||||||
|
container(scrollable_tabs)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fixed(35.0))
|
.height(Length::Fixed(35.0))
|
||||||
.style(bar_style)
|
.style(bar_style)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_chat_title_short() {
|
||||||
|
assert_eq!(truncate_chat_title("Hello"), "Hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_chat_title_exact_18() {
|
||||||
|
let title: String = "a".repeat(18);
|
||||||
|
assert_eq!(truncate_chat_title(&title), title);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_chat_title_over_18() {
|
||||||
|
let title: String = "a".repeat(25);
|
||||||
|
let expected = format!("{}...", "a".repeat(18));
|
||||||
|
assert_eq!(truncate_chat_title(&title), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use iced::widget::{button, column, container, row, stack, text, Space};
|
use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
|
||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
|
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
|
||||||
|
|
||||||
@@ -19,15 +19,12 @@ fn content_bg(_theme: &Theme) -> container::Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Horizontal separator line between regions.
|
/// Sidebar resize drag handle style.
|
||||||
fn separator_v() -> iced::widget::Container<'static, Message> {
|
fn drag_handle_style(_theme: &Theme) -> container::Style {
|
||||||
container(Space::new(0, 0))
|
container::Style {
|
||||||
.width(Length::Fixed(1.0))
|
|
||||||
.height(Length::Fill)
|
|
||||||
.style(|_theme: &Theme| container::Style {
|
|
||||||
background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
|
background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
|
||||||
..container::Style::default()
|
..container::Style::default()
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Horizontal line separator (full width).
|
/// Horizontal line separator (full width).
|
||||||
@@ -46,6 +43,7 @@ pub fn view(
|
|||||||
// Navigation
|
// Navigation
|
||||||
sidebar_view: SidebarView,
|
sidebar_view: SidebarView,
|
||||||
sidebar_visible: bool,
|
sidebar_visible: bool,
|
||||||
|
sidebar_width: f32,
|
||||||
// Tabs
|
// Tabs
|
||||||
tabs: &[Tab],
|
tabs: &[Tab],
|
||||||
active_tab: Option<&str>,
|
active_tab: Option<&str>,
|
||||||
@@ -76,7 +74,7 @@ pub fn view(
|
|||||||
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
|
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
|
||||||
|
|
||||||
// Tab bar
|
// Tab bar
|
||||||
let tabs_el = tab_bar::view(tabs, active_tab);
|
let tabs_el = tab_bar::view(tabs, active_tab, locale);
|
||||||
|
|
||||||
// Content area
|
// Content area
|
||||||
let content_area = welcome::view(locale);
|
let content_area = welcome::view(locale);
|
||||||
@@ -104,12 +102,25 @@ pub fn view(
|
|||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.style(content_bg);
|
.style(content_bg);
|
||||||
|
|
||||||
// Main row: activity bar | separator | sidebar? | separator | right column
|
// Main row: activity bar | sidebar? | drag handle | right column
|
||||||
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, locale));
|
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_width, active_tab, locale));
|
||||||
main_row = main_row.push(separator_v());
|
|
||||||
|
// Resize drag handle: 4px wide strip between sidebar and content
|
||||||
|
let handle = container(Space::new(0, 0))
|
||||||
|
.width(Length::Fixed(4.0))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(drag_handle_style);
|
||||||
|
|
||||||
|
let handle_hover = mouse_area(handle)
|
||||||
|
.on_press(Message::SidebarResizeStart)
|
||||||
|
.on_release(Message::SidebarResizeEnd)
|
||||||
|
.on_move(|point| Message::SidebarResizeMove(point.x))
|
||||||
|
.interaction(iced::mouse::Interaction::ResizingHorizontally);
|
||||||
|
|
||||||
|
main_row = main_row.push(handle_hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
main_row = main_row.push(right);
|
main_row = main_row.push(right);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"menu.group.file": "Datei",
|
"menu.group.file": "Datei",
|
||||||
"menu.group.edit": "Bearbeiten",
|
"menu.group.edit": "Bearbeiten",
|
||||||
"menu.group.view": "Ansicht",
|
"menu.group.view": "Ansicht",
|
||||||
"menu.group.blog": "Blogbereich",
|
"menu.group.window": "Fenster",
|
||||||
"menu.group.help": "Hilfe",
|
"menu.group.help": "Hilfe",
|
||||||
"menu.item.newPost": "Neuer Beitrag",
|
"menu.item.newPost": "Neuer Beitrag",
|
||||||
"menu.item.importMedia": "Medien importieren...",
|
"menu.item.importMedia": "Medien importieren...",
|
||||||
@@ -88,6 +88,16 @@
|
|||||||
"tabBar.loading": "Laden...",
|
"tabBar.loading": "Laden...",
|
||||||
"tabBar.unknown": "Unbekannt",
|
"tabBar.unknown": "Unbekannt",
|
||||||
"tabBar.style": "Stil",
|
"tabBar.style": "Stil",
|
||||||
|
"tabBar.menuEditor": "Menü-Editor",
|
||||||
|
"tabBar.metadataDiff": "Metadaten-Diff",
|
||||||
|
"tabBar.siteValidation": "Website-Validierung",
|
||||||
|
"tabBar.translationValidation": "Übersetzungsvalidierung",
|
||||||
|
"tabBar.documentation": "Dokumentation",
|
||||||
|
"tabBar.apiDocumentation": "API-Dokumentation",
|
||||||
|
"tabBar.findDuplicates": "Duplikate finden",
|
||||||
|
"tabBar.tags": "Tags",
|
||||||
|
"tabBar.preview": "Vorschau",
|
||||||
|
"tabBar.modified": "Geändert",
|
||||||
"sidebar.noPostsYet": "Noch keine Beiträge",
|
"sidebar.noPostsYet": "Noch keine Beiträge",
|
||||||
"sidebar.noPagesYet": "Noch keine Seiten",
|
"sidebar.noPagesYet": "Noch keine Seiten",
|
||||||
"sidebar.noMediaYet": "Noch keine Medien",
|
"sidebar.noMediaYet": "Noch keine Medien",
|
||||||
@@ -99,6 +109,9 @@
|
|||||||
"sidebar.loading": "Lädt...",
|
"sidebar.loading": "Lädt...",
|
||||||
"sidebar.settingsHeader": "Einstellungen",
|
"sidebar.settingsHeader": "Einstellungen",
|
||||||
"sidebar.tagsHeader": "Schlagwörter",
|
"sidebar.tagsHeader": "Schlagwörter",
|
||||||
|
"sidebar.drafts": "Entwürfe",
|
||||||
|
"sidebar.published": "Veröffentlicht",
|
||||||
|
"sidebar.archived": "Archiviert",
|
||||||
"language.en": "Englisch",
|
"language.en": "Englisch",
|
||||||
"language.de": "Deutsch",
|
"language.de": "Deutsch",
|
||||||
"language.fr": "Französisch",
|
"language.fr": "Französisch",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"menu.group.file": "File",
|
"menu.group.file": "File",
|
||||||
"menu.group.edit": "Edit",
|
"menu.group.edit": "Edit",
|
||||||
"menu.group.view": "View",
|
"menu.group.view": "View",
|
||||||
"menu.group.blog": "Blog",
|
"menu.group.window": "Window",
|
||||||
"menu.group.help": "Help",
|
"menu.group.help": "Help",
|
||||||
"menu.item.newPost": "New Post",
|
"menu.item.newPost": "New Post",
|
||||||
"menu.item.importMedia": "Import Media...",
|
"menu.item.importMedia": "Import Media...",
|
||||||
@@ -88,6 +88,16 @@
|
|||||||
"tabBar.loading": "Loading...",
|
"tabBar.loading": "Loading...",
|
||||||
"tabBar.unknown": "Unknown",
|
"tabBar.unknown": "Unknown",
|
||||||
"tabBar.style": "Style",
|
"tabBar.style": "Style",
|
||||||
|
"tabBar.menuEditor": "Menu Editor",
|
||||||
|
"tabBar.metadataDiff": "Metadata Diff",
|
||||||
|
"tabBar.siteValidation": "Site Validation",
|
||||||
|
"tabBar.translationValidation": "Translation Validation",
|
||||||
|
"tabBar.documentation": "Documentation",
|
||||||
|
"tabBar.apiDocumentation": "API Documentation",
|
||||||
|
"tabBar.findDuplicates": "Find Duplicates",
|
||||||
|
"tabBar.tags": "Tags",
|
||||||
|
"tabBar.preview": "Preview",
|
||||||
|
"tabBar.modified": "Modified",
|
||||||
"sidebar.noPostsYet": "No posts yet",
|
"sidebar.noPostsYet": "No posts yet",
|
||||||
"sidebar.noPagesYet": "No pages yet",
|
"sidebar.noPagesYet": "No pages yet",
|
||||||
"sidebar.noMediaYet": "No media yet",
|
"sidebar.noMediaYet": "No media yet",
|
||||||
@@ -99,6 +109,9 @@
|
|||||||
"sidebar.loading": "Loading...",
|
"sidebar.loading": "Loading...",
|
||||||
"sidebar.settingsHeader": "Settings",
|
"sidebar.settingsHeader": "Settings",
|
||||||
"sidebar.tagsHeader": "Tags",
|
"sidebar.tagsHeader": "Tags",
|
||||||
|
"sidebar.drafts": "Drafts",
|
||||||
|
"sidebar.published": "Published",
|
||||||
|
"sidebar.archived": "Archived",
|
||||||
"language.en": "English",
|
"language.en": "English",
|
||||||
"language.de": "German",
|
"language.de": "German",
|
||||||
"language.fr": "French",
|
"language.fr": "French",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"menu.group.file": "Archivo",
|
"menu.group.file": "Archivo",
|
||||||
"menu.group.edit": "Editar",
|
"menu.group.edit": "Editar",
|
||||||
"menu.group.view": "Ver",
|
"menu.group.view": "Ver",
|
||||||
"menu.group.blog": "Bitácora",
|
"menu.group.window": "Ventana",
|
||||||
"menu.group.help": "Ayuda",
|
"menu.group.help": "Ayuda",
|
||||||
"menu.item.newPost": "Nueva entrada",
|
"menu.item.newPost": "Nueva entrada",
|
||||||
"menu.item.importMedia": "Importar medios...",
|
"menu.item.importMedia": "Importar medios...",
|
||||||
@@ -88,6 +88,16 @@
|
|||||||
"tabBar.loading": "Cargando...",
|
"tabBar.loading": "Cargando...",
|
||||||
"tabBar.unknown": "Desconocido",
|
"tabBar.unknown": "Desconocido",
|
||||||
"tabBar.style": "Estilo",
|
"tabBar.style": "Estilo",
|
||||||
|
"tabBar.menuEditor": "Editor de menú",
|
||||||
|
"tabBar.metadataDiff": "Diff de metadatos",
|
||||||
|
"tabBar.siteValidation": "Validación del sitio",
|
||||||
|
"tabBar.translationValidation": "Validación de traducciones",
|
||||||
|
"tabBar.documentation": "Documentación",
|
||||||
|
"tabBar.apiDocumentation": "Documentación API",
|
||||||
|
"tabBar.findDuplicates": "Buscar duplicados",
|
||||||
|
"tabBar.tags": "Etiquetas",
|
||||||
|
"tabBar.preview": "Vista previa",
|
||||||
|
"tabBar.modified": "Modificado",
|
||||||
"sidebar.noPostsYet": "Aún no hay entradas",
|
"sidebar.noPostsYet": "Aún no hay entradas",
|
||||||
"sidebar.noPagesYet": "Aún no hay páginas",
|
"sidebar.noPagesYet": "Aún no hay páginas",
|
||||||
"sidebar.noMediaYet": "Aún no hay medios",
|
"sidebar.noMediaYet": "Aún no hay medios",
|
||||||
@@ -99,6 +109,9 @@
|
|||||||
"sidebar.loading": "Cargando...",
|
"sidebar.loading": "Cargando...",
|
||||||
"sidebar.settingsHeader": "Configuración",
|
"sidebar.settingsHeader": "Configuración",
|
||||||
"sidebar.tagsHeader": "Etiquetas",
|
"sidebar.tagsHeader": "Etiquetas",
|
||||||
|
"sidebar.drafts": "Borradores",
|
||||||
|
"sidebar.published": "Publicados",
|
||||||
|
"sidebar.archived": "Archivados",
|
||||||
"language.en": "Inglés",
|
"language.en": "Inglés",
|
||||||
"language.de": "Alemán",
|
"language.de": "Alemán",
|
||||||
"language.fr": "Francés",
|
"language.fr": "Francés",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"menu.group.file": "Fichier",
|
"menu.group.file": "Fichier",
|
||||||
"menu.group.edit": "Édition",
|
"menu.group.edit": "Édition",
|
||||||
"menu.group.view": "Affichage",
|
"menu.group.view": "Affichage",
|
||||||
"menu.group.blog": "Espace blog",
|
"menu.group.window": "Fenêtre",
|
||||||
"menu.group.help": "Aide",
|
"menu.group.help": "Aide",
|
||||||
"menu.item.newPost": "Nouvel article",
|
"menu.item.newPost": "Nouvel article",
|
||||||
"menu.item.importMedia": "Importer des médias...",
|
"menu.item.importMedia": "Importer des médias...",
|
||||||
@@ -88,6 +88,16 @@
|
|||||||
"tabBar.loading": "Chargement...",
|
"tabBar.loading": "Chargement...",
|
||||||
"tabBar.unknown": "Inconnu",
|
"tabBar.unknown": "Inconnu",
|
||||||
"tabBar.style": "Style",
|
"tabBar.style": "Style",
|
||||||
|
"tabBar.menuEditor": "Éditeur de menu",
|
||||||
|
"tabBar.metadataDiff": "Diff métadonnées",
|
||||||
|
"tabBar.siteValidation": "Validation du site",
|
||||||
|
"tabBar.translationValidation": "Validation des traductions",
|
||||||
|
"tabBar.documentation": "Documentation",
|
||||||
|
"tabBar.apiDocumentation": "Documentation API",
|
||||||
|
"tabBar.findDuplicates": "Trouver les doublons",
|
||||||
|
"tabBar.tags": "Tags",
|
||||||
|
"tabBar.preview": "Aperçu",
|
||||||
|
"tabBar.modified": "Modifié",
|
||||||
"sidebar.noPostsYet": "Aucun article pour le moment",
|
"sidebar.noPostsYet": "Aucun article pour le moment",
|
||||||
"sidebar.noPagesYet": "Aucune page pour le moment",
|
"sidebar.noPagesYet": "Aucune page pour le moment",
|
||||||
"sidebar.noMediaYet": "Aucun média pour le moment",
|
"sidebar.noMediaYet": "Aucun média pour le moment",
|
||||||
@@ -99,6 +109,9 @@
|
|||||||
"sidebar.loading": "Chargement...",
|
"sidebar.loading": "Chargement...",
|
||||||
"sidebar.settingsHeader": "Paramètres",
|
"sidebar.settingsHeader": "Paramètres",
|
||||||
"sidebar.tagsHeader": "Étiquettes",
|
"sidebar.tagsHeader": "Étiquettes",
|
||||||
|
"sidebar.drafts": "Brouillons",
|
||||||
|
"sidebar.published": "Publiés",
|
||||||
|
"sidebar.archived": "Archivés",
|
||||||
"language.en": "Anglais",
|
"language.en": "Anglais",
|
||||||
"language.de": "Allemand",
|
"language.de": "Allemand",
|
||||||
"language.fr": "Français",
|
"language.fr": "Français",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"menu.group.file": "Archivio",
|
"menu.group.file": "Archivio",
|
||||||
"menu.group.edit": "Modifica",
|
"menu.group.edit": "Modifica",
|
||||||
"menu.group.view": "Vista",
|
"menu.group.view": "Vista",
|
||||||
"menu.group.blog": "Sezione blog",
|
"menu.group.window": "Finestra",
|
||||||
"menu.group.help": "Aiuto",
|
"menu.group.help": "Aiuto",
|
||||||
"menu.item.newPost": "Nuovo post",
|
"menu.item.newPost": "Nuovo post",
|
||||||
"menu.item.importMedia": "Importa media...",
|
"menu.item.importMedia": "Importa media...",
|
||||||
@@ -88,6 +88,16 @@
|
|||||||
"tabBar.loading": "Caricamento...",
|
"tabBar.loading": "Caricamento...",
|
||||||
"tabBar.unknown": "Sconosciuto",
|
"tabBar.unknown": "Sconosciuto",
|
||||||
"tabBar.style": "Stile",
|
"tabBar.style": "Stile",
|
||||||
|
"tabBar.menuEditor": "Editor menu",
|
||||||
|
"tabBar.metadataDiff": "Diff metadati",
|
||||||
|
"tabBar.siteValidation": "Validazione sito",
|
||||||
|
"tabBar.translationValidation": "Validazione traduzioni",
|
||||||
|
"tabBar.documentation": "Documentazione",
|
||||||
|
"tabBar.apiDocumentation": "Documentazione API",
|
||||||
|
"tabBar.findDuplicates": "Trova duplicati",
|
||||||
|
"tabBar.tags": "Tag",
|
||||||
|
"tabBar.preview": "Anteprima",
|
||||||
|
"tabBar.modified": "Modificato",
|
||||||
"sidebar.noPostsYet": "Nessun post",
|
"sidebar.noPostsYet": "Nessun post",
|
||||||
"sidebar.noPagesYet": "Nessuna pagina",
|
"sidebar.noPagesYet": "Nessuna pagina",
|
||||||
"sidebar.noMediaYet": "Nessun media",
|
"sidebar.noMediaYet": "Nessun media",
|
||||||
@@ -99,6 +109,9 @@
|
|||||||
"sidebar.loading": "Caricamento...",
|
"sidebar.loading": "Caricamento...",
|
||||||
"sidebar.settingsHeader": "Impostazioni",
|
"sidebar.settingsHeader": "Impostazioni",
|
||||||
"sidebar.tagsHeader": "Tag",
|
"sidebar.tagsHeader": "Tag",
|
||||||
|
"sidebar.drafts": "Bozze",
|
||||||
|
"sidebar.published": "Pubblicati",
|
||||||
|
"sidebar.archived": "Archiviati",
|
||||||
"language.en": "Inglese",
|
"language.en": "Inglese",
|
||||||
"language.de": "Tedesco",
|
"language.de": "Tedesco",
|
||||||
"language.fr": "Francese",
|
"language.fr": "Francese",
|
||||||
|
|||||||
Reference in New Issue
Block a user