Implement the Pico CSS theme editor.
This commit is contained in:
@@ -6,7 +6,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
|||||||
|
|
||||||
## Available Features
|
## Available Features
|
||||||
|
|
||||||
- Native Iced desktop workspace with localized menus, tabs, automatically paged post/media sidebars, dialogs, tasks, and embedded Wry previews.
|
- Native Iced desktop workspace with localized menus, tabs, automatically paged post/media sidebars, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor.
|
||||||
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import.
|
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import.
|
||||||
- Media import, thumbnails, metadata translations, filters, validation, and post assignment.
|
- Media import, thumbnails, metadata translations, filters, validation, and post assignment.
|
||||||
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use std::path::Path;
|
|||||||
|
|
||||||
use crate::db::DbConnection as Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use crate::engine::EngineResult;
|
use crate::engine::EngineResult;
|
||||||
use crate::model::PublishingPreferences;
|
|
||||||
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
use crate::model::metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
||||||
|
use crate::model::{DomainEntity, NotificationAction, Project, PublishingPreferences};
|
||||||
use crate::util::atomic_write_str;
|
use crate::util::atomic_write_str;
|
||||||
|
|
||||||
// ── project.json ────────────────────────────────────────────────────
|
// ── project.json ────────────────────────────────────────────────────
|
||||||
@@ -26,6 +26,41 @@ pub fn write_project_json(data_dir: &Path, meta: &ProjectMetadata) -> EngineResu
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist the shared project row and its filesystem metadata, then publish
|
||||||
|
/// the normal project-updated domain event.
|
||||||
|
pub fn update_project_metadata(
|
||||||
|
conn: &Connection,
|
||||||
|
data_dir: &Path,
|
||||||
|
project: &Project,
|
||||||
|
metadata: &ProjectMetadata,
|
||||||
|
) -> EngineResult<ProjectMetadata> {
|
||||||
|
if project.id.is_empty() {
|
||||||
|
return Err(crate::engine::EngineError::Validation(
|
||||||
|
"project id is required".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
metadata
|
||||||
|
.validate()
|
||||||
|
.map_err(crate::engine::EngineError::Validation)?;
|
||||||
|
|
||||||
|
let mut persisted_project = project.clone();
|
||||||
|
persisted_project.name = metadata.name.clone();
|
||||||
|
persisted_project.description = metadata.description.clone();
|
||||||
|
persisted_project.updated_at = crate::util::now_unix_ms();
|
||||||
|
|
||||||
|
let category_metadata = read_category_meta_json(data_dir)?;
|
||||||
|
write_project_json(data_dir, metadata)?;
|
||||||
|
write_category_meta_json(data_dir, &category_metadata)?;
|
||||||
|
crate::db::queries::project::update_project(conn, &persisted_project)?;
|
||||||
|
crate::engine::domain_events::entity_changed(
|
||||||
|
&project.id,
|
||||||
|
DomainEntity::Project,
|
||||||
|
&project.id,
|
||||||
|
NotificationAction::Updated,
|
||||||
|
);
|
||||||
|
Ok(metadata.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Copy the project fields persisted in both representations from project.json
|
/// Copy the project fields persisted in both representations from project.json
|
||||||
/// into the machine-local project row.
|
/// into the machine-local project row.
|
||||||
pub fn sync_project_from_file(
|
pub fn sync_project_from_file(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use tokio::sync::oneshot;
|
|||||||
use crate::db::{Database, queries};
|
use crate::db::{Database, queries};
|
||||||
use crate::engine::generation::PublishedPostSource;
|
use crate::engine::generation::PublishedPostSource;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{Post, PostStatus, ProjectMetadata};
|
use crate::model::{Post, PostStatus, ProjectMetadata, pico_stylesheet_href};
|
||||||
use crate::render::build_preview_response;
|
use crate::render::build_preview_response;
|
||||||
use crate::util::frontmatter::{read_post_file, read_translation_file};
|
use crate::util::frontmatter::{read_post_file, read_translation_file};
|
||||||
|
|
||||||
@@ -148,12 +148,31 @@ async fn handle_draft_preview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_style_preview(Query(query): Query<StylePreviewQuery>) -> Response {
|
async fn handle_style_preview(
|
||||||
let theme = query.theme.unwrap_or_else(|| "default".to_string());
|
State(state): State<PreviewServerState>,
|
||||||
let mode = query.mode.unwrap_or_else(|| "auto".to_string());
|
Query(query): Query<StylePreviewQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let metadata = match crate::engine::meta::read_project_json(&state.data_dir) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) => {
|
||||||
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let theme = query.theme.as_deref().unwrap_or("default");
|
||||||
|
let stylesheet = pico_stylesheet_href(Some(theme));
|
||||||
|
let mode = match query.mode.as_deref().map(str::trim) {
|
||||||
|
Some("light") => Some("light"),
|
||||||
|
Some("dark") => Some("dark"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let mode_attributes = mode
|
||||||
|
.map(|mode| format!(" data-theme=\"{mode}\" data-mode=\"{mode}\""))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let project_name = escape_html(&metadata.name);
|
||||||
|
let language = escape_html(metadata.main_language.as_deref().unwrap_or("en"));
|
||||||
|
let description = escape_html(metadata.description.as_deref().unwrap_or(&metadata.name));
|
||||||
let html = format!(
|
let html = format!(
|
||||||
"<!doctype html><html lang=\"en\" data-theme=\"{}\" data-mode=\"{}\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>Style Preview</title><link rel=\"stylesheet\" href=\"/assets/pico.min.css\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main><section class=\"style-preview\"><h1>Style Preview</h1><p>Theme: {}</p><p>Mode: {}</p></section></main></body></html>",
|
"<!doctype html><html lang=\"{language}\"{mode_attributes}><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>{project_name}</title><link rel=\"stylesheet\" href=\"{stylesheet}\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main class=\"container\"><nav><ul><li><strong>{project_name}</strong></li></ul></nav><article><header><h1>{project_name}</h1></header><p>{description}</p><progress value=\"70\" max=\"100\"></progress><input type=\"text\" value=\"{project_name}\" aria-label=\"{project_name}\" /><button type=\"button\">{project_name}</button></article></main></body></html>"
|
||||||
theme, mode, theme, mode,
|
|
||||||
);
|
);
|
||||||
Html(html).into_response()
|
Html(html).into_response()
|
||||||
}
|
}
|
||||||
@@ -200,48 +219,82 @@ fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> Strin
|
|||||||
let Some(style) = style else {
|
let Some(style) = style else {
|
||||||
return html;
|
return html;
|
||||||
};
|
};
|
||||||
if style.theme.as_deref().unwrap_or("").is_empty()
|
|
||||||
&& style.mode.as_deref().unwrap_or("").is_empty()
|
|
||||||
{
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(start) = html.find("<html") else {
|
let mut styled = html;
|
||||||
return html;
|
if let Some(theme) = style.theme.as_deref().filter(|theme| !theme.is_empty()) {
|
||||||
};
|
styled = override_pico_stylesheet(&styled, &pico_stylesheet_href(Some(theme)));
|
||||||
let Some(end_rel) = html[start..].find('>') else {
|
|
||||||
return html;
|
|
||||||
};
|
|
||||||
let end = start + end_rel;
|
|
||||||
|
|
||||||
let mut attrs = String::new();
|
|
||||||
if let Some(theme) = style.theme.as_deref().filter(|value| !value.is_empty()) {
|
|
||||||
attrs.push_str(" data-theme=\"");
|
|
||||||
attrs.push_str(&escape_html_attr(theme));
|
|
||||||
attrs.push('"');
|
|
||||||
}
|
}
|
||||||
if let Some(mode) = style.mode.as_deref().filter(|value| !value.is_empty()) {
|
if let Some(mode) = style.mode.as_deref() {
|
||||||
attrs.push_str(" data-mode=\"");
|
let mode = match mode.trim() {
|
||||||
attrs.push_str(&escape_html_attr(mode));
|
"light" => Some("light"),
|
||||||
attrs.push('"');
|
"dark" => Some("dark"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
styled = override_html_attribute(&styled, "data-theme", mode);
|
||||||
|
styled = override_html_attribute(&styled, "data-mode", mode);
|
||||||
}
|
}
|
||||||
if attrs.is_empty() {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut styled = String::with_capacity(html.len() + attrs.len());
|
|
||||||
styled.push_str(&html[..end]);
|
|
||||||
styled.push_str(&attrs);
|
|
||||||
styled.push_str(&html[end..]);
|
|
||||||
styled
|
styled
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_html_attr(value: &str) -> String {
|
fn override_pico_stylesheet(html: &str, href: &str) -> String {
|
||||||
|
let Some(start) = html.find("/assets/pico") else {
|
||||||
|
return html.to_string();
|
||||||
|
};
|
||||||
|
let Some(end_offset) = html[start..].find(".min.css") else {
|
||||||
|
return html.to_string();
|
||||||
|
};
|
||||||
|
let end = start + end_offset + ".min.css".len();
|
||||||
|
format!("{}{}{}", &html[..start], href, &html[end..])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn override_html_attribute(html: &str, attribute: &str, value: Option<&str>) -> String {
|
||||||
|
let Some(html_start) = html.find("<html") else {
|
||||||
|
return html.to_string();
|
||||||
|
};
|
||||||
|
let Some(tag_end_offset) = html[html_start..].find('>') else {
|
||||||
|
return html.to_string();
|
||||||
|
};
|
||||||
|
let tag_end = html_start + tag_end_offset;
|
||||||
|
let attribute_start_pattern = format!(" {attribute}=\"");
|
||||||
|
let existing = html[html_start..tag_end]
|
||||||
|
.find(&attribute_start_pattern)
|
||||||
|
.and_then(|offset| {
|
||||||
|
let start = html_start + offset;
|
||||||
|
html[start + attribute_start_pattern.len()..tag_end]
|
||||||
|
.find('"')
|
||||||
|
.map(|end_offset| {
|
||||||
|
(
|
||||||
|
start,
|
||||||
|
start + attribute_start_pattern.len() + end_offset + 1,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
match (existing, value) {
|
||||||
|
(Some((start, end)), Some(value)) => format!(
|
||||||
|
"{} {attribute}=\"{}\"{}",
|
||||||
|
&html[..start],
|
||||||
|
value,
|
||||||
|
&html[end..]
|
||||||
|
),
|
||||||
|
(Some((start, end)), None) => format!("{}{}", &html[..start], &html[end..]),
|
||||||
|
(None, Some(value)) => format!(
|
||||||
|
"{} {attribute}=\"{}\"{}",
|
||||||
|
&html[..tag_end],
|
||||||
|
value,
|
||||||
|
&html[tag_end..]
|
||||||
|
),
|
||||||
|
(None, None) => html.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(value: &str) -> String {
|
||||||
value
|
value
|
||||||
.replace('&', "&")
|
.replace('&', "&")
|
||||||
.replace('"', """)
|
|
||||||
.replace('<', "<")
|
.replace('<', "<")
|
||||||
.replace('>', ">")
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_draft_preview(
|
fn render_draft_preview(
|
||||||
@@ -384,6 +437,13 @@ fn serve_scoped_file(
|
|||||||
let scope_root = data_dir.join(scope_dir);
|
let scope_root = data_dir.join(scope_dir);
|
||||||
let candidate = scope_root.join(relative);
|
let candidate = scope_root.join(relative);
|
||||||
if !candidate.exists() || !candidate.is_file() {
|
if !candidate.exists() || !candidate.is_file() {
|
||||||
|
let bundled_path = format!("{scope_dir}/{relative}");
|
||||||
|
if let Some(bytes) = crate::engine::site_assets::bundled_site_asset(&bundled_path) {
|
||||||
|
let mime = guess_content_type(Path::new(&bundled_path));
|
||||||
|
return Ok(Some(
|
||||||
|
(StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(),
|
||||||
|
));
|
||||||
|
}
|
||||||
return Ok(Some(error_response(
|
return Ok(Some(error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"preview asset not found",
|
"preview asset not found",
|
||||||
@@ -715,16 +775,28 @@ mod tests {
|
|||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
.get(format!(
|
.get(format!(
|
||||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=nightfall&mode=dark"
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=amber&mode=dark"
|
||||||
))
|
))
|
||||||
.send()
|
.send()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let body = response.text().unwrap();
|
let body = response.text().unwrap();
|
||||||
|
let stylesheet = client
|
||||||
|
.get(format!(
|
||||||
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/pico.amber.min.css"
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.unwrap();
|
||||||
|
assert!(stylesheet.status().is_success());
|
||||||
|
let stylesheet = stylesheet.text().unwrap();
|
||||||
server.stop().unwrap();
|
server.stop().unwrap();
|
||||||
|
|
||||||
assert!(body.contains("Style Preview"));
|
assert!(body.contains("<article>"));
|
||||||
assert!(body.contains("nightfall"));
|
assert!(body.contains("Blog"));
|
||||||
assert!(body.contains("dark"));
|
assert!(body.contains("href=\"/assets/pico.amber.min.css\""));
|
||||||
|
assert!(body.contains("data-theme=\"dark\""));
|
||||||
|
assert!(body.contains("data-mode=\"dark\""));
|
||||||
|
assert!(!body.contains("data-theme=\"amber\""));
|
||||||
|
assert!(stylesheet.contains("--pico-primary-background:#ffbf00"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -742,15 +814,34 @@ mod tests {
|
|||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
.get(format!(
|
.get(format!(
|
||||||
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=nightfall&mode=dark"
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=amber&mode=light"
|
||||||
))
|
))
|
||||||
.send()
|
.send()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let body = response.text().unwrap();
|
let body = response.text().unwrap();
|
||||||
server.stop().unwrap();
|
server.stop().unwrap();
|
||||||
|
|
||||||
assert!(body.contains("data-theme=\"nightfall\""));
|
assert!(body.contains("href=\"/assets/pico.amber.min.css\""));
|
||||||
assert!(body.contains("data-mode=\"dark\""));
|
assert!(body.contains("data-theme=\"light\""));
|
||||||
|
assert!(body.contains("data-mode=\"light\""));
|
||||||
|
assert!(!body.contains("data-theme=\"amber\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_preview_mode_removes_forced_color_scheme_and_invalid_theme_falls_back() {
|
||||||
|
let html = "<html data-theme=\"dark\" data-mode=\"dark\"><head><link rel=\"stylesheet\" href=\"/assets/pico.blue.min.css\"></head></html>".to_string();
|
||||||
|
let styled = apply_preview_style(
|
||||||
|
html,
|
||||||
|
Some(&StylePreviewQuery {
|
||||||
|
theme: Some("../../secret".into()),
|
||||||
|
mode: Some("auto".into()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(styled.contains("href=\"/assets/pico.min.css\""));
|
||||||
|
assert!(!styled.contains("data-theme="));
|
||||||
|
assert!(!styled.contains("data-mode="));
|
||||||
|
assert!(!styled.contains("secret"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -231,6 +231,13 @@ const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
pub(crate) fn bundled_site_asset(relative_path: &str) -> Option<&'static [u8]> {
|
||||||
|
BUNDLED_SITE_ASSETS
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.relative_path == relative_path)
|
||||||
|
.map(|asset| asset.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
||||||
for asset in BUNDLED_SITE_ASSETS {
|
for asset in BUNDLED_SITE_ASSETS {
|
||||||
let target = project_dir.join(asset.relative_path);
|
let target = project_dir.join(asset.relative_path);
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const SUPPORTED_PICO_THEMES: [&str; 20] = [
|
||||||
|
"default", "amber", "blue", "cyan", "fuchsia", "green", "grey", "indigo", "jade", "lime",
|
||||||
|
"orange", "pink", "pumpkin", "purple", "red", "sand", "slate", "violet", "yellow", "zinc",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn is_supported_pico_theme(theme: &str) -> bool {
|
||||||
|
SUPPORTED_PICO_THEMES.contains(&theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pico_stylesheet_href(theme: Option<&str>) -> String {
|
||||||
|
match theme.filter(|theme| is_supported_pico_theme(theme)) {
|
||||||
|
Some("default") | None => "/assets/pico.min.css".to_string(),
|
||||||
|
Some(theme) => format!("/assets/pico.{theme}.min.css"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn default_max_posts() -> i32 {
|
fn default_max_posts() -> i32 {
|
||||||
50
|
50
|
||||||
}
|
}
|
||||||
@@ -69,6 +85,11 @@ impl ProjectMetadata {
|
|||||||
self.image_import_concurrency
|
self.image_import_concurrency
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some(theme) = self.pico_theme.as_deref()
|
||||||
|
&& !is_supported_pico_theme(theme)
|
||||||
|
{
|
||||||
|
return Err(format!("unsupported picoTheme: {theme}"));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,4 +230,14 @@ mod tests {
|
|||||||
meta.max_posts_per_page = 500;
|
meta.max_posts_per_page = 500;
|
||||||
assert!(meta.validate().is_ok());
|
assert!(meta.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pico_theme_validation_accepts_only_bundled_themes() {
|
||||||
|
let mut meta: ProjectMetadata = serde_json::from_str(r#"{"name":"Test"}"#).unwrap();
|
||||||
|
meta.pico_theme = Some("amber".into());
|
||||||
|
assert!(meta.validate().is_ok());
|
||||||
|
|
||||||
|
meta.pico_theme = Some("nightfall".into());
|
||||||
|
assert!(meta.validate().is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ pub use import::{
|
|||||||
};
|
};
|
||||||
pub use mcp::{McpProposal, ProposalKind, ProposalStatus};
|
pub use mcp::{McpProposal, ProposalKind, ProposalStatus};
|
||||||
pub use media::{Media, MediaTranslation};
|
pub use media::{Media, MediaTranslation};
|
||||||
pub use metadata::{CategorySettings, ProjectMetadata, TagEntry};
|
pub use metadata::{
|
||||||
|
CategorySettings, ProjectMetadata, SUPPORTED_PICO_THEMES, TagEntry, is_supported_pico_theme,
|
||||||
|
pico_stylesheet_href,
|
||||||
|
};
|
||||||
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
|
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
|
||||||
pub use project::{Project, Setting};
|
pub use project::{Project, Setting};
|
||||||
pub use script::{Script, ScriptKind, ScriptStatus};
|
pub use script::{Script, ScriptKind, ScriptStatus};
|
||||||
|
|||||||
@@ -151,10 +151,9 @@ pub fn render_starter_single_post_page_with_media_map(
|
|||||||
language,
|
language,
|
||||||
language_prefix: language_prefix(language, main_language(metadata)),
|
language_prefix: language_prefix(language, main_language(metadata)),
|
||||||
page_title: &post.title,
|
page_title: &post.title,
|
||||||
pico_stylesheet_href: metadata
|
pico_stylesheet_href: Some(crate::model::pico_stylesheet_href(
|
||||||
.pico_theme
|
metadata.pico_theme.as_deref(),
|
||||||
.as_ref()
|
)),
|
||||||
.map(|_| "/assets/pico.min.css".to_string()),
|
|
||||||
html_theme_attribute: None,
|
html_theme_attribute: None,
|
||||||
alternate_links: build_alternate_links(post, metadata, language),
|
alternate_links: build_alternate_links(post, metadata, language),
|
||||||
blog_languages: build_blog_languages(post, metadata, language),
|
blog_languages: build_blog_languages(post, metadata, language),
|
||||||
@@ -226,10 +225,9 @@ pub fn render_starter_list_page_with_media_map(
|
|||||||
language: language.to_string(),
|
language: language.to_string(),
|
||||||
language_prefix: language_prefix(language, main_language(metadata)),
|
language_prefix: language_prefix(language, main_language(metadata)),
|
||||||
page_title: metadata.name.clone(),
|
page_title: metadata.name.clone(),
|
||||||
pico_stylesheet_href: metadata
|
pico_stylesheet_href: Some(crate::model::pico_stylesheet_href(
|
||||||
.pico_theme
|
metadata.pico_theme.as_deref(),
|
||||||
.as_ref()
|
)),
|
||||||
.map(|_| "/assets/pico.min.css".to_string()),
|
|
||||||
html_theme_attribute: None,
|
html_theme_attribute: None,
|
||||||
alternate_links: vec![],
|
alternate_links: vec![],
|
||||||
blog_languages: build_blog_languages_for_index(metadata, language),
|
blog_languages: build_blog_languages_for_index(metadata, language),
|
||||||
@@ -539,6 +537,21 @@ mod tests {
|
|||||||
assert!(rendered.html.contains("href=\"/2024/03/09/hello\""));
|
assert!(rendered.html.contains("href=\"/2024/03/09/hello\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starter_renderer_uses_the_selected_pico_theme_stylesheet() {
|
||||||
|
let post = make_post();
|
||||||
|
let mut metadata = make_metadata();
|
||||||
|
metadata.pico_theme = Some("amber".into());
|
||||||
|
|
||||||
|
let rendered = render_starter_single_post_page(&post, "Body", &metadata, "en").unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
rendered
|
||||||
|
.html
|
||||||
|
.contains("href=\"/assets/pico.amber.min.css\"")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn starter_single_post_renderer_rewrites_bds_media_image_links() {
|
fn starter_single_post_renderer_rewrites_bds_media_image_links() {
|
||||||
let post = make_post();
|
let post = make_post();
|
||||||
|
|||||||
@@ -1598,10 +1598,9 @@ fn starter_partials() -> HashMap<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
|
fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
|
||||||
metadata
|
Some(crate::model::pico_stylesheet_href(
|
||||||
.pico_theme
|
metadata.pico_theme.as_deref(),
|
||||||
.as_ref()
|
))
|
||||||
.map(|_| "/assets/pico.min.css".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
|
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ use crate::views::{
|
|||||||
default_category_rows,
|
default_category_rows,
|
||||||
},
|
},
|
||||||
site_validation::SiteValidationState,
|
site_validation::SiteValidationState,
|
||||||
|
style_view::{StyleMsg, StyleViewState},
|
||||||
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
|
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
|
||||||
template_editor::{TemplateEditorMsg, TemplateEditorState},
|
template_editor::{TemplateEditorMsg, TemplateEditorState},
|
||||||
workspace,
|
workspace,
|
||||||
@@ -164,6 +165,7 @@ pub enum Message {
|
|||||||
},
|
},
|
||||||
MainWindowLoaded(Option<window::Id>),
|
MainWindowLoaded(Option<window::Id>),
|
||||||
EmbeddedPreviewReady(Result<(), String>),
|
EmbeddedPreviewReady(Result<(), String>),
|
||||||
|
EmbeddedStylePreviewReady(Result<(), String>),
|
||||||
|
|
||||||
// Panel
|
// Panel
|
||||||
SetPanelTab(PanelTab),
|
SetPanelTab(PanelTab),
|
||||||
@@ -327,6 +329,7 @@ pub enum Message {
|
|||||||
ScriptEditor(ScriptEditorMsg),
|
ScriptEditor(ScriptEditorMsg),
|
||||||
Tags(TagsMsg),
|
Tags(TagsMsg),
|
||||||
Settings(SettingsMsg),
|
Settings(SettingsMsg),
|
||||||
|
Style(StyleMsg),
|
||||||
ImportEditor(ImportEditorMsg),
|
ImportEditor(ImportEditorMsg),
|
||||||
MenuEditor(MenuEditorMsg),
|
MenuEditor(MenuEditorMsg),
|
||||||
|
|
||||||
@@ -954,6 +957,7 @@ pub struct BdsApp {
|
|||||||
preview_session: Option<PreviewSession>,
|
preview_session: Option<PreviewSession>,
|
||||||
mcp_server: Option<engine::mcp::McpHttpServer>,
|
mcp_server: Option<engine::mcp::McpHttpServer>,
|
||||||
embedded_preview: Option<EmbeddedPreviewState>,
|
embedded_preview: Option<EmbeddedPreviewState>,
|
||||||
|
embedded_style_preview: Option<EmbeddedPreviewState>,
|
||||||
main_window_id: Option<window::Id>,
|
main_window_id: Option<window::Id>,
|
||||||
|
|
||||||
// Editor states (keyed by entity id)
|
// Editor states (keyed by entity id)
|
||||||
@@ -965,6 +969,7 @@ pub struct BdsApp {
|
|||||||
chat_editors: HashMap<String, ChatEditorState>,
|
chat_editors: HashMap<String, ChatEditorState>,
|
||||||
tags_view_state: Option<TagsViewState>,
|
tags_view_state: Option<TagsViewState>,
|
||||||
settings_state: Option<SettingsViewState>,
|
settings_state: Option<SettingsViewState>,
|
||||||
|
style_view_state: Option<StyleViewState>,
|
||||||
dashboard_state: Option<DashboardState>,
|
dashboard_state: Option<DashboardState>,
|
||||||
site_validation_state: SiteValidationState,
|
site_validation_state: SiteValidationState,
|
||||||
duplicates_state: DuplicatesState,
|
duplicates_state: DuplicatesState,
|
||||||
@@ -1135,6 +1140,7 @@ impl BdsApp {
|
|||||||
preview_session: None,
|
preview_session: None,
|
||||||
mcp_server,
|
mcp_server,
|
||||||
embedded_preview: None,
|
embedded_preview: None,
|
||||||
|
embedded_style_preview: None,
|
||||||
main_window_id: None,
|
main_window_id: None,
|
||||||
post_editors: HashMap::new(),
|
post_editors: HashMap::new(),
|
||||||
media_editors: HashMap::new(),
|
media_editors: HashMap::new(),
|
||||||
@@ -1144,6 +1150,7 @@ impl BdsApp {
|
|||||||
chat_editors: HashMap::new(),
|
chat_editors: HashMap::new(),
|
||||||
tags_view_state: None,
|
tags_view_state: None,
|
||||||
settings_state: None,
|
settings_state: None,
|
||||||
|
style_view_state: None,
|
||||||
dashboard_state: None,
|
dashboard_state: None,
|
||||||
site_validation_state: SiteValidationState::default(),
|
site_validation_state: SiteValidationState::default(),
|
||||||
duplicates_state: DuplicatesState::default(),
|
duplicates_state: DuplicatesState::default(),
|
||||||
@@ -1227,6 +1234,7 @@ impl BdsApp {
|
|||||||
preview_session: None,
|
preview_session: None,
|
||||||
mcp_server: None,
|
mcp_server: None,
|
||||||
embedded_preview: None,
|
embedded_preview: None,
|
||||||
|
embedded_style_preview: None,
|
||||||
main_window_id: None,
|
main_window_id: None,
|
||||||
post_editors: HashMap::new(),
|
post_editors: HashMap::new(),
|
||||||
media_editors: HashMap::new(),
|
media_editors: HashMap::new(),
|
||||||
@@ -1236,6 +1244,7 @@ impl BdsApp {
|
|||||||
chat_editors: HashMap::new(),
|
chat_editors: HashMap::new(),
|
||||||
tags_view_state: None,
|
tags_view_state: None,
|
||||||
settings_state: None,
|
settings_state: None,
|
||||||
|
style_view_state: None,
|
||||||
dashboard_state: None,
|
dashboard_state: None,
|
||||||
site_validation_state: SiteValidationState::default(),
|
site_validation_state: SiteValidationState::default(),
|
||||||
duplicates_state: DuplicatesState::default(),
|
duplicates_state: DuplicatesState::default(),
|
||||||
@@ -1582,7 +1591,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
self.enforce_panel_tab_fallback();
|
self.enforce_panel_tab_fallback();
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
let mut tasks = vec![self.sync_embedded_preview_for_active_post()];
|
let mut tasks = vec![self.sync_embedded_previews()];
|
||||||
if let Some(post_id) = semantic_post_id {
|
if let Some(post_id) = semantic_post_id {
|
||||||
tasks.push(Task::done(Message::LoadSemanticTagSuggestions(post_id)));
|
tasks.push(Task::done(Message::LoadSemanticTagSuggestions(post_id)));
|
||||||
}
|
}
|
||||||
@@ -1600,7 +1609,7 @@ impl BdsApp {
|
|||||||
self.git_diffs.remove(&id);
|
self.git_diffs.remove(&id);
|
||||||
self.enforce_panel_tab_fallback();
|
self.enforce_panel_tab_fallback();
|
||||||
self.sync_menu_state();
|
self.sync_menu_state();
|
||||||
self.sync_embedded_preview_for_active_post()
|
self.sync_embedded_previews()
|
||||||
}
|
}
|
||||||
Message::SelectTab(id) => {
|
Message::SelectTab(id) => {
|
||||||
if self.tabs.iter().any(|t| t.id == id) {
|
if self.tabs.iter().any(|t| t.id == id) {
|
||||||
@@ -1610,7 +1619,7 @@ impl BdsApp {
|
|||||||
self.active_tab = Some(id);
|
self.active_tab = Some(id);
|
||||||
}
|
}
|
||||||
self.enforce_panel_tab_fallback();
|
self.enforce_panel_tab_fallback();
|
||||||
self.sync_embedded_preview_for_active_post()
|
self.sync_embedded_previews()
|
||||||
}
|
}
|
||||||
Message::PinTab(id) => {
|
Message::PinTab(id) => {
|
||||||
tabs::pin_tab(&mut self.tabs, &id);
|
tabs::pin_tab(&mut self.tabs, &id);
|
||||||
@@ -1622,6 +1631,7 @@ impl BdsApp {
|
|||||||
self.active_tab = None;
|
self.active_tab = None;
|
||||||
self.git_diffs.clear();
|
self.git_diffs.clear();
|
||||||
self.hide_embedded_preview();
|
self.hide_embedded_preview();
|
||||||
|
self.hide_embedded_style_preview();
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1692,6 +1702,8 @@ impl BdsApp {
|
|||||||
self.preview_session = None;
|
self.preview_session = None;
|
||||||
self.duplicates_state = DuplicatesState::default();
|
self.duplicates_state = DuplicatesState::default();
|
||||||
self.hide_embedded_preview();
|
self.hide_embedded_preview();
|
||||||
|
self.hide_embedded_style_preview();
|
||||||
|
self.style_view_state = None;
|
||||||
self.data_dir = self
|
self.data_dir = self
|
||||||
.active_project
|
.active_project
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1710,6 +1722,9 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if self.tabs.iter().any(|tab| tab.tab_type == TabType::Style) {
|
||||||
|
self.style_view_state = self.hydrate_style_state();
|
||||||
|
}
|
||||||
let name = self
|
let name = self
|
||||||
.active_project
|
.active_project
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1737,6 +1752,7 @@ impl BdsApp {
|
|||||||
self.refresh_counts(),
|
self.refresh_counts(),
|
||||||
self.refresh_git_if_visible(),
|
self.refresh_git_if_visible(),
|
||||||
Task::done(Message::EmbeddingBackfill),
|
Task::done(Message::EmbeddingBackfill),
|
||||||
|
self.sync_embedded_previews(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
Message::ProjectSwitched(result) => {
|
Message::ProjectSwitched(result) => {
|
||||||
@@ -1862,6 +1878,8 @@ impl BdsApp {
|
|||||||
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
||||||
self.preview_session = None;
|
self.preview_session = None;
|
||||||
self.hide_embedded_preview();
|
self.hide_embedded_preview();
|
||||||
|
self.hide_embedded_style_preview();
|
||||||
|
self.style_view_state = None;
|
||||||
self.notify(
|
self.notify(
|
||||||
ToastLevel::Success,
|
ToastLevel::Success,
|
||||||
&tw(
|
&tw(
|
||||||
@@ -2374,9 +2392,9 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
message @ (Message::MainWindowLoaded(_) | Message::EmbeddedPreviewReady(_)) => {
|
message @ (Message::MainWindowLoaded(_)
|
||||||
self.handle_preview_message(message)
|
| Message::EmbeddedPreviewReady(_)
|
||||||
}
|
| Message::EmbeddedStylePreviewReady(_)) => self.handle_preview_message(message),
|
||||||
|
|
||||||
// ── Tasks ──
|
// ── Tasks ──
|
||||||
Message::TaskTick => {
|
Message::TaskTick => {
|
||||||
@@ -2481,6 +2499,8 @@ impl BdsApp {
|
|||||||
self.data_dir = Some(data_dir.clone());
|
self.data_dir = Some(data_dir.clone());
|
||||||
self.preview_session = None;
|
self.preview_session = None;
|
||||||
self.hide_embedded_preview();
|
self.hide_embedded_preview();
|
||||||
|
self.hide_embedded_style_preview();
|
||||||
|
self.style_view_state = None;
|
||||||
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
||||||
let main_language = meta.main_language.unwrap_or_else(|| "en".into());
|
let main_language = meta.main_language.unwrap_or_else(|| "en".into());
|
||||||
self.content_language = main_language.clone();
|
self.content_language = main_language.clone();
|
||||||
@@ -3031,6 +3051,7 @@ impl BdsApp {
|
|||||||
Message::ScriptEditor(msg) => self.handle_script_editor_msg(msg),
|
Message::ScriptEditor(msg) => self.handle_script_editor_msg(msg),
|
||||||
Message::Tags(msg) => self.handle_tags_msg(msg),
|
Message::Tags(msg) => self.handle_tags_msg(msg),
|
||||||
Message::Settings(msg) => self.handle_settings_msg(msg),
|
Message::Settings(msg) => self.handle_settings_msg(msg),
|
||||||
|
Message::Style(msg) => self.handle_style_msg(msg),
|
||||||
Message::ImportEditor(msg) => self.handle_import_editor_msg(msg),
|
Message::ImportEditor(msg) => self.handle_import_editor_msg(msg),
|
||||||
Message::MenuEditor(msg) => self.handle_menu_editor_msg(msg),
|
Message::MenuEditor(msg) => self.handle_menu_editor_msg(msg),
|
||||||
|
|
||||||
@@ -3380,6 +3401,13 @@ impl BdsApp {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let style_preview_widget = if self.active_style_uses_embedded_preview() {
|
||||||
|
self.embedded_style_preview
|
||||||
|
.as_ref()
|
||||||
|
.map(|preview| webview::webview(&preview.controller).into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
native_edit::native_edit(
|
native_edit::native_edit(
|
||||||
workspace::view(
|
workspace::view(
|
||||||
@@ -3418,6 +3446,7 @@ impl BdsApp {
|
|||||||
self.active_modal.clone(),
|
self.active_modal.clone(),
|
||||||
self.data_dir.as_deref(),
|
self.data_dir.as_deref(),
|
||||||
post_preview_widget,
|
post_preview_widget,
|
||||||
|
style_preview_widget,
|
||||||
&self.post_editors,
|
&self.post_editors,
|
||||||
&self.media_editors,
|
&self.media_editors,
|
||||||
&self.template_editors,
|
&self.template_editors,
|
||||||
@@ -3426,6 +3455,7 @@ impl BdsApp {
|
|||||||
&self.chat_editors,
|
&self.chat_editors,
|
||||||
self.tags_view_state.as_ref(),
|
self.tags_view_state.as_ref(),
|
||||||
self.settings_state.as_ref(),
|
self.settings_state.as_ref(),
|
||||||
|
self.style_view_state.as_ref(),
|
||||||
self.dashboard_state.as_ref(),
|
self.dashboard_state.as_ref(),
|
||||||
&self.site_validation_state,
|
&self.site_validation_state,
|
||||||
&self.duplicates_state,
|
&self.duplicates_state,
|
||||||
@@ -3981,6 +4011,15 @@ impl BdsApp {
|
|||||||
.and_then(|project| project.data_path.as_deref())
|
.and_then(|project| project.data_path.as_deref())
|
||||||
.map(PathBuf::from);
|
.map(PathBuf::from);
|
||||||
}
|
}
|
||||||
|
if let Some(data_dir) = self.data_dir.as_deref()
|
||||||
|
&& let Ok(metadata) = engine::meta::read_project_json(data_dir)
|
||||||
|
{
|
||||||
|
let applied = StyleViewState::new(metadata.pico_theme.as_deref());
|
||||||
|
self.theme_badge = applied.applied_theme.clone();
|
||||||
|
if let Some(state) = self.style_view_state.as_mut() {
|
||||||
|
state.refresh_applied_theme(metadata.pico_theme.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
previous_active.as_deref() == Some(project_id.as_str())
|
previous_active.as_deref() == Some(project_id.as_str())
|
||||||
|| self
|
|| self
|
||||||
.active_project
|
.active_project
|
||||||
@@ -5323,9 +5362,8 @@ impl BdsApp {
|
|||||||
// Read pico theme from project metadata for status bar badge
|
// Read pico theme from project metadata for status bar badge
|
||||||
if let Some(ref data_dir) = self.data_dir
|
if let Some(ref data_dir) = self.data_dir
|
||||||
&& let Ok(meta) = engine::meta::read_project_json(data_dir)
|
&& let Ok(meta) = engine::meta::read_project_json(data_dir)
|
||||||
&& let Some(theme) = meta.pico_theme
|
|
||||||
{
|
{
|
||||||
self.theme_badge = theme;
|
self.theme_badge = StyleViewState::new(meta.pico_theme.as_deref()).applied_theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
||||||
@@ -7104,6 +7142,64 @@ impl BdsApp {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hydrate_style_state(&self) -> Option<StyleViewState> {
|
||||||
|
self.active_project.as_ref()?;
|
||||||
|
let data_dir = self.data_dir.as_deref()?;
|
||||||
|
let metadata = engine::meta::read_project_json(data_dir).ok()?;
|
||||||
|
Some(StyleViewState::new(metadata.pico_theme.as_deref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_style_msg(&mut self, message: StyleMsg) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
StyleMsg::SelectTheme(theme) => {
|
||||||
|
if let Some(state) = self.style_view_state.as_mut() {
|
||||||
|
state.select_theme(&theme);
|
||||||
|
}
|
||||||
|
self.sync_embedded_preview_for_style()
|
||||||
|
}
|
||||||
|
StyleMsg::PreviewModeChanged(mode) => {
|
||||||
|
if let Some(state) = self.style_view_state.as_mut() {
|
||||||
|
state.set_preview_mode(mode);
|
||||||
|
}
|
||||||
|
self.sync_embedded_preview_for_style()
|
||||||
|
}
|
||||||
|
StyleMsg::Apply => {
|
||||||
|
let Some(theme) = self
|
||||||
|
.style_view_state
|
||||||
|
.as_ref()
|
||||||
|
.filter(|state| state.can_apply())
|
||||||
|
.map(|state| state.selected_theme.clone())
|
||||||
|
else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let result = (|| {
|
||||||
|
let db = self.db.as_ref().ok_or("database unavailable")?;
|
||||||
|
let data_dir = self
|
||||||
|
.data_dir
|
||||||
|
.as_deref()
|
||||||
|
.ok_or("project data directory unavailable")?;
|
||||||
|
let project = self.active_project.as_ref().ok_or("project unavailable")?;
|
||||||
|
let mut metadata = engine::meta::read_project_json(data_dir)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
metadata.pico_theme = Some(theme.clone());
|
||||||
|
engine::meta::update_project_metadata(db.conn(), data_dir, project, &metadata)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
if let Some(state) = self.style_view_state.as_mut() {
|
||||||
|
state.mark_applied();
|
||||||
|
}
|
||||||
|
self.theme_badge = theme;
|
||||||
|
self.notify(ToastLevel::Success, &t(self.ui_locale, "style.applied"));
|
||||||
|
}
|
||||||
|
Err(error) => self.notify_operation_failed("style.apply", error),
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_settings_msg(&mut self, msg: SettingsMsg) -> Task<Message> {
|
fn handle_settings_msg(&mut self, msg: SettingsMsg) -> Task<Message> {
|
||||||
// Ensure settings state exists
|
// Ensure settings state exists
|
||||||
if self.settings_state.is_none() {
|
if self.settings_state.is_none() {
|
||||||
@@ -7275,11 +7371,9 @@ impl BdsApp {
|
|||||||
Some(state.data_path.clone())
|
Some(state.data_path.clone())
|
||||||
};
|
};
|
||||||
project.updated_at = bds_core::util::now_unix_ms();
|
project.updated_at = bds_core::util::now_unix_ms();
|
||||||
let db_result =
|
match engine::meta::update_project_metadata(db.conn(), data_dir, project, &meta)
|
||||||
bds_core::db::queries::project::update_project(db.conn(), project);
|
{
|
||||||
let file_result = engine::meta::write_project_json(data_dir, &meta);
|
Ok(_) => {
|
||||||
match (db_result, file_result) {
|
|
||||||
(Ok(()), Ok(())) => {
|
|
||||||
let semantic_should_backfill =
|
let semantic_should_backfill =
|
||||||
state.semantic_similarity_enabled && !semantic_was_enabled;
|
state.semantic_similarity_enabled && !semantic_was_enabled;
|
||||||
if let Some(listing) =
|
if let Some(listing) =
|
||||||
@@ -7295,8 +7389,7 @@ impl BdsApp {
|
|||||||
return Task::done(Message::EmbeddingBackfill);
|
return Task::done(Message::EmbeddingBackfill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(Err(e), _) => self.notify_operation_failed("common.save", e),
|
Err(e) => self.notify_operation_failed("common.save", e),
|
||||||
(_, Err(e)) => self.notify_operation_failed("common.save", e),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8166,6 +8259,9 @@ impl BdsApp {
|
|||||||
TabType::Settings if self.settings_state.is_none() => {
|
TabType::Settings if self.settings_state.is_none() => {
|
||||||
self.settings_state = Some(self.hydrate_settings_state());
|
self.settings_state = Some(self.hydrate_settings_state());
|
||||||
}
|
}
|
||||||
|
TabType::Style if self.style_view_state.is_none() => {
|
||||||
|
self.style_view_state = self.hydrate_style_state();
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8379,6 +8475,72 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_style_uses_embedded_preview(&self) -> bool {
|
||||||
|
let active_is_style = self.active_tab.as_ref().is_some_and(|tab_id| {
|
||||||
|
self.tabs
|
||||||
|
.iter()
|
||||||
|
.any(|tab| tab.id == *tab_id && tab.tab_type == TabType::Style)
|
||||||
|
});
|
||||||
|
active_is_style && self.active_project.is_some() && self.style_view_state.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_embedded_style_preview(&self) {
|
||||||
|
if let Some(preview) = &self.embedded_style_preview {
|
||||||
|
preview.controller.set_visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_embedded_previews(&mut self) -> Task<Message> {
|
||||||
|
let post = self.sync_embedded_preview_for_active_post();
|
||||||
|
let style = self.sync_embedded_preview_for_style();
|
||||||
|
Task::batch([post, style])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_embedded_preview_for_style(&mut self) -> Task<Message> {
|
||||||
|
if !self.active_style_uses_embedded_preview() {
|
||||||
|
self.hide_embedded_style_preview();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if let Err(error) = self.ensure_preview_server() {
|
||||||
|
self.notify(ToastLevel::Error, &error);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
let Some(url) = self
|
||||||
|
.style_view_state
|
||||||
|
.as_ref()
|
||||||
|
.map(StyleViewState::preview_url)
|
||||||
|
else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(preview) = &mut self.embedded_style_preview {
|
||||||
|
preview.current_url = Some(url.clone());
|
||||||
|
if preview.controller.is_active() {
|
||||||
|
preview.controller.navigate(&url);
|
||||||
|
preview.controller.set_visible(true);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.embedded_style_preview = Some(EmbeddedPreviewState {
|
||||||
|
controller: WebViewController::new(WebViewConfig::default().url(url.clone())),
|
||||||
|
current_url: Some(url.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(window_id) = self.main_window_id else {
|
||||||
|
return window::get_oldest().map(Message::MainWindowLoaded);
|
||||||
|
};
|
||||||
|
if let Some(preview) = &mut self.embedded_style_preview
|
||||||
|
&& !preview.controller.is_active()
|
||||||
|
{
|
||||||
|
preview.controller = WebViewController::new(WebViewConfig::default().url(url));
|
||||||
|
return preview
|
||||||
|
.controller
|
||||||
|
.create_task(window_id, Message::EmbeddedStylePreviewReady);
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_embedded_preview_for_active_post(&mut self) -> Task<Message> {
|
fn sync_embedded_preview_for_active_post(&mut self) -> Task<Message> {
|
||||||
let Some(active_id) = self.active_tab.clone() else {
|
let Some(active_id) = self.active_tab.clone() else {
|
||||||
self.hide_embedded_preview();
|
self.hide_embedded_preview();
|
||||||
@@ -9054,6 +9216,7 @@ mod tests {
|
|||||||
use crate::views::post_editor::{PostEditorMsg, PostEditorState};
|
use crate::views::post_editor::{PostEditorMsg, PostEditorState};
|
||||||
use crate::views::script_editor::{ScriptEditorMsg, ScriptEditorState};
|
use crate::views::script_editor::{ScriptEditorMsg, ScriptEditorState};
|
||||||
use crate::views::settings_view::{AiModeViewState, SettingsSection, SettingsViewState};
|
use crate::views::settings_view::{AiModeViewState, SettingsSection, SettingsViewState};
|
||||||
|
use crate::views::style_view::{PreviewMode, StyleMsg, StyleViewState};
|
||||||
use crate::views::template_editor::TemplateEditorState;
|
use crate::views::template_editor::TemplateEditorState;
|
||||||
use bds_core::db::Database;
|
use bds_core::db::Database;
|
||||||
use bds_core::db::fts::ensure_fts_tables;
|
use bds_core::db::fts::ensure_fts_tables;
|
||||||
@@ -10364,6 +10527,57 @@ mod tests {
|
|||||||
assert!(app.active_post_uses_embedded_preview());
|
assert!(app.active_post_uses_embedded_preview());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn style_selection_and_preview_mode_stay_local_until_apply() {
|
||||||
|
let mut state = StyleViewState::new(Some("blue"));
|
||||||
|
|
||||||
|
assert_eq!(state.selected_theme, "blue");
|
||||||
|
assert_eq!(state.applied_theme, "blue");
|
||||||
|
assert!(!state.can_apply());
|
||||||
|
|
||||||
|
state.select_theme("green");
|
||||||
|
state.set_preview_mode(PreviewMode::Dark);
|
||||||
|
|
||||||
|
assert_eq!(state.selected_theme, "green");
|
||||||
|
assert_eq!(state.applied_theme, "blue");
|
||||||
|
assert_eq!(state.preview_mode, PreviewMode::Dark);
|
||||||
|
assert!(state.can_apply());
|
||||||
|
|
||||||
|
state.mark_applied();
|
||||||
|
assert!(!state.can_apply());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applying_style_persists_project_metadata_emits_event_and_updates_badge() {
|
||||||
|
let (db, project, tmp) = setup();
|
||||||
|
let events = bds_core::engine::domain_events::subscribe();
|
||||||
|
let mut app = make_app(db, project, &tmp);
|
||||||
|
app.style_view_state = Some(StyleViewState::new(None));
|
||||||
|
|
||||||
|
let _ = app.update(Message::Style(StyleMsg::SelectTheme("purple".to_string())));
|
||||||
|
let _ = app.update(Message::Style(StyleMsg::Apply));
|
||||||
|
|
||||||
|
let metadata = bds_core::engine::meta::read_project_json(tmp.path()).unwrap();
|
||||||
|
assert_eq!(metadata.pico_theme.as_deref(), Some("purple"));
|
||||||
|
let project_json = std::fs::read_to_string(tmp.path().join("meta/project.json")).unwrap();
|
||||||
|
assert!(project_json.contains("\"picoTheme\": \"purple\""));
|
||||||
|
assert!(!project_json.contains("previewMode"));
|
||||||
|
assert_eq!(app.theme_badge, "purple");
|
||||||
|
assert_eq!(
|
||||||
|
app.style_view_state.as_ref().unwrap().applied_theme,
|
||||||
|
"purple"
|
||||||
|
);
|
||||||
|
assert!(events.drain().iter().any(|event| matches!(
|
||||||
|
event,
|
||||||
|
DomainEvent::EntityChanged {
|
||||||
|
project_id,
|
||||||
|
entity: bds_core::model::DomainEntity::Project,
|
||||||
|
entity_id,
|
||||||
|
action: bds_core::model::NotificationAction::Updated,
|
||||||
|
} if project_id == "p1" && entity_id == "p1"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn task_tick_autosaves_dirty_post_editor() {
|
fn task_tick_autosaves_dirty_post_editor() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ impl BdsApp {
|
|||||||
match message {
|
match message {
|
||||||
Message::MainWindowLoaded(window_id) => {
|
Message::MainWindowLoaded(window_id) => {
|
||||||
self.main_window_id = window_id;
|
self.main_window_id = window_id;
|
||||||
if self.active_post_uses_embedded_preview() {
|
Task::batch([
|
||||||
self.sync_embedded_preview_for_active_post()
|
self.sync_embedded_preview_for_active_post(),
|
||||||
} else {
|
self.sync_embedded_preview_for_style(),
|
||||||
Task::none()
|
])
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Message::EmbeddedPreviewReady(result) => {
|
Message::EmbeddedPreviewReady(result) => {
|
||||||
match result {
|
match result {
|
||||||
@@ -29,6 +28,24 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
Message::EmbeddedStylePreviewReady(result) => {
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
let visible = self.active_style_uses_embedded_preview();
|
||||||
|
if let Some(preview) = &mut self.embedded_style_preview {
|
||||||
|
preview.controller.take_staged();
|
||||||
|
if let Some(url) = preview.current_url.as_deref() {
|
||||||
|
preview.controller.navigate(url);
|
||||||
|
}
|
||||||
|
preview.controller.set_visible(visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.notify(ToastLevel::Error, &error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
_ => unreachable!("non-preview message routed to preview handler"),
|
_ => unreachable!("non-preview message routed to preview handler"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,25 +278,23 @@ pub fn section_header<'a, Message: 'a>(label: &str) -> Element<'a, Message> {
|
|||||||
/// Primary action button style.
|
/// Primary action button style.
|
||||||
pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style {
|
pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
let _ = theme;
|
let _ = theme;
|
||||||
match status {
|
let background = match status {
|
||||||
button::Status::Hovered => button::Style {
|
button::Status::Hovered => PRIMARY_HOVER,
|
||||||
background: Some(Background::Color(PRIMARY_HOVER)),
|
button::Status::Disabled => PRIMARY_BG.scale_alpha(0.45),
|
||||||
text_color: Color::WHITE,
|
_ => PRIMARY_BG,
|
||||||
border: iced::Border {
|
};
|
||||||
radius: 6.0.into(),
|
button::Style {
|
||||||
..iced::Border::default()
|
background: Some(Background::Color(background)),
|
||||||
},
|
text_color: if matches!(status, button::Status::Disabled) {
|
||||||
..button::Style::default()
|
Color::WHITE.scale_alpha(0.55)
|
||||||
|
} else {
|
||||||
|
Color::WHITE
|
||||||
},
|
},
|
||||||
_ => button::Style {
|
border: iced::Border {
|
||||||
background: Some(Background::Color(PRIMARY_BG)),
|
radius: 6.0.into(),
|
||||||
text_color: Color::WHITE,
|
..iced::Border::default()
|
||||||
border: iced::Border {
|
|
||||||
radius: 6.0.into(),
|
|
||||||
..iced::Border::default()
|
|
||||||
},
|
|
||||||
..button::Style::default()
|
|
||||||
},
|
},
|
||||||
|
..button::Style::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +462,10 @@ mod tests {
|
|||||||
primary_button(&theme, button::Status::Active).background,
|
primary_button(&theme, button::Status::Active).background,
|
||||||
primary_button(&theme, button::Status::Hovered).background
|
primary_button(&theme, button::Status::Hovered).background
|
||||||
);
|
);
|
||||||
|
assert_ne!(
|
||||||
|
primary_button(&theme, button::Status::Active).background,
|
||||||
|
primary_button(&theme, button::Status::Disabled).background
|
||||||
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
field_style(&theme, text_input::Status::Active).border.color,
|
field_style(&theme, text_input::Status::Active).border.color,
|
||||||
field_style(&theme, text_input::Status::Focused)
|
field_style(&theme, text_input::Status::Focused)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub mod settings_view;
|
|||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod site_validation;
|
pub mod site_validation;
|
||||||
pub mod status_bar;
|
pub mod status_bar;
|
||||||
|
pub mod style_view;
|
||||||
pub mod tab_bar;
|
pub mod tab_bar;
|
||||||
pub mod tags_view;
|
pub mod tags_view;
|
||||||
pub mod template_editor;
|
pub mod template_editor;
|
||||||
|
|||||||
406
crates/bds-ui/src/views/style_view.rs
Normal file
406
crates/bds-ui/src/views/style_view.rs
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
use iced::widget::text::Shaping;
|
||||||
|
use iced::widget::{Column, Space, button, column, container, row, scrollable, text};
|
||||||
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
|
use bds_core::i18n::UiLocale;
|
||||||
|
|
||||||
|
use crate::app::Message;
|
||||||
|
use crate::components::inputs;
|
||||||
|
use crate::i18n::t;
|
||||||
|
|
||||||
|
const DEFAULT_THEME: &str = "default";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PreviewMode {
|
||||||
|
Auto,
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PreviewMode {
|
||||||
|
pub fn query_value(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Auto => "auto",
|
||||||
|
Self::Light => "light",
|
||||||
|
Self::Dark => "dark",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct PreviewModeOption {
|
||||||
|
mode: PreviewMode,
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PreviewModeOption {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str(&self.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct StyleTheme {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub accent_color: &'static str,
|
||||||
|
pub light_bg_color: &'static str,
|
||||||
|
pub dark_bg_color: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn theme(name: &'static str, accent_color: &'static str) -> StyleTheme {
|
||||||
|
StyleTheme {
|
||||||
|
name,
|
||||||
|
accent_color,
|
||||||
|
light_bg_color: "#ffffff",
|
||||||
|
dark_bg_color: "#13171f",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const THEMES: [StyleTheme; 20] = [
|
||||||
|
theme("default", "#0172ad"),
|
||||||
|
theme("amber", "#ffbf00"),
|
||||||
|
theme("blue", "#2060df"),
|
||||||
|
theme("cyan", "#047878"),
|
||||||
|
theme("fuchsia", "#c1208b"),
|
||||||
|
theme("green", "#398712"),
|
||||||
|
theme("grey", "#ababab"),
|
||||||
|
theme("indigo", "#524ed2"),
|
||||||
|
theme("jade", "#007a50"),
|
||||||
|
theme("lime", "#a5d601"),
|
||||||
|
theme("orange", "#d24317"),
|
||||||
|
theme("pink", "#d92662"),
|
||||||
|
theme("pumpkin", "#ff9500"),
|
||||||
|
theme("purple", "#9236a4"),
|
||||||
|
theme("red", "#c52f21"),
|
||||||
|
theme("sand", "#ccc6b4"),
|
||||||
|
theme("slate", "#525f7a"),
|
||||||
|
theme("violet", "#7540bf"),
|
||||||
|
theme("yellow", "#f2df0d"),
|
||||||
|
theme("zinc", "#646b79"),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct StyleViewState {
|
||||||
|
pub selected_theme: String,
|
||||||
|
pub applied_theme: String,
|
||||||
|
pub preview_mode: PreviewMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StyleViewState {
|
||||||
|
pub fn new(applied_theme: Option<&str>) -> Self {
|
||||||
|
let applied_theme = normalize_theme(applied_theme);
|
||||||
|
Self {
|
||||||
|
selected_theme: applied_theme.clone(),
|
||||||
|
applied_theme,
|
||||||
|
preview_mode: PreviewMode::Auto,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_theme(&mut self, theme: &str) {
|
||||||
|
if is_supported_theme(theme) {
|
||||||
|
self.selected_theme = theme.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_preview_mode(&mut self, mode: PreviewMode) {
|
||||||
|
self.preview_mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_apply(&self) -> bool {
|
||||||
|
self.selected_theme != self.applied_theme
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_applied(&mut self) {
|
||||||
|
self.applied_theme.clone_from(&self.selected_theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_applied_theme(&mut self, applied_theme: Option<&str>) {
|
||||||
|
let preserve_pending_selection = self.can_apply();
|
||||||
|
self.applied_theme = normalize_theme(applied_theme);
|
||||||
|
if !preserve_pending_selection {
|
||||||
|
self.selected_theme.clone_from(&self.applied_theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preview_url(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"http://{}:{}/__style-preview?theme={}&mode={}",
|
||||||
|
bds_core::engine::preview::PREVIEW_HOST,
|
||||||
|
bds_core::engine::preview::PREVIEW_PORT,
|
||||||
|
self.selected_theme,
|
||||||
|
self.preview_mode.query_value(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum StyleMsg {
|
||||||
|
SelectTheme(String),
|
||||||
|
PreviewModeChanged(PreviewMode),
|
||||||
|
Apply,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_supported_theme(theme: &str) -> bool {
|
||||||
|
THEMES.iter().any(|candidate| candidate.name == theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_theme_name(theme: &str) -> String {
|
||||||
|
let replaced = theme.replace('-', " ");
|
||||||
|
let mut characters = replaced.chars();
|
||||||
|
match characters.next() {
|
||||||
|
Some(first) => first.to_uppercase().chain(characters).collect(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_theme(theme: Option<&str>) -> String {
|
||||||
|
theme
|
||||||
|
.filter(|theme| is_supported_theme(theme))
|
||||||
|
.unwrap_or(DEFAULT_THEME)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hex_color(hex: &str) -> Color {
|
||||||
|
let bytes = hex.as_bytes();
|
||||||
|
if bytes.len() == 7 && bytes[0] == b'#' {
|
||||||
|
let component = |start| u8::from_str_radix(&hex[start..start + 2], 16).unwrap_or_default();
|
||||||
|
Color::from_rgb8(component(1), component(3), component(5))
|
||||||
|
} else {
|
||||||
|
Color::TRANSPARENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_button_style(selected: bool, status: button::Status) -> button::Style {
|
||||||
|
let hovered = matches!(status, button::Status::Hovered);
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(if selected {
|
||||||
|
Color::from_rgb8(0x1F, 0x45, 0x63)
|
||||||
|
} else if hovered {
|
||||||
|
Color::from_rgb8(0x32, 0x32, 0x35)
|
||||||
|
} else {
|
||||||
|
Color::from_rgb8(0x24, 0x24, 0x26)
|
||||||
|
})),
|
||||||
|
text_color: Color::from_rgb8(0xE4, 0xE4, 0xE4),
|
||||||
|
border: Border {
|
||||||
|
color: if selected {
|
||||||
|
Color::from_rgb8(0x00, 0x7F, 0xD4)
|
||||||
|
} else {
|
||||||
|
Color::from_rgb8(0x3C, 0x3C, 0x3C)
|
||||||
|
},
|
||||||
|
width: if selected { 2.0 } else { 1.0 },
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
..button::Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn swatch(color: &'static str) -> Element<'static, Message> {
|
||||||
|
container(Space::new(Length::Fill, Length::Fixed(16.0)))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(move |_: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(parse_hex_color(color))),
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_button<'a>(theme: &StyleTheme, selected_theme: &str) -> Element<'a, Message> {
|
||||||
|
let selected = theme.name == selected_theme;
|
||||||
|
let theme_name = theme.name.to_string();
|
||||||
|
button(
|
||||||
|
column![
|
||||||
|
row![
|
||||||
|
swatch(theme.accent_color),
|
||||||
|
swatch(theme.light_bg_color),
|
||||||
|
swatch(theme.dark_bg_color),
|
||||||
|
]
|
||||||
|
.spacing(2),
|
||||||
|
text(display_theme_name(theme.name))
|
||||||
|
.size(12)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
]
|
||||||
|
.spacing(7)
|
||||||
|
.width(Length::Fill),
|
||||||
|
)
|
||||||
|
.on_press(Message::Style(StyleMsg::SelectTheme(theme_name)))
|
||||||
|
.padding(8)
|
||||||
|
.width(Length::FillPortion(1))
|
||||||
|
.style(move |_: &Theme, status| theme_button_style(selected, status))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view<'a>(
|
||||||
|
state: &'a StyleViewState,
|
||||||
|
locale: UiLocale,
|
||||||
|
preview_widget: Option<Element<'a, Message>>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let header = inputs::card(
|
||||||
|
column![
|
||||||
|
text(t(locale, "style.title"))
|
||||||
|
.size(18)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
text(t(locale, "style.subtitle"))
|
||||||
|
.size(12)
|
||||||
|
.color(inputs::LABEL_COLOR)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
]
|
||||||
|
.spacing(4),
|
||||||
|
);
|
||||||
|
|
||||||
|
let theme_rows = THEMES
|
||||||
|
.chunks(4)
|
||||||
|
.map(|themes| {
|
||||||
|
let mut children = themes
|
||||||
|
.iter()
|
||||||
|
.map(|theme| theme_button(theme, &state.selected_theme))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
while children.len() < 4 {
|
||||||
|
children.push(Space::with_width(Length::FillPortion(1)).into());
|
||||||
|
}
|
||||||
|
iced::widget::Row::with_children(children)
|
||||||
|
.spacing(8)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect::<Vec<Element<'a, Message>>>();
|
||||||
|
let picker = inputs::card(
|
||||||
|
column![
|
||||||
|
text(t(locale, "style.themes"))
|
||||||
|
.size(12)
|
||||||
|
.color(inputs::SECTION_COLOR)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
scrollable(Column::with_children(theme_rows).spacing(8))
|
||||||
|
.direction(scrollable::Direction::Vertical(inputs::compact_scrollbar()))
|
||||||
|
.style(inputs::scrollable_style)
|
||||||
|
.height(Length::Fixed(250.0)),
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
);
|
||||||
|
|
||||||
|
let preview_modes = [
|
||||||
|
PreviewModeOption {
|
||||||
|
mode: PreviewMode::Auto,
|
||||||
|
label: t(locale, "style.previewMode.auto"),
|
||||||
|
},
|
||||||
|
PreviewModeOption {
|
||||||
|
mode: PreviewMode::Light,
|
||||||
|
label: t(locale, "style.previewMode.light"),
|
||||||
|
},
|
||||||
|
PreviewModeOption {
|
||||||
|
mode: PreviewMode::Dark,
|
||||||
|
label: t(locale, "style.previewMode.dark"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let selected_mode = preview_modes
|
||||||
|
.iter()
|
||||||
|
.find(|option| option.mode == state.preview_mode);
|
||||||
|
let mode_select = container(inputs::labeled_select(
|
||||||
|
&t(locale, "style.previewMode"),
|
||||||
|
&preview_modes,
|
||||||
|
selected_mode,
|
||||||
|
|option| Message::Style(StyleMsg::PreviewModeChanged(option.mode)),
|
||||||
|
))
|
||||||
|
.width(Length::Fixed(220.0));
|
||||||
|
let apply_label = text(t(locale, "style.apply"))
|
||||||
|
.size(13)
|
||||||
|
.shaping(Shaping::Advanced);
|
||||||
|
let apply_button: Element<'a, Message> = if state.can_apply() {
|
||||||
|
button(apply_label)
|
||||||
|
.on_press(Message::Style(StyleMsg::Apply))
|
||||||
|
.padding([8, 16])
|
||||||
|
.style(inputs::primary_button)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
button(apply_label)
|
||||||
|
.padding([8, 16])
|
||||||
|
.style(inputs::primary_button)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
let controls = inputs::toolbar(vec![mode_select.into()], vec![apply_button]);
|
||||||
|
|
||||||
|
let preview = preview_widget.unwrap_or_else(|| {
|
||||||
|
container(
|
||||||
|
text(t(locale, "tabBar.loading"))
|
||||||
|
.size(14)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
let preview_card = inputs::card(
|
||||||
|
column![
|
||||||
|
text(t(locale, "style.preview"))
|
||||||
|
.size(12)
|
||||||
|
.color(inputs::SECTION_COLOR)
|
||||||
|
.shaping(Shaping::Advanced),
|
||||||
|
preview,
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.height(Length::Fill),
|
||||||
|
)
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
container(
|
||||||
|
column![header, picker, controls, preview_card]
|
||||||
|
.spacing(10)
|
||||||
|
.height(Length::Fill),
|
||||||
|
)
|
||||||
|
.padding(16)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn themes_match_the_allium_and_bds2_catalog() {
|
||||||
|
assert_eq!(THEMES.len(), 20);
|
||||||
|
assert_eq!(THEMES.first().unwrap().name, "default");
|
||||||
|
assert_eq!(THEMES.last().unwrap().name, "zinc");
|
||||||
|
assert_eq!(
|
||||||
|
THEMES.iter().map(|theme| theme.name).collect::<Vec<_>>(),
|
||||||
|
bds_core::model::SUPPORTED_PICO_THEMES.to_vec()
|
||||||
|
);
|
||||||
|
assert_eq!(THEMES[1].accent_color, "#ffbf00");
|
||||||
|
assert_eq!(THEMES[2].accent_color, "#2060df");
|
||||||
|
assert_eq!(THEMES[18].accent_color, "#f2df0d");
|
||||||
|
assert_eq!(THEMES[19].accent_color, "#646b79");
|
||||||
|
assert_eq!(
|
||||||
|
THEMES
|
||||||
|
.iter()
|
||||||
|
.map(|theme| theme.accent_color)
|
||||||
|
.collect::<std::collections::HashSet<_>>()
|
||||||
|
.len(),
|
||||||
|
THEMES.len()
|
||||||
|
);
|
||||||
|
assert!(THEMES.iter().all(|theme| {
|
||||||
|
theme.light_bg_color == "#ffffff" && theme.dark_bg_color == "#13171f"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_url_tracks_selection_and_local_mode() {
|
||||||
|
let mut state = StyleViewState::new(Some("blue"));
|
||||||
|
state.select_theme("pumpkin");
|
||||||
|
state.set_preview_mode(PreviewMode::Light);
|
||||||
|
assert_eq!(
|
||||||
|
state.preview_url(),
|
||||||
|
"http://127.0.0.1:4123/__style-preview?theme=pumpkin&mode=light"
|
||||||
|
);
|
||||||
|
assert_eq!(state.applied_theme, "blue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_or_empty_applied_theme_uses_default() {
|
||||||
|
assert_eq!(StyleViewState::new(None).applied_theme, "default");
|
||||||
|
assert_eq!(StyleViewState::new(Some("")).applied_theme, "default");
|
||||||
|
assert_eq!(
|
||||||
|
StyleViewState::new(Some("unknown")).applied_theme,
|
||||||
|
"default"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,36 +35,3 @@ pub fn view(locale: UiLocale) -> Element<'static, Message> {
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tab_placeholder(
|
|
||||||
locale: UiLocale,
|
|
||||||
title: impl Into<String>,
|
|
||||||
subtitle: Option<String>,
|
|
||||||
) -> Element<'static, Message> {
|
|
||||||
let title = title.into();
|
|
||||||
let subtitle = subtitle.unwrap_or_else(|| t(locale, "common.tabNotImplemented"));
|
|
||||||
|
|
||||||
container(
|
|
||||||
column![
|
|
||||||
text(title)
|
|
||||||
.size(24)
|
|
||||||
.shaping(Shaping::Advanced)
|
|
||||||
.color(Color::from_rgb(0.85, 0.85, 0.90)),
|
|
||||||
text(subtitle)
|
|
||||||
.size(14)
|
|
||||||
.shaping(Shaping::Advanced)
|
|
||||||
.color(Color::from_rgb(0.55, 0.55, 0.60)),
|
|
||||||
]
|
|
||||||
.spacing(12)
|
|
||||||
.align_x(iced::Alignment::Center),
|
|
||||||
)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.height(Length::Fill)
|
|
||||||
.center_x(Length::Fill)
|
|
||||||
.center_y(Length::Fill)
|
|
||||||
.style(|_theme: &Theme| container::Style {
|
|
||||||
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
|
|
||||||
..container::Style::default()
|
|
||||||
})
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ use crate::views::{
|
|||||||
settings_view::{self, SettingsViewState},
|
settings_view::{self, SettingsViewState},
|
||||||
sidebar,
|
sidebar,
|
||||||
site_validation::{self, SiteValidationState},
|
site_validation::{self, SiteValidationState},
|
||||||
status_bar, tab_bar,
|
status_bar,
|
||||||
|
style_view::{self, StyleViewState},
|
||||||
|
tab_bar,
|
||||||
tags_view::{self, TagsViewState},
|
tags_view::{self, TagsViewState},
|
||||||
template_editor::{self, TemplateEditorState},
|
template_editor::{self, TemplateEditorState},
|
||||||
toast,
|
toast,
|
||||||
@@ -120,6 +122,7 @@ pub fn view<'a>(
|
|||||||
// Data directory (for thumbnail paths)
|
// Data directory (for thumbnail paths)
|
||||||
data_dir: Option<&'a Path>,
|
data_dir: Option<&'a Path>,
|
||||||
post_preview_widget: Option<Element<'a, Message>>,
|
post_preview_widget: Option<Element<'a, Message>>,
|
||||||
|
style_preview_widget: Option<Element<'a, Message>>,
|
||||||
// Editor states
|
// Editor states
|
||||||
post_editors: &'a HashMap<String, PostEditorState>,
|
post_editors: &'a HashMap<String, PostEditorState>,
|
||||||
media_editors: &'a HashMap<String, MediaEditorState>,
|
media_editors: &'a HashMap<String, MediaEditorState>,
|
||||||
@@ -129,6 +132,7 @@ pub fn view<'a>(
|
|||||||
chat_editors: &'a HashMap<String, ChatEditorState>,
|
chat_editors: &'a HashMap<String, ChatEditorState>,
|
||||||
tags_view_state: Option<&'a TagsViewState>,
|
tags_view_state: Option<&'a TagsViewState>,
|
||||||
settings_state: Option<&'a SettingsViewState>,
|
settings_state: Option<&'a SettingsViewState>,
|
||||||
|
style_view_state: Option<&'a StyleViewState>,
|
||||||
dashboard_state: Option<&'a DashboardState>,
|
dashboard_state: Option<&'a DashboardState>,
|
||||||
site_validation_state: &'a SiteValidationState,
|
site_validation_state: &'a SiteValidationState,
|
||||||
duplicates_state: &'a DuplicatesState,
|
duplicates_state: &'a DuplicatesState,
|
||||||
@@ -157,6 +161,7 @@ pub fn view<'a>(
|
|||||||
offline_mode,
|
offline_mode,
|
||||||
data_dir,
|
data_dir,
|
||||||
post_preview_widget,
|
post_preview_widget,
|
||||||
|
style_preview_widget,
|
||||||
post_editors,
|
post_editors,
|
||||||
media_editors,
|
media_editors,
|
||||||
template_editors,
|
template_editors,
|
||||||
@@ -165,6 +170,7 @@ pub fn view<'a>(
|
|||||||
chat_editors,
|
chat_editors,
|
||||||
tags_view_state,
|
tags_view_state,
|
||||||
settings_state,
|
settings_state,
|
||||||
|
style_view_state,
|
||||||
dashboard_state,
|
dashboard_state,
|
||||||
site_validation_state,
|
site_validation_state,
|
||||||
duplicates_state,
|
duplicates_state,
|
||||||
@@ -405,6 +411,7 @@ fn route_content_area<'a>(
|
|||||||
offline_mode: bool,
|
offline_mode: bool,
|
||||||
data_dir: Option<&'a Path>,
|
data_dir: Option<&'a Path>,
|
||||||
post_preview_widget: Option<Element<'a, Message>>,
|
post_preview_widget: Option<Element<'a, Message>>,
|
||||||
|
style_preview_widget: Option<Element<'a, Message>>,
|
||||||
post_editors: &'a HashMap<String, PostEditorState>,
|
post_editors: &'a HashMap<String, PostEditorState>,
|
||||||
media_editors: &'a HashMap<String, MediaEditorState>,
|
media_editors: &'a HashMap<String, MediaEditorState>,
|
||||||
template_editors: &'a HashMap<String, TemplateEditorState>,
|
template_editors: &'a HashMap<String, TemplateEditorState>,
|
||||||
@@ -413,6 +420,7 @@ fn route_content_area<'a>(
|
|||||||
chat_editors: &'a HashMap<String, ChatEditorState>,
|
chat_editors: &'a HashMap<String, ChatEditorState>,
|
||||||
tags_view_state: Option<&'a TagsViewState>,
|
tags_view_state: Option<&'a TagsViewState>,
|
||||||
settings_state: Option<&'a SettingsViewState>,
|
settings_state: Option<&'a SettingsViewState>,
|
||||||
|
style_view_state: Option<&'a StyleViewState>,
|
||||||
dashboard_state: Option<&'a DashboardState>,
|
dashboard_state: Option<&'a DashboardState>,
|
||||||
site_validation_state: &'a SiteValidationState,
|
site_validation_state: &'a SiteValidationState,
|
||||||
duplicates_state: &'a DuplicatesState,
|
duplicates_state: &'a DuplicatesState,
|
||||||
@@ -435,6 +443,7 @@ fn route_content_area<'a>(
|
|||||||
import_editors,
|
import_editors,
|
||||||
tags_view_state,
|
tags_view_state,
|
||||||
settings_state,
|
settings_state,
|
||||||
|
style_view_state,
|
||||||
dashboard_state,
|
dashboard_state,
|
||||||
site_validation_state,
|
site_validation_state,
|
||||||
) {
|
) {
|
||||||
@@ -509,6 +518,13 @@ fn route_content_area<'a>(
|
|||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ContentRoute::Style => {
|
||||||
|
if let Some(state) = style_view_state {
|
||||||
|
style_view::view(state, locale, style_preview_widget)
|
||||||
|
} else {
|
||||||
|
welcome::view(locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
|
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
|
||||||
ContentRoute::FindDuplicates => duplicates::view(duplicates_state, locale),
|
ContentRoute::FindDuplicates => duplicates::view(duplicates_state, locale),
|
||||||
ContentRoute::Documentation => documentation::view(guide_documentation, locale),
|
ContentRoute::Documentation => documentation::view(guide_documentation, locale),
|
||||||
@@ -533,7 +549,6 @@ fn route_content_area<'a>(
|
|||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContentRoute::Placeholder(title) => welcome::tab_placeholder(locale, title, None),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,6 +565,7 @@ enum ContentRoute<'a> {
|
|||||||
Chat(&'a str),
|
Chat(&'a str),
|
||||||
Tags,
|
Tags,
|
||||||
Settings,
|
Settings,
|
||||||
|
Style,
|
||||||
SiteValidation,
|
SiteValidation,
|
||||||
FindDuplicates,
|
FindDuplicates,
|
||||||
Documentation,
|
Documentation,
|
||||||
@@ -560,7 +576,6 @@ enum ContentRoute<'a> {
|
|||||||
MenuEditor,
|
MenuEditor,
|
||||||
TranslationValidation,
|
TranslationValidation,
|
||||||
GitDiff(&'a str),
|
GitDiff(&'a str),
|
||||||
Placeholder(&'a str),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
#[expect(
|
||||||
@@ -577,6 +592,7 @@ fn route_kind<'a>(
|
|||||||
import_editors: &'a HashMap<String, crate::views::import_editor::ImportEditorState>,
|
import_editors: &'a HashMap<String, crate::views::import_editor::ImportEditorState>,
|
||||||
tags_view_state: Option<&'a TagsViewState>,
|
tags_view_state: Option<&'a TagsViewState>,
|
||||||
settings_state: Option<&'a SettingsViewState>,
|
settings_state: Option<&'a SettingsViewState>,
|
||||||
|
style_view_state: Option<&'a StyleViewState>,
|
||||||
dashboard_state: Option<&'a DashboardState>,
|
dashboard_state: Option<&'a DashboardState>,
|
||||||
_site_validation_state: &'a SiteValidationState,
|
_site_validation_state: &'a SiteValidationState,
|
||||||
) -> ContentRoute<'a> {
|
) -> ContentRoute<'a> {
|
||||||
@@ -651,7 +667,13 @@ fn route_kind<'a>(
|
|||||||
TabType::CliDocumentation => ContentRoute::CliDocumentation,
|
TabType::CliDocumentation => ContentRoute::CliDocumentation,
|
||||||
TabType::McpDocumentation => ContentRoute::McpDocumentation,
|
TabType::McpDocumentation => ContentRoute::McpDocumentation,
|
||||||
TabType::MenuEditor => ContentRoute::MenuEditor,
|
TabType::MenuEditor => ContentRoute::MenuEditor,
|
||||||
TabType::Style => ContentRoute::Placeholder(&tab.title),
|
TabType::Style => {
|
||||||
|
if style_view_state.is_some() {
|
||||||
|
ContentRoute::Style
|
||||||
|
} else {
|
||||||
|
ContentRoute::Welcome
|
||||||
|
}
|
||||||
|
}
|
||||||
TabType::TranslationValidation => ContentRoute::TranslationValidation,
|
TabType::TranslationValidation => ContentRoute::TranslationValidation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -701,35 +723,46 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unsupported_tool_tabs_do_not_fall_back_to_welcome_route() {
|
fn style_tab_routes_to_real_editor_only_with_project_state() {
|
||||||
let empty_posts = HashMap::new();
|
let empty_posts = HashMap::new();
|
||||||
let empty_media = HashMap::new();
|
let empty_media = HashMap::new();
|
||||||
let empty_templates = HashMap::new();
|
let empty_templates = HashMap::new();
|
||||||
let empty_scripts = HashMap::new();
|
let empty_scripts = HashMap::new();
|
||||||
let empty_imports = HashMap::new();
|
let empty_imports = HashMap::new();
|
||||||
let site_validation_state = SiteValidationState::default();
|
let site_validation_state = SiteValidationState::default();
|
||||||
let unsupported = [TabType::Style];
|
let style_state = StyleViewState::new(Some("blue"));
|
||||||
|
let tabs = vec![tab("style", TabType::Style, "Style")];
|
||||||
|
let route = route_kind(
|
||||||
|
&tabs,
|
||||||
|
Some("style"),
|
||||||
|
&empty_posts,
|
||||||
|
&empty_media,
|
||||||
|
&empty_templates,
|
||||||
|
&empty_scripts,
|
||||||
|
&empty_imports,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&style_state),
|
||||||
|
None,
|
||||||
|
&site_validation_state,
|
||||||
|
);
|
||||||
|
assert!(matches!(route, ContentRoute::Style));
|
||||||
|
|
||||||
for tab_type in unsupported {
|
let no_project_route = route_kind(
|
||||||
let tabs = vec![tab("tool", tab_type.clone(), "Tool")];
|
&tabs,
|
||||||
let route = route_kind(
|
Some("style"),
|
||||||
&tabs,
|
&empty_posts,
|
||||||
Some("tool"),
|
&empty_media,
|
||||||
&empty_posts,
|
&empty_templates,
|
||||||
&empty_media,
|
&empty_scripts,
|
||||||
&empty_templates,
|
&empty_imports,
|
||||||
&empty_scripts,
|
None,
|
||||||
&empty_imports,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
&site_validation_state,
|
||||||
&site_validation_state,
|
);
|
||||||
);
|
assert!(matches!(no_project_route, ContentRoute::Welcome));
|
||||||
match route {
|
|
||||||
ContentRoute::Placeholder(title) => assert_eq!(title, "Tool"),
|
|
||||||
_ => panic!("expected placeholder route for {tab_type:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -752,6 +785,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&site_validation,
|
&site_validation,
|
||||||
);
|
);
|
||||||
assert!(matches!(route, ContentRoute::MenuEditor));
|
assert!(matches!(route, ContentRoute::MenuEditor));
|
||||||
@@ -781,6 +815,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&site_validation,
|
&site_validation,
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -814,6 +849,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&site_validation,
|
&site_validation,
|
||||||
);
|
);
|
||||||
assert!(matches!(route, ContentRoute::FindDuplicates));
|
assert!(matches!(route, ContentRoute::FindDuplicates));
|
||||||
@@ -839,6 +875,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&site_validation_state,
|
&site_validation_state,
|
||||||
);
|
);
|
||||||
assert!(matches!(route, ContentRoute::GitDiff("git-diff:file.txt")));
|
assert!(matches!(route, ContentRoute::GitDiff("git-diff:file.txt")));
|
||||||
@@ -864,6 +901,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&site_validation_state,
|
&site_validation_state,
|
||||||
);
|
);
|
||||||
assert!(matches!(route, ContentRoute::Chat("conversation")));
|
assert!(matches!(route, ContentRoute::Chat("conversation")));
|
||||||
@@ -894,6 +932,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&site_validation_state,
|
&site_validation_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -928,6 +967,7 @@ mod tests {
|
|||||||
&empty_imports,
|
&empty_imports,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some(&dashboard),
|
Some(&dashboard),
|
||||||
&site_validation_state,
|
&site_validation_state,
|
||||||
);
|
);
|
||||||
@@ -962,6 +1002,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&site_validation_state,
|
&site_validation_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,16 @@ settings-nav-publishing = Veröffentlichung
|
|||||||
settings-nav-data = Daten
|
settings-nav-data = Daten
|
||||||
settings-nav-mcp = MCP
|
settings-nav-mcp = MCP
|
||||||
settings-nav-style = Stil
|
settings-nav-style = Stil
|
||||||
|
style-title = Stil
|
||||||
|
style-subtitle = Wähle das Pico-CSS-Theme für dieses Projekt.
|
||||||
|
style-themes = Themes
|
||||||
|
style-previewMode = Vorschaumodus
|
||||||
|
style-previewMode-auto = Automatisch
|
||||||
|
style-previewMode-light = Hell
|
||||||
|
style-previewMode-dark = Dunkel
|
||||||
|
style-apply = Theme anwenden
|
||||||
|
style-applied = Theme angewendet.
|
||||||
|
style-preview = Live-Vorschau
|
||||||
tags-nav-cloud = Tag-Wolke
|
tags-nav-cloud = Tag-Wolke
|
||||||
tags-nav-manage = Tags verwalten
|
tags-nav-manage = Tags verwalten
|
||||||
tags-nav-merge = Tags zusammenführen
|
tags-nav-merge = Tags zusammenführen
|
||||||
@@ -578,7 +588,6 @@ dashboard-timeline = Beiträge im Zeitverlauf
|
|||||||
dashboard-recentPosts = Kürzlich aktualisiert
|
dashboard-recentPosts = Kürzlich aktualisiert
|
||||||
dashboard-postCount = { $count } Beiträge
|
dashboard-postCount = { $count } Beiträge
|
||||||
dashboard-pin = Anheften
|
dashboard-pin = Anheften
|
||||||
common-tabNotImplemented = Dieser Bereich wird in einem späteren Meilenstein implementiert.
|
|
||||||
editor-noPreviewAvailable = Keine Vorschau verfügbar
|
editor-noPreviewAvailable = Keine Vorschau verfügbar
|
||||||
common-operationFailed = { $operation } fehlgeschlagen: { $error }
|
common-operationFailed = { $operation } fehlgeschlagen: { $error }
|
||||||
common-databaseUnavailable = Datenbank nicht verfügbar
|
common-databaseUnavailable = Datenbank nicht verfügbar
|
||||||
|
|||||||
@@ -312,6 +312,16 @@ settings-nav-publishing = Publishing
|
|||||||
settings-nav-data = Data
|
settings-nav-data = Data
|
||||||
settings-nav-mcp = MCP
|
settings-nav-mcp = MCP
|
||||||
settings-nav-style = Style
|
settings-nav-style = Style
|
||||||
|
style-title = Style
|
||||||
|
style-subtitle = Choose the Pico CSS theme used by this project.
|
||||||
|
style-themes = Themes
|
||||||
|
style-previewMode = Preview Mode
|
||||||
|
style-previewMode-auto = Auto
|
||||||
|
style-previewMode-light = Light
|
||||||
|
style-previewMode-dark = Dark
|
||||||
|
style-apply = Apply Theme
|
||||||
|
style-applied = Theme applied.
|
||||||
|
style-preview = Live Preview
|
||||||
tags-nav-cloud = Tag Cloud
|
tags-nav-cloud = Tag Cloud
|
||||||
tags-nav-manage = Manage Tags
|
tags-nav-manage = Manage Tags
|
||||||
tags-nav-merge = Merge Tags
|
tags-nav-merge = Merge Tags
|
||||||
@@ -578,7 +588,6 @@ dashboard-timeline = Posts Over Time
|
|||||||
dashboard-recentPosts = Recently Updated
|
dashboard-recentPosts = Recently Updated
|
||||||
dashboard-postCount = { $count } posts
|
dashboard-postCount = { $count } posts
|
||||||
dashboard-pin = Pin
|
dashboard-pin = Pin
|
||||||
common-tabNotImplemented = This area will be implemented in a later milestone.
|
|
||||||
editor-noPreviewAvailable = No preview available
|
editor-noPreviewAvailable = No preview available
|
||||||
common-operationFailed = { $operation } failed: { $error }
|
common-operationFailed = { $operation } failed: { $error }
|
||||||
common-databaseUnavailable = Database unavailable
|
common-databaseUnavailable = Database unavailable
|
||||||
|
|||||||
@@ -312,6 +312,16 @@ settings-nav-publishing = Publicación
|
|||||||
settings-nav-data = Datos
|
settings-nav-data = Datos
|
||||||
settings-nav-mcp = MCP
|
settings-nav-mcp = MCP
|
||||||
settings-nav-style = Estilo
|
settings-nav-style = Estilo
|
||||||
|
style-title = Estilo
|
||||||
|
style-subtitle = Elige el tema de Pico CSS para este proyecto.
|
||||||
|
style-themes = Temas
|
||||||
|
style-previewMode = Modo de vista previa
|
||||||
|
style-previewMode-auto = Automático
|
||||||
|
style-previewMode-light = Claro
|
||||||
|
style-previewMode-dark = Oscuro
|
||||||
|
style-apply = Aplicar tema
|
||||||
|
style-applied = Tema aplicado.
|
||||||
|
style-preview = Vista previa en directo
|
||||||
tags-nav-cloud = Nube de etiquetas
|
tags-nav-cloud = Nube de etiquetas
|
||||||
tags-nav-manage = Gestionar etiquetas
|
tags-nav-manage = Gestionar etiquetas
|
||||||
tags-nav-merge = Fusionar etiquetas
|
tags-nav-merge = Fusionar etiquetas
|
||||||
@@ -578,7 +588,6 @@ dashboard-timeline = Entradas a lo largo del tiempo
|
|||||||
dashboard-recentPosts = Actualizadas recientemente
|
dashboard-recentPosts = Actualizadas recientemente
|
||||||
dashboard-postCount = { $count } entradas
|
dashboard-postCount = { $count } entradas
|
||||||
dashboard-pin = Fijar
|
dashboard-pin = Fijar
|
||||||
common-tabNotImplemented = Esta sección se implementará en un hito posterior.
|
|
||||||
editor-noPreviewAvailable = No hay vista previa disponible
|
editor-noPreviewAvailable = No hay vista previa disponible
|
||||||
common-operationFailed = Error en { $operation }: { $error }
|
common-operationFailed = Error en { $operation }: { $error }
|
||||||
common-databaseUnavailable = Base de datos no disponible
|
common-databaseUnavailable = Base de datos no disponible
|
||||||
|
|||||||
@@ -312,6 +312,16 @@ settings-nav-publishing = Publication
|
|||||||
settings-nav-data = Données
|
settings-nav-data = Données
|
||||||
settings-nav-mcp = MCP
|
settings-nav-mcp = MCP
|
||||||
settings-nav-style = Style
|
settings-nav-style = Style
|
||||||
|
style-title = Style
|
||||||
|
style-subtitle = Choisissez le thème Pico CSS de ce projet.
|
||||||
|
style-themes = Thèmes
|
||||||
|
style-previewMode = Mode d’aperçu
|
||||||
|
style-previewMode-auto = Automatique
|
||||||
|
style-previewMode-light = Clair
|
||||||
|
style-previewMode-dark = Sombre
|
||||||
|
style-apply = Appliquer le thème
|
||||||
|
style-applied = Thème appliqué.
|
||||||
|
style-preview = Aperçu en direct
|
||||||
tags-nav-cloud = Nuage de tags
|
tags-nav-cloud = Nuage de tags
|
||||||
tags-nav-manage = Gérer les tags
|
tags-nav-manage = Gérer les tags
|
||||||
tags-nav-merge = Fusionner les tags
|
tags-nav-merge = Fusionner les tags
|
||||||
@@ -578,7 +588,6 @@ dashboard-timeline = Articles dans le temps
|
|||||||
dashboard-recentPosts = Récemment mis à jour
|
dashboard-recentPosts = Récemment mis à jour
|
||||||
dashboard-postCount = { $count } articles
|
dashboard-postCount = { $count } articles
|
||||||
dashboard-pin = Épingler
|
dashboard-pin = Épingler
|
||||||
common-tabNotImplemented = Cette section sera mise en œuvre lors d’une étape ultérieure.
|
|
||||||
editor-noPreviewAvailable = Aucun aperçu disponible
|
editor-noPreviewAvailable = Aucun aperçu disponible
|
||||||
common-operationFailed = Échec de l’opération « { $operation } » : { $error }
|
common-operationFailed = Échec de l’opération « { $operation } » : { $error }
|
||||||
common-databaseUnavailable = Base de données indisponible
|
common-databaseUnavailable = Base de données indisponible
|
||||||
|
|||||||
@@ -312,6 +312,16 @@ settings-nav-publishing = Pubblicazione
|
|||||||
settings-nav-data = Dati
|
settings-nav-data = Dati
|
||||||
settings-nav-mcp = MCP
|
settings-nav-mcp = MCP
|
||||||
settings-nav-style = Stile
|
settings-nav-style = Stile
|
||||||
|
style-title = Stile
|
||||||
|
style-subtitle = Scegli il tema Pico CSS per questo progetto.
|
||||||
|
style-themes = Temi
|
||||||
|
style-previewMode = Modalità anteprima
|
||||||
|
style-previewMode-auto = Automatica
|
||||||
|
style-previewMode-light = Chiara
|
||||||
|
style-previewMode-dark = Scura
|
||||||
|
style-apply = Applica tema
|
||||||
|
style-applied = Tema applicato.
|
||||||
|
style-preview = Anteprima in tempo reale
|
||||||
tags-nav-cloud = Nuvola di tag
|
tags-nav-cloud = Nuvola di tag
|
||||||
tags-nav-manage = Gestisci tag
|
tags-nav-manage = Gestisci tag
|
||||||
tags-nav-merge = Unisci tag
|
tags-nav-merge = Unisci tag
|
||||||
@@ -578,7 +588,6 @@ dashboard-timeline = Post nel tempo
|
|||||||
dashboard-recentPosts = Aggiornati di recente
|
dashboard-recentPosts = Aggiornati di recente
|
||||||
dashboard-postCount = { $count } post
|
dashboard-postCount = { $count } post
|
||||||
dashboard-pin = Fissa
|
dashboard-pin = Fissa
|
||||||
common-tabNotImplemented = Questa sezione verrà implementata in una fase successiva.
|
|
||||||
editor-noPreviewAvailable = Nessuna anteprima disponibile
|
editor-noPreviewAvailable = Nessuna anteprima disponibile
|
||||||
common-operationFailed = Operazione non riuscita ({ $operation }): { $error }
|
common-operationFailed = Operazione non riuscita ({ $operation }): { $error }
|
||||||
common-databaseUnavailable = Database non disponibile
|
common-databaseUnavailable = Database non disponibile
|
||||||
|
|||||||
Reference in New Issue
Block a user