feat: more closing of M4

This commit is contained in:
2026-04-13 20:30:43 +02:00
parent ffcf3d9878
commit 316a218685
11 changed files with 515 additions and 35 deletions

View File

@@ -59,9 +59,11 @@ struct PreviewServerState {
#[derive(Debug, Deserialize, Default)]
struct DraftPreviewQuery {
language: Option<String>,
theme: Option<String>,
mode: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
#[derive(Debug, Clone, Deserialize, Default)]
struct StylePreviewQuery {
theme: Option<String>,
mode: Option<String>,
@@ -115,9 +117,10 @@ pub fn start_preview_server(
async fn handle_preview_request(
State(state): State<PreviewServerState>,
Query(query): Query<StylePreviewQuery>,
uri: Uri,
) -> Response {
match render_preview_response(&state, uri.path(), None) {
match render_preview_response(&state, uri.path(), None, Some(&query)) {
Ok(response) => response,
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
@@ -128,7 +131,11 @@ async fn handle_draft_preview(
AxumPath(post_id): AxumPath<String>,
Query(query): Query<DraftPreviewQuery>,
) -> Response {
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref()) {
let style = StylePreviewQuery {
theme: query.theme.clone(),
mode: query.mode.clone(),
};
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref(), Some(&style)) {
Ok(response) => response,
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
@@ -153,9 +160,10 @@ fn render_preview_response(
state: &PreviewServerState,
path: &str,
requested_language: Option<&str>,
style: Option<&StylePreviewQuery>,
) -> EngineResult<Response> {
if let Some(post_id) = path.strip_prefix("/__draft/") {
let html = render_draft_preview(state, post_id, requested_language)?;
let html = apply_preview_style(render_draft_preview(state, post_id, requested_language)?, style);
return Ok(Html(html).into_response());
}
@@ -173,7 +181,53 @@ fn render_preview_response(
let response = build_preview_response(db.conn(), &state.data_dir, &state.project_id, &metadata, &input_posts, path)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK);
Ok((status, Html(response.html)).into_response())
Ok((status, Html(apply_preview_style(response.html, style))).into_response())
}
fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> String {
let Some(style) = style else {
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 {
return html;
};
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()) {
attrs.push_str(" data-mode=\"");
attrs.push_str(&escape_html_attr(mode));
attrs.push('"');
}
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
}
fn escape_html_attr(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn render_draft_preview(
@@ -579,4 +633,30 @@ mod tests {
assert!(body.contains("nightfall"));
assert!(body.contains("dark"));
}
#[test]
fn preview_server_applies_theme_query_to_rendered_pages() {
let _guard = preview_port_guard().lock().unwrap();
let (dir, _db) = setup_preview_fixture();
let server = start_preview_server(
dir.path().join("bds.db"),
dir.path().to_path_buf(),
"project-1".into(),
)
.unwrap();
let client = reqwest::blocking::Client::new();
let response = client
.get(format!(
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=nightfall&mode=dark"
))
.send()
.unwrap();
let body = response.text().unwrap();
server.stop().unwrap();
assert!(body.contains("data-theme=\"nightfall\""));
assert!(body.contains("data-mode=\"dark\""));
}
}

View File

@@ -9,6 +9,7 @@ use rusqlite::Connection;
use serde_json::{Value, json};
use crate::db::queries;
use crate::engine::menu::{self, MenuItemKind};
use crate::model::{CategorySettings, Media, Post, ProjectMetadata, Tag, Template, TemplateKind, TemplateStatus};
use crate::render::{RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, render_liquid_template, resolve_post_template};
use crate::util::frontmatter::{read_template_file, read_translation_file};
@@ -100,6 +101,7 @@ pub fn build_site_render_artifacts(
let localized_posts = load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
let routes = build_language_routes(&localized_posts, metadata, &language, &tags);
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts);
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
let rendered_list_pages = routes
.par_iter()
.map(|route| {
@@ -109,6 +111,7 @@ pub fn build_site_render_artifacts(
&language,
&localized_posts,
&tags,
&menu_items,
&post_data_json_by_id,
&bundle,
)
@@ -144,6 +147,7 @@ pub fn build_site_render_artifacts(
&category_settings,
&media_by_id,
&canonical_map,
&menu_items,
&post_data_json_by_id,
&bundle,
)?;
@@ -186,7 +190,8 @@ pub fn build_preview_response(
let bundle = load_template_bundle(conn, data_dir, project_id)?;
let language = language_from_path(&normalized, metadata);
let html = render_not_found_route(&bundle, metadata, &language, &normalized)?;
let menu_items = build_menu_items(data_dir, &language, main_language(metadata))?;
let html = render_not_found_route(&bundle, metadata, &language, &normalized, &menu_items)?;
Ok(PreviewRenderResult {
status_code: 404,
html,
@@ -447,6 +452,7 @@ fn render_list_route(
language: &str,
posts: &[RenderPostRecord],
tags: &[Tag],
menu_items: &[Value],
post_data_json_by_id: &HashMap<String, Value>,
bundle: &TemplateBundle,
) -> Result<String, Box<dyn Error + Send + Sync>> {
@@ -461,7 +467,7 @@ fn render_list_route(
"pico_stylesheet_href": pico_stylesheet_href(metadata),
"blog_languages": build_list_blog_languages(metadata, language, &route.url_path),
"alternate_links": build_alternate_list_links(metadata, &route.url_path),
"menu_items": Vec::<Value>::new(),
"menu_items": menu_items,
"calendar_initial_year": route.posts.first().map(|post| calendar_initial_parts(&post.post).0).unwrap_or(1970),
"calendar_initial_month": route.posts.first().map(|post| calendar_initial_parts(&post.post).1).unwrap_or(1),
"archive_context": route.archive_context,
@@ -506,6 +512,7 @@ fn render_post_route(
category_settings: &HashMap<String, CategorySettings>,
media_by_id: &HashMap<String, Media>,
canonical_post_path_by_slug: &HashMap<String, String>,
menu_items: &[Value],
post_data_json_by_id: &HashMap<String, Value>,
bundle: &TemplateBundle,
) -> Result<String, Box<dyn Error + Send + Sync>> {
@@ -553,7 +560,7 @@ fn render_post_route(
"html_theme_attribute": serde_json::Value::Null,
"alternate_links": build_alternate_post_links(&record.post, metadata),
"blog_languages": build_post_blog_languages(&record.post, metadata, language),
"menu_items": Vec::<Value>::new(),
"menu_items": menu_items,
"calendar_initial_year": calendar_initial_parts(&record.post).0,
"calendar_initial_month": calendar_initial_parts(&record.post).1,
"post": post_context(&record.post, &record.body_markdown, linked_media, outgoing_link_context, incoming_link_context),
@@ -588,6 +595,7 @@ fn render_not_found_route(
metadata: &ProjectMetadata,
language: &str,
requested_path: &str,
menu_items: &[Value],
) -> Result<String, Box<dyn Error + Send + Sync>> {
let context = json!({
"language": language,
@@ -597,7 +605,7 @@ fn render_not_found_route(
"pico_stylesheet_href": pico_stylesheet_href(metadata),
"blog_languages": build_list_blog_languages(metadata, language, requested_path),
"alternate_links": build_alternate_list_links(metadata, requested_path),
"menu_items": Vec::<Value>::new(),
"menu_items": menu_items,
"calendar_initial_year": 1970,
"calendar_initial_month": 1,
"post": serde_json::Value::Null,
@@ -618,7 +626,7 @@ fn render_not_found_route(
"prev_page_href": serde_json::Value::Null,
"next_page_href": serde_json::Value::Null,
"not_found_message": format!("No rendered page exists for {}", requested_path),
"not_found_back_label": "Back to home",
"not_found_back_label": serde_json::Value::Null,
"canonical_post_path_by_slug": HashMap::<String, String>::new(),
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
"post_data_json_by_id": HashMap::<String, Value>::new(),
@@ -626,6 +634,68 @@ fn render_not_found_route(
Ok(render_liquid_template(&bundle.not_found_template, &bundle.partials, &context)?)
}
fn build_menu_items(
data_dir: &Path,
language: &str,
main_language: &str,
) -> Result<Vec<Value>, Box<dyn Error + Send + Sync>> {
let items = menu::read_menu(data_dir)?;
Ok(items
.iter()
.map(|item| menu_item_context(item, language, main_language))
.collect())
}
fn menu_item_context(item: &menu::MenuItem, language: &str, main_language: &str) -> Value {
let children = item
.children
.iter()
.map(|child| menu_item_context(child, language, main_language))
.collect::<Vec<_>>();
json!({
"title": item.label,
"href": menu_item_href(item, language, main_language),
"has_children": !children.is_empty(),
"children": children,
})
}
fn menu_item_href(item: &menu::MenuItem, language: &str, main_language: &str) -> String {
let prefix = language_prefix(language, main_language);
match item.kind {
MenuItemKind::Home => {
if prefix.is_empty() {
"/".to_string()
} else {
format!("{prefix}/")
}
}
MenuItemKind::Submenu => "#".to_string(),
MenuItemKind::Page => item
.slug
.as_deref()
.map(|slug| prefixed_slug_path(&prefix, slug))
.unwrap_or_else(|| "#".to_string()),
MenuItemKind::CategoryArchive => item
.slug
.as_deref()
.map(|slug| format!("{}/category/{}/", prefix_or_root(&prefix), slugify(slug)))
.unwrap_or_else(|| "#".to_string()),
}
}
fn prefixed_slug_path(prefix: &str, slug: &str) -> String {
format!("{}{}/", prefix_or_root(prefix), slug.trim_matches('/'))
}
fn prefix_or_root(prefix: &str) -> &str {
if prefix.is_empty() {
"/"
} else {
prefix
}
}
fn route_href(route: &RouteSpec, page: usize) -> String {
let base = route.url_path.trim_end_matches('/');
if page <= 1 {

View File

@@ -30,6 +30,7 @@ use crate::views::{
modal, workspace,
post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink},
media_editor::{LinkedPostItem, MediaEditorState, MediaEditorMsg},
site_validation::SiteValidationState,
template_editor::{TemplateEditorState, TemplateEditorMsg},
script_editor::{ScriptEditorState, ScriptEditorMsg},
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
@@ -135,7 +136,9 @@ pub enum Message {
ValidateMedia,
GenerateSite,
RunMetadataDiff,
RunSiteValidation,
EngineTaskDone { task_id: TaskId, label: String, result: Result<String, String> },
SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>),
// Editor views
PostEditor(PostEditorMsg),
@@ -555,8 +558,11 @@ mod tests {
use bds_core::db::fts::ensure_fts_tables;
use bds_core::db::queries::project::insert_project;
use bds_core::db::Database;
use bds_core::engine::{media, post, script, template};
use bds_core::engine::{ai, media, post, script, template};
use bds_core::model::{Project, ScriptKind, TemplateKind};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use tempfile::TempDir;
fn make_project() -> Project {
@@ -609,6 +615,28 @@ mod tests {
(db, project, tempdir)
}
fn spawn_models_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
thread::spawn(move || {
if let Some(stream) = listener.incoming().next() {
let mut stream = stream.unwrap();
let mut buffer = [0_u8; 8192];
let size = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..size]).to_string();
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
let body = r#"{"data":[{"id":"llama3.2","name":"Llama 3.2"}]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body,
);
stream.write_all(response.as_bytes()).unwrap();
}
});
format!("http://{}", addr)
}
fn make_app(db: Database, project: Project, tmp: &TempDir) -> BdsApp {
BdsApp::new_for_tests(db, project, tmp.path().to_path_buf())
}
@@ -801,6 +829,24 @@ mod tests {
}
}
#[test]
fn save_ai_settings_allows_airplane_only_configuration() {
let (db, _project, _tmp) = setup();
let mut state = SettingsViewState::default();
state.airplane_endpoint_url = spawn_models_server();
state.airplane_endpoint_model = "llama3.2".to_string();
state.system_prompt = iced::widget::text_editor::Content::with_text("Use JSON only.");
BdsApp::save_ai_settings_state(&db, &mut state).unwrap();
let settings = ai::load_ai_settings(db.conn(), false).unwrap();
assert!(settings.online_endpoint.url.is_empty());
assert!(settings.online_endpoint.model.is_empty());
assert_eq!(settings.airplane_endpoint.url, state.airplane_endpoint_url);
assert_eq!(settings.airplane_endpoint.model, "llama3.2");
assert_eq!(settings.system_prompt.trim_end(), "Use JSON only.");
}
#[test]
fn media_editor_save_flow_persists_changes() {
let (db, project, tmp) = setup();
@@ -1607,6 +1653,7 @@ pub struct BdsApp {
tags_view_state: Option<TagsViewState>,
settings_state: Option<SettingsViewState>,
dashboard_state: Option<DashboardState>,
site_validation_state: SiteValidationState,
}
// ───────────────────────────────────────────────────────────
@@ -1739,6 +1786,7 @@ impl BdsApp {
tags_view_state: None,
settings_state: None,
dashboard_state: None,
site_validation_state: SiteValidationState::default(),
},
init_task,
)
@@ -1796,6 +1844,7 @@ impl BdsApp {
tags_view_state: None,
settings_state: None,
dashboard_state: None,
site_validation_state: SiteValidationState::default(),
}
}
@@ -2301,6 +2350,7 @@ impl BdsApp {
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
Task::none()
}
Message::RunSiteValidation => self.start_site_validation(),
Message::EngineTaskDone { task_id, label, result } => {
match &result {
Ok(detail) => {
@@ -2316,6 +2366,36 @@ impl BdsApp {
self.refresh_task_snapshots();
sidebar_task
}
Message::SiteValidationLoaded(result) => {
self.site_validation_state.is_running = false;
self.site_validation_state.has_run = true;
match result {
Ok(report) => {
self.site_validation_state.error_message = None;
self.site_validation_state.missing_files = report.missing_pages;
self.site_validation_state.extra_files = report.extra_pages;
self.site_validation_state.stale_files = report.stale_pages;
self.notify(
ToastLevel::Success,
&format!(
"{}: missing={}, extra={}, stale={}",
t(self.ui_locale, "tabBar.siteValidation"),
self.site_validation_state.missing_files.len(),
self.site_validation_state.extra_files.len(),
self.site_validation_state.stale_files.len(),
),
);
}
Err(error) => {
self.site_validation_state.error_message = Some(error.clone());
self.site_validation_state.missing_files.clear();
self.site_validation_state.extra_files.clear();
self.site_validation_state.stale_files.clear();
self.notify(ToastLevel::Error, &error);
}
}
Task::none()
}
// ── Toast ──
Message::ShowToast(level, msg) => {
@@ -3232,6 +3312,7 @@ impl BdsApp {
self.tags_view_state.as_ref(),
self.settings_state.as_ref(),
self.dashboard_state.as_ref(),
&self.site_validation_state,
)
}
@@ -3330,20 +3411,7 @@ impl BdsApp {
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
MenuAction::ValidateSite => {
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
self.spawn_engine_task(
"tabBar.siteValidation",
|db_path, project_id, data_dir, _tm, _tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let report = engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)
.map_err(|e| e.to_string())?;
Ok(format!(
"missing={}, extra={}, stale={}",
report.missing_pages.len(),
report.extra_pages.len(),
report.stale_pages.len(),
))
},
)
self.start_site_validation()
}
MenuAction::UploadSite => {
if self.offline_mode {
@@ -3603,6 +3671,38 @@ impl BdsApp {
self.add_output(text);
}
fn start_site_validation(&mut self) -> Task<Message> {
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
let Some(_db) = self.db.as_ref() else {
self.site_validation_state.is_running = false;
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.generateSiteNoProject"));
return Task::none();
};
let Some(project_id) = self.active_project.as_ref().map(|project| project.id.clone()) else {
self.site_validation_state.is_running = false;
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.generateSiteNoProject"));
return Task::none();
};
let Some(data_dir) = self.data_dir.clone() else {
self.site_validation_state.is_running = false;
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.previewDataDirUnavailable"));
return Task::none();
};
self.site_validation_state.is_running = true;
self.site_validation_state.error_message = None;
let db_path = self.db_path.clone();
Task::perform(
async move {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)
.map_err(|error| error.to_string())
},
Message::SiteValidationLoaded,
)
}
/// Spawn a blocking engine operation on a background thread via TaskManager.
///
/// Returns `Task::none()` if no active project/db/data_dir.
@@ -6167,12 +6267,18 @@ impl BdsApp {
db: &Database,
state: &mut SettingsViewState,
) -> Result<(), String> {
let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?;
let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?;
ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?;
ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?;
ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?;
ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?;
if Self::endpoint_has_configuration(state, AiEndpointKind::Online) {
let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?;
ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?;
ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?;
state.online_api_key_input.clear();
state.online_api_key_configured = true;
}
if Self::endpoint_has_configuration(state, AiEndpointKind::Airplane) {
let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?;
ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?;
ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?;
}
ai::save_model_preferences(
db.conn(),
(!state.default_model.trim().is_empty()).then_some(state.default_model.as_str()),
@@ -6181,11 +6287,27 @@ impl BdsApp {
&state.system_prompt.text(),
)
.map_err(|error| error.to_string())?;
state.online_api_key_input.clear();
state.online_api_key_configured = true;
Ok(())
}
fn endpoint_has_configuration(
state: &SettingsViewState,
kind: AiEndpointKind,
) -> bool {
match kind {
AiEndpointKind::Online => {
!state.online_endpoint_url.trim().is_empty()
|| !state.online_endpoint_model.trim().is_empty()
|| !state.online_api_key_input.trim().is_empty()
|| state.online_api_key_configured
}
AiEndpointKind::Airplane => {
!state.airplane_endpoint_url.trim().is_empty()
|| !state.airplane_endpoint_model.trim().is_empty()
}
}
}
fn compose_ai_endpoint(
state: &SettingsViewState,
kind: AiEndpointKind,

View File

@@ -15,3 +15,4 @@ pub mod script_editor;
pub mod tags_view;
pub mod settings_view;
pub mod dashboard;
pub mod site_validation;

View File

@@ -0,0 +1,128 @@
use iced::widget::text::Shaping;
use iced::widget::{button, column, container, row, scrollable, text};
use iced::{Background, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::i18n::t;
#[derive(Debug, Clone, Default)]
pub struct SiteValidationState {
pub has_run: bool,
pub is_running: bool,
pub missing_files: Vec<String>,
pub extra_files: Vec<String>,
pub stale_files: Vec<String>,
pub error_message: Option<String>,
}
pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, Message> {
let run_button = if state.is_running {
button(text(t(locale, "siteValidation.running")).size(13).shaping(Shaping::Advanced))
} else {
button(text(t(locale, "siteValidation.run")).size(13).shaping(Shaping::Advanced))
.on_press(Message::RunSiteValidation)
};
let mut content = column![
row![
text(t(locale, "tabBar.siteValidation"))
.size(24)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.88, 0.88, 0.92)),
run_button,
]
.spacing(16),
]
.spacing(16);
if state.is_running {
content = content.push(help_text(t(locale, "siteValidation.running")));
} else if let Some(error) = &state.error_message {
content = content.push(section(
t(locale, "siteValidation.error"),
std::slice::from_ref(error),
Color::from_rgb(0.75, 0.28, 0.28),
));
} else if !state.has_run {
content = content.push(help_text(t(locale, "siteValidation.idle")));
} else if state.missing_files.is_empty() && state.extra_files.is_empty() && state.stale_files.is_empty() {
content = content.push(help_text(t(locale, "siteValidation.clean")));
} else {
if !state.missing_files.is_empty() {
content = content.push(section(
format!("{} ({})", t(locale, "siteValidation.missing"), state.missing_files.len()),
&state.missing_files,
Color::from_rgb(0.70, 0.25, 0.25),
));
}
if !state.extra_files.is_empty() {
content = content.push(section(
format!("{} ({})", t(locale, "siteValidation.extra"), state.extra_files.len()),
&state.extra_files,
Color::from_rgb(0.68, 0.48, 0.18),
));
}
if !state.stale_files.is_empty() {
content = content.push(section(
format!("{} ({})", t(locale, "siteValidation.stale"), state.stale_files.len()),
&state.stale_files,
Color::from_rgb(0.62, 0.32, 0.18),
));
}
}
container(scrollable(content))
.width(Length::Fill)
.height(Length::Fill)
.padding(24)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
..container::Style::default()
})
.into()
}
fn help_text<'a>(value: String) -> Element<'a, Message> {
container(
text(value)
.size(14)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.66, 0.66, 0.72)),
)
.padding(16)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))),
..container::Style::default()
})
.into()
}
fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<'a, Message> {
let items = entries.iter().fold(column!().spacing(6), |column, entry| {
column.push(
text(entry)
.size(13)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.82, 0.82, 0.88)),
)
});
container(
column![
text(title.to_string())
.size(16)
.shaping(Shaping::Advanced)
.color(accent),
items,
]
.spacing(10),
)
.padding(16)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))),
..container::Style::default()
})
.into()
}

View File

@@ -20,6 +20,7 @@ use crate::views::{
modal, panel,
post_editor::{self, PostEditorState},
project_selector,
site_validation::{self, SiteValidationState},
script_editor::{self, ScriptEditorState},
settings_view::{self, SettingsViewState},
sidebar, status_bar, tab_bar,
@@ -110,6 +111,7 @@ pub fn view<'a>(
tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
) -> Element<'a, Message> {
// Activity bar (leftmost column)
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
@@ -131,6 +133,7 @@ pub fn view<'a>(
tags_view_state,
settings_state,
dashboard_state,
site_validation_state,
);
// Right column: tab bar + content + panel
@@ -353,6 +356,7 @@ fn route_content_area<'a>(
tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
) -> Element<'a, Message> {
match route_kind(
tabs,
@@ -364,6 +368,7 @@ fn route_content_area<'a>(
tags_view_state,
settings_state,
dashboard_state,
site_validation_state,
) {
ContentRoute::Dashboard(state) => crate::views::dashboard::view(state, locale),
ContentRoute::Welcome => welcome::view(locale),
@@ -411,6 +416,7 @@ fn route_content_area<'a>(
loading_view(locale)
}
}
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
ContentRoute::Placeholder(title) => welcome::tab_placeholder(title, None),
}
}
@@ -426,6 +432,7 @@ enum ContentRoute<'a> {
Scripts(&'a str),
Tags,
Settings,
SiteValidation,
Placeholder(&'a str),
}
@@ -439,6 +446,7 @@ fn route_kind<'a>(
tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>,
_site_validation_state: &'a SiteValidationState,
) -> ContentRoute<'a> {
let Some(tab_id) = active_tab else {
return dashboard_state.map(ContentRoute::Dashboard).unwrap_or(ContentRoute::Welcome);
@@ -466,6 +474,7 @@ fn route_kind<'a>(
TabType::Settings => {
if settings_state.is_some() { ContentRoute::Settings } else { ContentRoute::Loading }
}
TabType::SiteValidation => ContentRoute::SiteValidation,
TabType::Style
| TabType::Chat
| TabType::Import
@@ -474,7 +483,6 @@ fn route_kind<'a>(
| TabType::GitDiff
| TabType::Documentation
| TabType::ApiDocumentation
| TabType::SiteValidation
| TabType::TranslationValidation
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
}
@@ -501,6 +509,7 @@ mod tests {
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let site_validation_state = SiteValidationState::default();
let unsupported = [
TabType::Style,
TabType::Chat,
@@ -510,7 +519,6 @@ mod tests {
TabType::GitDiff,
TabType::Documentation,
TabType::ApiDocumentation,
TabType::SiteValidation,
TabType::TranslationValidation,
TabType::FindDuplicates,
];
@@ -527,6 +535,7 @@ mod tests {
None,
None,
None,
&site_validation_state,
);
match route {
ContentRoute::Placeholder(title) => assert_eq!(title, "Tool"),
@@ -542,6 +551,7 @@ mod tests {
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let site_validation_state = SiteValidationState::default();
let route = route_kind(
&[],
None,
@@ -552,12 +562,41 @@ mod tests {
None,
None,
Some(&dashboard),
&site_validation_state,
);
match route {
ContentRoute::Dashboard(state) => assert_eq!(state.subtitle, "Test Project"),
_ => panic!("expected dashboard route"),
}
}
#[test]
fn site_validation_tab_routes_to_real_view() {
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let site_validation_state = SiteValidationState::default();
let tabs = vec![tab("site_validation", TabType::SiteValidation, "Validation")];
let route = route_kind(
&tabs,
Some("site_validation"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
None,
None,
None,
&site_validation_state,
);
match route {
ContentRoute::SiteValidation => {}
_ => panic!("expected site validation route"),
}
}
}
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {

View File

@@ -93,6 +93,14 @@
"tabBar.metadataDiff": "Metadaten-Diff",
"tabBar.siteValidation": "Website-Validierung",
"tabBar.translationValidation": "Übersetzungsvalidierung",
"siteValidation.run": "Validierung starten",
"siteValidation.idle": "Starte die Website-Validierung, um generierte Dateien mit dem erwarteten Output zu vergleichen.",
"siteValidation.running": "Validierung läuft...",
"siteValidation.clean": "Es wurden keine fehlenden, zusätzlichen oder veralteten generierten Dateien gefunden.",
"siteValidation.error": "Validierung fehlgeschlagen",
"siteValidation.missing": "Fehlende Dateien",
"siteValidation.extra": "Zusätzliche Dateien",
"siteValidation.stale": "Veraltete Dateien",
"tabBar.documentation": "Dokumentation",
"tabBar.apiDocumentation": "API-Dokumentation",
"tabBar.findDuplicates": "Duplikate finden",

View File

@@ -93,6 +93,14 @@
"tabBar.metadataDiff": "Metadata Diff",
"tabBar.siteValidation": "Site Validation",
"tabBar.translationValidation": "Translation Validation",
"siteValidation.run": "Run Validation",
"siteValidation.idle": "Run site validation to compare generated files against expected output.",
"siteValidation.running": "Validation in progress...",
"siteValidation.clean": "No missing, extra, or stale generated files were found.",
"siteValidation.error": "Validation failed",
"siteValidation.missing": "Missing Files",
"siteValidation.extra": "Extra Files",
"siteValidation.stale": "Stale Files",
"tabBar.documentation": "Documentation",
"tabBar.apiDocumentation": "API Documentation",
"tabBar.findDuplicates": "Find Duplicates",

View File

@@ -93,6 +93,14 @@
"tabBar.metadataDiff": "Diff de metadatos",
"tabBar.siteValidation": "Validación del sitio",
"tabBar.translationValidation": "Validación de traducciones",
"siteValidation.run": "Ejecutar validación",
"siteValidation.idle": "Ejecuta la validación del sitio para comparar los archivos generados con la salida esperada.",
"siteValidation.running": "Validación en curso...",
"siteValidation.clean": "No se encontraron archivos generados faltantes, extra o desactualizados.",
"siteValidation.error": "La validación falló",
"siteValidation.missing": "Archivos faltantes",
"siteValidation.extra": "Archivos adicionales",
"siteValidation.stale": "Archivos desactualizados",
"tabBar.documentation": "Documentación",
"tabBar.apiDocumentation": "Documentación API",
"tabBar.findDuplicates": "Buscar duplicados",

View File

@@ -93,6 +93,14 @@
"tabBar.metadataDiff": "Diff métadonnées",
"tabBar.siteValidation": "Validation du site",
"tabBar.translationValidation": "Validation des traductions",
"siteValidation.run": "Lancer la validation",
"siteValidation.idle": "Lancez la validation du site pour comparer les fichiers générés à la sortie attendue.",
"siteValidation.running": "Validation en cours...",
"siteValidation.clean": "Aucun fichier généré manquant, supplémentaire ou obsolète n'a été trouvé.",
"siteValidation.error": "La validation a échoué",
"siteValidation.missing": "Fichiers manquants",
"siteValidation.extra": "Fichiers supplémentaires",
"siteValidation.stale": "Fichiers obsolètes",
"tabBar.documentation": "Documentation",
"tabBar.apiDocumentation": "Documentation API",
"tabBar.findDuplicates": "Trouver les doublons",

View File

@@ -93,6 +93,14 @@
"tabBar.metadataDiff": "Diff metadati",
"tabBar.siteValidation": "Validazione sito",
"tabBar.translationValidation": "Validazione traduzioni",
"siteValidation.run": "Esegui validazione",
"siteValidation.idle": "Esegui la validazione del sito per confrontare i file generati con l'output previsto.",
"siteValidation.running": "Validazione in corso...",
"siteValidation.clean": "Non sono stati trovati file generati mancanti, aggiuntivi o obsoleti.",
"siteValidation.error": "Validazione non riuscita",
"siteValidation.missing": "File mancanti",
"siteValidation.extra": "File aggiuntivi",
"siteValidation.stale": "File obsoleti",
"tabBar.documentation": "Documentazione",
"tabBar.apiDocumentation": "Documentazione API",
"tabBar.findDuplicates": "Trova duplicati",