diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs index 76b0907..b0d928e 100644 --- a/crates/bds-core/src/engine/preview.rs +++ b/crates/bds-core/src/engine/preview.rs @@ -59,9 +59,11 @@ struct PreviewServerState { #[derive(Debug, Deserialize, Default)] struct DraftPreviewQuery { language: Option, + theme: Option, + mode: Option, } -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Clone, Deserialize, Default)] struct StylePreviewQuery { theme: Option, mode: Option, @@ -115,9 +117,10 @@ pub fn start_preview_server( async fn handle_preview_request( State(state): State, + Query(query): Query, 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, Query(query): Query, ) -> 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 { 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("') 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('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") } 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\"")); + } } \ No newline at end of file diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index 9fed07b..b77a8b0 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -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, bundle: &TemplateBundle, ) -> Result> { @@ -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::::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, media_by_id: &HashMap, canonical_post_path_by_slug: &HashMap, + menu_items: &[Value], post_data_json_by_id: &HashMap, bundle: &TemplateBundle, ) -> Result> { @@ -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::::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> { 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::::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::::new(), "canonical_media_path_by_source_path": HashMap::::new(), "post_data_json_by_id": HashMap::::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, Box> { + 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::>(); + 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 { diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 7208438..05c97fd 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -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 }, + SiteValidationLoaded(Result), // 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, settings_state: Option, dashboard_state: Option, + 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 { + 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, diff --git a/crates/bds-ui/src/views/mod.rs b/crates/bds-ui/src/views/mod.rs index e2349d5..983dcb8 100644 --- a/crates/bds-ui/src/views/mod.rs +++ b/crates/bds-ui/src/views/mod.rs @@ -15,3 +15,4 @@ pub mod script_editor; pub mod tags_view; pub mod settings_view; pub mod dashboard; +pub mod site_validation; diff --git a/crates/bds-ui/src/views/site_validation.rs b/crates/bds-ui/src/views/site_validation.rs new file mode 100644 index 0000000..72e584f --- /dev/null +++ b/crates/bds-ui/src/views/site_validation.rs @@ -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, + pub extra_files: Vec, + pub stale_files: Vec, + pub error_message: Option, +} + +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() +} \ No newline at end of file diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 7e04b76..6088ab1 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -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> { diff --git a/locales/ui/de.json b/locales/ui/de.json index 2e4b401..2854b6a 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -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", diff --git a/locales/ui/en.json b/locales/ui/en.json index 0040b06..77b2c91 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -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", diff --git a/locales/ui/es.json b/locales/ui/es.json index 922c81c..ea7bcff 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -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", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index ce78548..e677116 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -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", diff --git a/locales/ui/it.json b/locales/ui/it.json index 7e95922..6d67702 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -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",