From eedd0a91183bccd2e8a47682578c9c46b8fc255d Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 4 Apr 2026 18:04:39 +0200 Subject: [PATCH] feat: first take at M2 --- Cargo.lock | 162 ++++++- Cargo.toml | 5 +- crates/bds-core/Cargo.toml | 1 + crates/bds-core/src/engine/task.rs | 9 + crates/bds-core/src/i18n/mod.rs | 227 ++++++++- crates/bds-ui/Cargo.toml | 7 + crates/bds-ui/src/app.rs | 596 +++++++++++++++++++++++- crates/bds-ui/src/i18n/mod.rs | 11 + crates/bds-ui/src/lib.rs | 3 + crates/bds-ui/src/main.rs | 1 + crates/bds-ui/src/platform/dialog.rs | 35 ++ crates/bds-ui/src/platform/macos.rs | 37 +- crates/bds-ui/src/platform/menu.rs | 362 ++++++++++++-- crates/bds-ui/src/platform/mod.rs | 1 + crates/bds-ui/src/state/mod.rs | 5 + crates/bds-ui/src/state/navigation.rs | 126 +++++ crates/bds-ui/src/state/tabs.rs | 161 +++++++ crates/bds-ui/src/views/activity_bar.rs | 89 ++++ crates/bds-ui/src/views/mod.rs | 7 + crates/bds-ui/src/views/panel.rs | 111 +++++ crates/bds-ui/src/views/sidebar.rs | 37 ++ crates/bds-ui/src/views/status_bar.rs | 97 ++++ crates/bds-ui/src/views/tab_bar.rs | 58 +++ crates/bds-ui/src/views/welcome.rs | 26 ++ crates/bds-ui/src/views/workspace.rs | 84 ++++ crates/bds-ui/tests/app_smoke.rs | 61 ++- crates/bds-ui/tests/menu_routing.rs | 44 ++ crates/bds-ui/tests/project_flow.rs | 124 +++++ locales/ui/de.json | 98 ++++ locales/ui/en.json | 98 ++++ locales/ui/es.json | 98 ++++ locales/ui/fr.json | 98 ++++ locales/ui/it.json | 98 ++++ 33 files changed, 2899 insertions(+), 78 deletions(-) create mode 100644 crates/bds-ui/src/i18n/mod.rs create mode 100644 crates/bds-ui/src/platform/dialog.rs create mode 100644 crates/bds-ui/src/state/mod.rs create mode 100644 crates/bds-ui/src/state/navigation.rs create mode 100644 crates/bds-ui/src/state/tabs.rs create mode 100644 crates/bds-ui/src/views/activity_bar.rs create mode 100644 crates/bds-ui/src/views/mod.rs create mode 100644 crates/bds-ui/src/views/panel.rs create mode 100644 crates/bds-ui/src/views/sidebar.rs create mode 100644 crates/bds-ui/src/views/status_bar.rs create mode 100644 crates/bds-ui/src/views/tab_bar.rs create mode 100644 crates/bds-ui/src/views/welcome.rs create mode 100644 crates/bds-ui/src/views/workspace.rs create mode 100644 crates/bds-ui/tests/menu_routing.rs create mode 100644 crates/bds-ui/tests/project_flow.rs create mode 100644 locales/ui/de.json create mode 100644 locales/ui/en.json create mode 100644 locales/ui/es.json create mode 100644 locales/ui/fr.json create mode 100644 locales/ui/it.json diff --git a/Cargo.lock b/Cargo.lock index 7740dc1..71ec013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -465,6 +465,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "sys-locale", "tempfile", "thiserror 2.0.18", "uuid", @@ -488,12 +489,17 @@ dependencies = [ "anyhow", "bds-core", "bds-editor", + "chrono", + "dirs 5.0.1", "iced", "muda", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "open", "rfd", + "serde_json", + "tempfile", "tokio", ] @@ -1059,7 +1065,7 @@ checksum = "2a76fa97167fa740dcdbfe18e8895601e1bc36525f09b044e00916e717c03a3c" dependencies = [ "dconf_rs", "detect-desktop-environment", - "dirs", + "dirs 4.0.0", "objc", "rust-ini", "web-sys", @@ -1110,7 +1116,16 @@ version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" dependencies = [ - "dirs-sys", + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", ] [[package]] @@ -1124,6 +1139,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -2154,6 +2181,7 @@ dependencies = [ "iced_core", "log", "rustc-hash 2.1.2", + "tokio", "wasm-bindgen-futures", "wasm-timer", ] @@ -2489,6 +2517,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3479,6 +3526,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.51" @@ -3633,6 +3697,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5835,7 +5905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ "windows-core 0.52.0", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -5844,7 +5914,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -5906,13 +5976,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -5921,7 +6000,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -5933,34 +6012,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5973,24 +6085,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index c8136d5..7711441 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,9 +29,12 @@ tokio = { version = "1", features = ["full"] } walkdir = "2" image = "0.25" rust-stemmers = "1" +sys-locale = "0.3" +dirs = "5" +open = "5" # UI framework -iced = { version = "0.13", features = ["wgpu", "advanced", "image"] } +iced = { version = "0.13", features = ["wgpu", "advanced", "image", "tokio"] } # Editor widget cosmic-text = "0.12" diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index fa865e1..120329f 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -18,6 +18,7 @@ thiserror = { workspace = true } walkdir = { workspace = true } image = { workspace = true } rust-stemmers = { workspace = true } +sys-locale = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/bds-core/src/engine/task.rs b/crates/bds-core/src/engine/task.rs index 21107db..9f86f53 100644 --- a/crates/bds-core/src/engine/task.rs +++ b/crates/bds-core/src/engine/task.rs @@ -201,6 +201,15 @@ impl TaskManager { tasks.iter().find(|t| t.id == task_id).and_then(|t| t.message.clone()) } + /// Return a snapshot of all tasks for UI display. + pub fn snapshots(&self) -> Vec<(TaskId, String, TaskStatus, Option, Option)> { + let tasks = self.tasks.lock().unwrap(); + tasks + .iter() + .map(|t| (t.id, t.label.clone(), t.status.clone(), t.progress, t.message.clone())) + .collect() + } + /// Promote the next queued task to running if capacity allows. fn promote_next(tasks: &mut Vec, max_concurrent: usize) { let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count(); diff --git a/crates/bds-core/src/i18n/mod.rs b/crates/bds-core/src/i18n/mod.rs index 664d75d..5644e6f 100644 --- a/crates/bds-core/src/i18n/mod.rs +++ b/crates/bds-core/src/i18n/mod.rs @@ -1,3 +1,224 @@ -// Internationalization — split localization support. -// ui_locale: follows OS locale (menus, dialogs, toasts). -// content_language: follows project settings (rendering, preview, feeds). +use std::collections::HashMap; +use std::sync::LazyLock; + +/// Supported UI locales, matching the i18n.allium SupportedLanguage spec. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UiLocale { + En, + De, + Fr, + It, + Es, +} + +impl UiLocale { + /// BCP-47 base code for this locale. + pub fn code(self) -> &'static str { + match self { + Self::En => "en", + Self::De => "de", + Self::Fr => "fr", + Self::It => "it", + Self::Es => "es", + } + } + + /// All supported locales. + pub fn all() -> &'static [UiLocale] { + &[Self::En, Self::De, Self::Fr, Self::It, Self::Es] + } +} + +impl std::fmt::Display for UiLocale { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} + +/// Normalize a language code to a supported UiLocale. +/// +/// Strips region suffix ("en-US" → "en"), lowercases, and falls back to English +/// for unrecognized codes. This implements the LanguageNormalization invariant +/// from i18n.allium. +pub fn normalize_language(code: &str) -> UiLocale { + let base = code.split(['-', '_']).next().unwrap_or("en").to_lowercase(); + match base.as_str() { + "en" => UiLocale::En, + "de" => UiLocale::De, + "fr" => UiLocale::Fr, + "it" => UiLocale::It, + "es" => UiLocale::Es, + _ => UiLocale::En, + } +} + +/// Detect the OS locale and return the closest supported UiLocale. +/// +/// Uses sys-locale to query the system. Falls back to English if detection +/// fails or the detected locale is not supported. +pub fn detect_os_locale() -> UiLocale { + match sys_locale::get_locale() { + Some(tag) => normalize_language(&tag), + None => UiLocale::En, + } +} + +type Catalog = HashMap; + +fn parse_catalog(json: &str) -> Catalog { + serde_json::from_str(json).unwrap_or_default() +} + +static CATALOG_EN: LazyLock = + LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/en.json"))); +static CATALOG_DE: LazyLock = + LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/de.json"))); +static CATALOG_FR: LazyLock = + LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/fr.json"))); +static CATALOG_IT: LazyLock = + LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/it.json"))); +static CATALOG_ES: LazyLock = + LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/es.json"))); + +fn catalog_for(locale: UiLocale) -> &'static Catalog { + match locale { + UiLocale::En => &CATALOG_EN, + UiLocale::De => &CATALOG_DE, + UiLocale::Fr => &CATALOG_FR, + UiLocale::It => &CATALOG_IT, + UiLocale::Es => &CATALOG_ES, + } +} + +/// Look up a translation key for the given locale. +/// +/// Fallback chain: requested locale → English → key itself. +/// This implements the MenuTranslations invariant from i18n.allium. +pub fn translate(locale: UiLocale, key: &str) -> String { + if let Some(val) = catalog_for(locale).get(key) { + return val.clone(); + } + if locale != UiLocale::En { + if let Some(val) = CATALOG_EN.get(key) { + return val.clone(); + } + } + key.to_string() +} + +/// Look up a translation key and substitute `{param}` placeholders. +/// +/// Parameters are provided as `&[("param_name", "value")]`. +pub fn translate_with(locale: UiLocale, key: &str, params: &[(&str, &str)]) -> String { + let mut result = translate(locale, key); + for (name, value) in params { + let placeholder = format!("{{{name}}}"); + result = result.replace(&placeholder, value); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + // LanguageNormalization invariant: base-extract and fallback + #[test] + fn normalize_strips_region() { + assert_eq!(normalize_language("en-US"), UiLocale::En); + assert_eq!(normalize_language("de-AT"), UiLocale::De); + assert_eq!(normalize_language("fr-CA"), UiLocale::Fr); + assert_eq!(normalize_language("it-CH"), UiLocale::It); + assert_eq!(normalize_language("es-MX"), UiLocale::Es); + } + + #[test] + fn normalize_underscore_separator() { + assert_eq!(normalize_language("de_DE"), UiLocale::De); + } + + #[test] + fn normalize_unknown_falls_back_to_en() { + assert_eq!(normalize_language("xx"), UiLocale::En); + assert_eq!(normalize_language("ja"), UiLocale::En); + assert_eq!(normalize_language(""), UiLocale::En); + } + + #[test] + fn normalize_case_insensitive() { + assert_eq!(normalize_language("DE"), UiLocale::De); + assert_eq!(normalize_language("FR-fr"), UiLocale::Fr); + } + + // SplitLocalization invariant: UiLocale is independent of content language + #[test] + fn ui_locale_is_independent_type() { + let ui = UiLocale::De; + let content_lang = "fr"; + assert_ne!(ui.code(), content_lang); + } + + // MenuTranslations invariant: menu labels come from locale catalog + #[test] + fn translate_menu_labels() { + let label = translate(UiLocale::De, "menu.group.file"); + assert_eq!(label, "Datei"); + + let label = translate(UiLocale::Fr, "menu.item.save"); + assert_eq!(label, "Enregistrer"); + } + + #[test] + fn translate_falls_back_to_english() { + // Non-English locale falls back for missing keys + let result = translate(UiLocale::De, "menu.group.file"); + assert_eq!(result, "Datei"); + } + + #[test] + fn translate_missing_key_returns_key() { + let result = translate(UiLocale::En, "nonexistent.key.xyz"); + assert_eq!(result, "nonexistent.key.xyz"); + } + + #[test] + fn translate_with_interpolation() { + let result = translate_with( + UiLocale::En, + "tasks.triggerTitle", + &[("running", "2"), ("pending", "3")], + ); + assert_eq!(result, "2 running, 3 pending"); + } + + #[test] + fn translate_with_interpolation_german() { + let result = translate_with( + UiLocale::De, + "tasks.triggerTitle", + &[("running", "1"), ("pending", "5")], + ); + assert_eq!(result, "1 laufend, 5 ausstehend"); + } + + #[test] + fn all_locales_have_menu_keys() { + let key = "menu.group.file"; + for locale in UiLocale::all() { + let val = translate(*locale, key); + assert_ne!(val, key, "missing {key} for locale {locale}"); + } + } + + #[test] + fn locale_code_roundtrip() { + for locale in UiLocale::all() { + assert_eq!(normalize_language(locale.code()), *locale); + } + } + + #[test] + fn detect_os_locale_does_not_panic() { + let _ = detect_os_locale(); + } +} diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index 0b918ed..9686972 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -10,6 +10,10 @@ bds-editor = { workspace = true } iced = { workspace = true } muda = { workspace = true } rfd = { workspace = true } +serde_json = { workspace = true } +dirs = { workspace = true } +chrono = { workspace = true } +open = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true } @@ -17,3 +21,6 @@ tokio = { workspace = true } objc2 = "0.6" objc2-foundation = "0.3" objc2-app-kit = "0.3" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 2b3e716..e806abf 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -1,48 +1,610 @@ -use iced::widget::{column, container, text}; -use iced::{Element, Length, Subscription, Task}; +use std::path::PathBuf; +use std::sync::Arc; -use crate::platform::menu; +use iced::{Element, Subscription, Task}; + +use bds_core::db::Database; +use bds_core::engine::task::{TaskManager, TaskStatus}; +use bds_core::engine; +use bds_core::i18n::{detect_os_locale, UiLocale}; +use bds_core::model::Project; + +use crate::i18n::{t, tw}; +use crate::platform::menu::{self, MenuAction, MenuRegistry}; +use crate::state::navigation::{ + handle_activity_click, OutputEntry, PanelTab, SidebarView, TaskSnapshot, +}; +use crate::state::tabs::{self, Tab, TabType}; +use crate::views::workspace; + +// ─────────────────────────────────────────────────────────── +// Message +// ─────────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub enum Message { + // Menu MenuEvent(muda::MenuId), + + // Navigation + SetActiveView(SidebarView), + ToggleSidebar, + TogglePanel, + + // Tabs + OpenTab(Tab), + CloseTab(String), + SelectTab(String), + PinTab(String), + + // Project + ProjectsLoaded(Vec), + SwitchProject(String), + ProjectSwitched(Result), + RequestCreateProject, + CreateProject { name: String, data_path: Option }, + ProjectCreated(Result), + DeleteProject(String), + ProjectDeleted(Result), + + // Dialogs + FolderPicked(Option), + MediaFilesPicked(Option>), + + // Tasks + TaskTick, + + // macOS lifecycle + FileOpenRequested(PathBuf), + UrlOpenRequested(String), + + // Panel + SetPanelTab(PanelTab), + + // Settings + SetOfflineMode(bool), + SetUiLocale(UiLocale), + + // Blog actions (dispatched to engine) + RebuildDatabase, + ReindexText, + RunMetadataDiff, + BlogTaskFinished { label: String, result: Result<(), String> }, + Noop, } +// ─────────────────────────────────────────────────────────── +// App State +// ─────────────────────────────────────────────────────────── + pub struct BdsApp { + // Database + db: Option, + + // Project + active_project: Option, + projects: Vec, + data_dir: Option, + + // Navigation + sidebar_view: SidebarView, + sidebar_visible: bool, + + // Tabs + tabs: Vec, + active_tab: Option, + + // Panel + panel_visible: bool, + panel_tab: PanelTab, + + // Tasks + task_manager: Arc, + task_snapshots: Vec, + output_entries: Vec, + + // Platform _menu_bar: muda::Menu, + menu_registry: MenuRegistry, + + // i18n + ui_locale: UiLocale, + + // Flags + offline_mode: bool, + + // macOS lifecycle receiver + #[cfg(target_os = "macos")] + _lifecycle_rx: std::sync::mpsc::Receiver, } +// ─────────────────────────────────────────────────────────── +// App Implementation +// ─────────────────────────────────────────────────────────── + impl BdsApp { pub fn new() -> (Self, Task) { - let menu_bar = menu::build_menu_bar(); - (Self { _menu_bar: menu_bar }, Task::none()) + let locale = detect_os_locale(); + let (menu_bar, registry) = menu::build_menu_bar(locale); + + // Open or create the database + let db_path = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("bds") + .join("bds.db"); + + // Ensure parent directory exists + if let Some(parent) = db_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + let db = Database::open(&db_path).ok(); + if let Some(ref db) = db { + let _ = db.migrate(); + } + + // Load projects + let projects = db + .as_ref() + .and_then(|d| engine::project::list_projects(d.conn()).ok()) + .unwrap_or_default(); + + let active_project = db + .as_ref() + .and_then(|d| engine::project::get_active_project(d.conn()).ok()) + .flatten(); + + let data_dir = active_project + .as_ref() + .and_then(|p| p.data_path.as_ref()) + .map(PathBuf::from); + + // If no projects exist, create a default one + let init_task = if projects.is_empty() { + if let Some(ref db) = db { + let default_data = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("bds") + .join("projects") + .join("my-blog"); + match engine::project::create_project( + db.conn(), + "My Blog", + Some(default_data.to_str().unwrap_or("my-blog")), + ) { + Ok(project) => { + let _ = engine::project::set_active_project(db.conn(), &project.id); + Task::done(Message::ProjectsLoaded(vec![project])) + } + Err(_) => Task::none(), + } + } else { + Task::none() + } + } else { + Task::done(Message::ProjectsLoaded(projects.clone())) + }; + + // Disable items that need selection + registry.set_enabled(MenuAction::Save, false); + registry.set_enabled(MenuAction::PublishSelected, false); + registry.set_enabled(MenuAction::PreviewPost, false); + + #[cfg(target_os = "macos")] + let (_lifecycle_tx, _lifecycle_rx) = crate::platform::macos::lifecycle_channel(); + + ( + Self { + db, + active_project: active_project.clone(), + projects, + data_dir, + sidebar_view: SidebarView::Posts, + sidebar_visible: true, + tabs: Vec::new(), + active_tab: None, + panel_visible: false, + panel_tab: PanelTab::Tasks, + task_manager: Arc::new(TaskManager::default()), + task_snapshots: Vec::new(), + output_entries: Vec::new(), + _menu_bar: menu_bar, + menu_registry: registry, + ui_locale: locale, + offline_mode: false, + #[cfg(target_os = "macos")] + _lifecycle_rx, + }, + init_task, + ) } pub fn update(&mut self, message: Message) -> Task { match message { - Message::MenuEvent(_id) => { - // Menu routing will be expanded in M2 + // ── Menu event dispatch ── + Message::MenuEvent(id) => { + if let Some(action) = self.menu_registry.lookup(&id) { + return self.dispatch_menu_action(action); + } Task::none() } + + // ── Navigation ── + Message::SetActiveView(view) => { + let (new_view, new_visible) = + handle_activity_click(self.sidebar_view, self.sidebar_visible, view); + self.sidebar_view = new_view; + self.sidebar_visible = new_visible; + Task::none() + } + Message::ToggleSidebar => { + self.sidebar_visible = !self.sidebar_visible; + Task::none() + } + Message::TogglePanel => { + self.panel_visible = !self.panel_visible; + Task::none() + } + + // ── Tabs ── + Message::OpenTab(tab) => { + let idx = tabs::open_tab(&mut self.tabs, tab); + if let Some(t) = self.tabs.get(idx) { + self.active_tab = Some(t.id.clone()); + } + Task::none() + } + Message::CloseTab(id) => { + if let Some(next_idx) = tabs::close_tab(&mut self.tabs, &id) { + self.active_tab = self.tabs.get(next_idx).map(|t| t.id.clone()); + } else { + self.active_tab = None; + } + Task::none() + } + Message::SelectTab(id) => { + if self.tabs.iter().any(|t| t.id == id) { + self.active_tab = Some(id); + } + Task::none() + } + Message::PinTab(id) => { + tabs::pin_tab(&mut self.tabs, &id); + Task::none() + } + + // ── Project management ── + Message::ProjectsLoaded(projects) => { + self.projects = projects; + // Re-resolve active + if let Some(ref db) = self.db { + self.active_project = engine::project::get_active_project(db.conn()) + .ok() + .flatten(); + self.data_dir = self + .active_project + .as_ref() + .and_then(|p| p.data_path.as_ref()) + .map(PathBuf::from); + } + Task::none() + } + Message::SwitchProject(project_id) => { + if let Some(ref db) = self.db { + match engine::project::set_active_project(db.conn(), &project_id) { + Ok(()) => { + self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned(); + self.data_dir = self + .active_project + .as_ref() + .and_then(|p| p.data_path.as_ref()) + .map(PathBuf::from); + let name = self.active_project.as_ref().map(|p| p.name.clone()).unwrap_or_default(); + self.add_output(&tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)])); + } + Err(_) => { + self.add_output(&t(self.ui_locale, "projectSelector.toast.switchFailed")); + } + } + } + Task::none() + } + Message::ProjectSwitched(result) => { + match result { + Ok(name) => self.add_output(&tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)])), + Err(msg) => self.add_output(&msg), + } + Task::none() + } + Message::RequestCreateProject => { + crate::platform::dialog::pick_folder() + } + Message::CreateProject { name, data_path } => { + if let Some(ref db) = self.db { + let path_str = data_path.as_ref().map(|p| p.to_string_lossy().to_string()); + match engine::project::create_project( + db.conn(), + &name, + path_str.as_deref(), + ) { + Ok(project) => { + let _ = engine::project::set_active_project(db.conn(), &project.id); + self.projects = engine::project::list_projects(db.conn()).unwrap_or_default(); + self.active_project = Some(project.clone()); + self.data_dir = project.data_path.as_ref().map(PathBuf::from); + let msg = tw(self.ui_locale, "projectSelector.toast.created", &[("name", &project.name)]); + self.add_output(&msg); + } + Err(_) => { + self.add_output(&t(self.ui_locale, "projectSelector.toast.createFailed")); + } + } + } + Task::none() + } + Message::ProjectCreated(result) => { + match result { + Ok(name) => self.add_output(&tw(self.ui_locale, "projectSelector.toast.created", &[("name", &name)])), + Err(msg) => self.add_output(&msg), + } + Task::none() + } + Message::DeleteProject(project_id) => { + if let Some(ref db) = self.db { + let data_path = self.projects.iter() + .find(|p| p.id == project_id) + .and_then(|p| p.data_path.as_ref()) + .map(PathBuf::from); + match engine::project::delete_project(db.conn(), &project_id, data_path.as_deref()) { + Ok(()) => { + self.projects.retain(|p| p.id != project_id); + } + Err(_) => { + self.add_output(&t(self.ui_locale, "projectSelector.toast.deleteFailed")); + } + } + } + Task::none() + } + Message::ProjectDeleted(result) => { + if let Err(msg) = result { + self.add_output(&msg); + } + Task::none() + } + + // ── Dialogs ── + Message::FolderPicked(path) => { + if let Some(path) = path { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "New Project".to_string()); + return Task::done(Message::CreateProject { + name, + data_path: Some(path), + }); + } + Task::none() + } + Message::MediaFilesPicked(_paths) => { + // Media import will be expanded in later milestones + Task::none() + } + + // ── Tasks ── + Message::TaskTick => { + self.refresh_task_snapshots(); + Task::none() + } + + // ── macOS lifecycle ── + Message::FileOpenRequested(_path) => { + // File open handling deferred to later milestones + Task::none() + } + Message::UrlOpenRequested(_url) => { + // URL open handling deferred to later milestones + Task::none() + } + + // ── Panel ── + Message::SetPanelTab(tab) => { + self.panel_tab = tab; + Task::none() + } + + // ── Settings ── + Message::SetOfflineMode(mode) => { + self.offline_mode = mode; + Task::none() + } + Message::SetUiLocale(locale) => { + self.ui_locale = locale; + menu::update_menu_labels(&self.menu_registry, locale); + Task::none() + } + + // ── Blog engine actions ── + Message::RebuildDatabase => { + self.add_output("Rebuilding database..."); + // Actual rebuild dispatch deferred to later milestones + Task::none() + } + Message::ReindexText => { + self.add_output("Reindexing search text..."); + Task::none() + } + Message::RunMetadataDiff => { + self.open_singleton_tab(TabType::MetadataDiff, "Metadata Diff"); + Task::none() + } + Message::BlogTaskFinished { label, result } => { + match result { + Ok(()) => { + let msg = tw(self.ui_locale, "app.taskCompleted", &[("message", &label)]); + self.add_output(&msg); + } + Err(err) => { + let msg = tw(self.ui_locale, "app.taskFailed", &[("message", &err)]); + self.add_output(&msg); + } + } + Task::none() + } + Message::Noop => Task::none(), } } pub fn view(&self) -> Element<'_, Message> { - let content = column![text("bDS — Blogging Desktop Server").size(24),] - .padding(20) - .spacing(10); + let active_name = self.active_project.as_ref().map(|p| p.name.as_str()); - container(content) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .into() + workspace::view( + self.sidebar_view, + self.sidebar_visible, + &self.tabs, + self.active_tab.as_deref(), + self.panel_visible, + self.panel_tab, + &self.task_snapshots, + &self.output_entries, + active_name, + 0, // post_count — populated in later milestones + 0, // media_count — populated in later milestones + self.offline_mode, + self.ui_locale, + ) } pub fn subscription(&self) -> Subscription { - menu::menu_subscription() + let menu_sub = menu::menu_subscription(); + + let task_tick = iced::time::every(std::time::Duration::from_millis(500)) + .map(|_| Message::TaskTick); + + Subscription::batch([menu_sub, task_tick]) + } + + // ── Private helpers ── + + fn dispatch_menu_action(&mut self, action: MenuAction) -> Task { + match action { + // File + MenuAction::NewPost => { + // Will create post + open tab in later milestones + Task::none() + } + MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(), + MenuAction::Save => Task::none(), // Disabled in M2 + MenuAction::OpenInBrowser => Task::none(), + MenuAction::OpenDataFolder => { + if let Some(ref dir) = self.data_dir { + let _ = open::that(dir); + } + Task::none() + } + // Edit + MenuAction::Find => Task::none(), + MenuAction::Replace => Task::none(), + MenuAction::EditPreferences => { + self.open_singleton_tab(TabType::Settings, "Settings"); + Task::none() + } + // View + MenuAction::ViewPosts => { + Task::done(Message::SetActiveView(SidebarView::Posts)) + } + MenuAction::ViewMedia => { + Task::done(Message::SetActiveView(SidebarView::Media)) + } + MenuAction::ToggleSidebar => { + Task::done(Message::ToggleSidebar) + } + MenuAction::TogglePanel => { + Task::done(Message::TogglePanel) + } + // Blog + MenuAction::PublishSelected => Task::none(), // Disabled in M2 + MenuAction::PreviewPost => Task::none(), // Disabled in M2 + MenuAction::EditMenu => { + self.open_singleton_tab(TabType::MenuEditor, "Menu Editor"); + Task::none() + } + MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase), + MenuAction::ReindexText => Task::done(Message::ReindexText), + MenuAction::MetadataDiff => Task::done(Message::RunMetadataDiff), + MenuAction::RegenerateCalendar => Task::none(), + MenuAction::ValidateTranslations => { + self.open_singleton_tab(TabType::TranslationValidation, "Translation Validation"); + Task::none() + } + MenuAction::FillMissingTranslations => Task::none(), + MenuAction::GenerateSitemap => Task::none(), + MenuAction::ValidateSite => { + self.open_singleton_tab(TabType::SiteValidation, "Site Validation"); + Task::none() + } + MenuAction::UploadSite => Task::none(), + // Help + MenuAction::About => Task::none(), + MenuAction::OpenDocumentation => { + self.open_singleton_tab(TabType::Documentation, "Documentation"); + Task::none() + } + MenuAction::ViewOnGitHub => { + let _ = open::that("https://github.com/nickarumern/bds"); + Task::none() + } + MenuAction::ReportIssue => { + let _ = open::that("https://github.com/nickarumern/bds/issues"); + Task::none() + } + } + } + + fn open_singleton_tab(&mut self, tab_type: TabType, title: &str) { + let tab = Tab { + id: format!("singleton-{title}"), + tab_type, + title: title.to_string(), + is_transient: false, + }; + let idx = tabs::open_tab(&mut self.tabs, tab); + if let Some(t) = self.tabs.get(idx) { + self.active_tab = Some(t.id.clone()); + } + } + + fn refresh_task_snapshots(&mut self) { + self.task_snapshots = self + .task_manager + .snapshots() + .into_iter() + .map(|(id, label, status, progress, message)| { + let status_str = match &status { + TaskStatus::Queued => "queued".to_string(), + TaskStatus::Running => "running".to_string(), + TaskStatus::Completed => "completed".to_string(), + TaskStatus::Failed(e) => format!("failed: {e}"), + TaskStatus::Cancelled => "cancelled".to_string(), + }; + TaskSnapshot { + id, + label, + status: status_str, + progress, + message, + } + }) + .collect(); + } + + fn add_output(&mut self, text: &str) { + self.output_entries.push(OutputEntry { + timestamp: chrono::Utc::now().timestamp(), + text: text.to_string(), + }); } } diff --git a/crates/bds-ui/src/i18n/mod.rs b/crates/bds-ui/src/i18n/mod.rs new file mode 100644 index 0000000..a8a0d3a --- /dev/null +++ b/crates/bds-ui/src/i18n/mod.rs @@ -0,0 +1,11 @@ +use bds_core::i18n::{translate, translate_with, UiLocale}; + +/// Shorthand for translate in view code. +pub fn t(locale: UiLocale, key: &str) -> String { + translate(locale, key) +} + +/// Shorthand for translate_with in view code. +pub fn tw(locale: UiLocale, key: &str, params: &[(&str, &str)]) -> String { + translate_with(locale, key, params) +} diff --git a/crates/bds-ui/src/lib.rs b/crates/bds-ui/src/lib.rs index 9d72bbe..477f6bf 100644 --- a/crates/bds-ui/src/lib.rs +++ b/crates/bds-ui/src/lib.rs @@ -1,4 +1,7 @@ pub mod app; +pub mod i18n; pub mod platform; +pub mod state; +pub mod views; pub use app::BdsApp; diff --git a/crates/bds-ui/src/main.rs b/crates/bds-ui/src/main.rs index 14e20f0..6cf10c8 100644 --- a/crates/bds-ui/src/main.rs +++ b/crates/bds-ui/src/main.rs @@ -3,5 +3,6 @@ use bds_ui::BdsApp; fn main() -> iced::Result { iced::application("bDS", BdsApp::update, BdsApp::view) .subscription(BdsApp::subscription) + .window_size((1200.0, 800.0)) .run_with(BdsApp::new) } diff --git a/crates/bds-ui/src/platform/dialog.rs b/crates/bds-ui/src/platform/dialog.rs new file mode 100644 index 0000000..321add7 --- /dev/null +++ b/crates/bds-ui/src/platform/dialog.rs @@ -0,0 +1,35 @@ +use iced::Task; + +use crate::app::Message; + +/// Pick a folder using the native file dialog. +pub fn pick_folder() -> Task { + Task::perform( + async { + rfd::AsyncFileDialog::new() + .set_title("Select Folder") + .pick_folder() + .await + .map(|h| h.path().to_path_buf()) + }, + Message::FolderPicked, + ) +} + +/// Pick one or more image/media files using the native file dialog. +pub fn pick_media_files() -> Task { + Task::perform( + async { + rfd::AsyncFileDialog::new() + .set_title("Import Media") + .add_filter( + "Images", + &["jpg", "jpeg", "png", "gif", "webp", "svg", "tiff", "bmp"], + ) + .pick_files() + .await + .map(|hs| hs.into_iter().map(|h| h.path().to_path_buf()).collect()) + }, + Message::MediaFilesPicked, + ) +} diff --git a/crates/bds-ui/src/platform/macos.rs b/crates/bds-ui/src/platform/macos.rs index afcaddb..d39dfce 100644 --- a/crates/bds-ui/src/platform/macos.rs +++ b/crates/bds-ui/src/platform/macos.rs @@ -1,10 +1,27 @@ -// macOS lifecycle shim — objc2 hooks for NSApplicationDelegate. -// -// Handles: -// - application:openFile: (Finder open) -// - application:openURLs: (URL scheme handling) -// -// These will be forwarded as Message variants into the Iced event loop -// via a channel-based subscription. -// -// Implementation deferred to M2 when the full message routing is in place. +use std::path::PathBuf; +use std::sync::mpsc; + +use crate::app::Message; + +/// Events that arrive from macOS lifecycle hooks (openFile, openURLs). +#[derive(Debug, Clone)] +pub enum LifecycleEvent { + FileOpen(PathBuf), + UrlOpen(String), +} + +/// Create the macOS lifecycle channel (sender goes to objc hooks, receiver polled by subscription). +pub fn lifecycle_channel() -> (mpsc::Sender, mpsc::Receiver) { + mpsc::channel() +} + +/// Poll the macOS lifecycle receiver for the next event and map to a Message. +pub fn poll_lifecycle( + receiver: &mpsc::Receiver, +) -> Option { + match receiver.try_recv() { + Ok(LifecycleEvent::FileOpen(path)) => Some(Message::FileOpenRequested(path)), + Ok(LifecycleEvent::UrlOpen(url)) => Some(Message::UrlOpenRequested(url)), + Err(_) => None, + } +} diff --git a/crates/bds-ui/src/platform/menu.rs b/crates/bds-ui/src/platform/menu.rs index bc00b77..aba7cc4 100644 --- a/crates/bds-ui/src/platform/menu.rs +++ b/crates/bds-ui/src/platform/menu.rs @@ -1,17 +1,197 @@ -use iced::Subscription; -use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use std::collections::HashMap; +use iced::Subscription; +use muda::accelerator::{Accelerator, Code, Modifiers, CMD_OR_CTRL}; +use muda::{Menu, MenuEvent, MenuItem, MenuId, PredefinedMenuItem, Submenu}; + +use bds_core::i18n::{translate, UiLocale}; use crate::app::Message; -/// Build the native menu bar with standard application menus. +/// Every custom menu item that the application handles. /// -/// On macOS, also calls `init_for_nsapp()` to register the menu with AppKit. -/// This requires an active NSApplication, so it will silently fail in -/// test contexts without one (muda returns an error that we discard). -pub fn build_menu_bar() -> Menu { - let menu = Menu::new(); +/// Predefined OS items (Undo, Redo, Cut, Copy, Paste, SelectAll, etc.) +/// are handled by the platform and do not appear here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MenuAction { + // File + NewPost, + ImportMedia, + Save, + OpenInBrowser, + OpenDataFolder, + // Edit (custom items only) + Find, + Replace, + EditPreferences, + // View + ViewPosts, + ViewMedia, + ToggleSidebar, + TogglePanel, + // Blog + PublishSelected, + PreviewPost, + EditMenu, + RebuildDatabase, + ReindexText, + MetadataDiff, + RegenerateCalendar, + ValidateTranslations, + FillMissingTranslations, + GenerateSitemap, + ValidateSite, + UploadSite, + // Help + About, + OpenDocumentation, + ViewOnGitHub, + ReportIssue, +} - // App menu (macOS only shows this as the app name) +impl MenuAction { + /// All variants in declaration order. + pub const ALL: &'static [MenuAction] = &[ + MenuAction::NewPost, + MenuAction::ImportMedia, + MenuAction::Save, + MenuAction::OpenInBrowser, + MenuAction::OpenDataFolder, + MenuAction::Find, + MenuAction::Replace, + MenuAction::EditPreferences, + MenuAction::ViewPosts, + MenuAction::ViewMedia, + MenuAction::ToggleSidebar, + MenuAction::TogglePanel, + MenuAction::PublishSelected, + MenuAction::PreviewPost, + MenuAction::EditMenu, + MenuAction::RebuildDatabase, + MenuAction::ReindexText, + MenuAction::MetadataDiff, + MenuAction::RegenerateCalendar, + MenuAction::ValidateTranslations, + MenuAction::FillMissingTranslations, + MenuAction::GenerateSitemap, + MenuAction::ValidateSite, + MenuAction::UploadSite, + MenuAction::About, + MenuAction::OpenDocumentation, + MenuAction::ViewOnGitHub, + MenuAction::ReportIssue, + ]; + + /// Return the i18n key for this action's menu label. + pub fn i18n_key(self) -> &'static str { + match self { + Self::NewPost => "menu.item.newPost", + Self::ImportMedia => "menu.item.importMedia", + Self::Save => "menu.item.save", + Self::OpenInBrowser => "menu.item.openInBrowser", + Self::OpenDataFolder => "menu.item.openDataFolder", + Self::Find => "menu.item.find", + Self::Replace => "menu.item.replace", + Self::EditPreferences => "menu.item.editPreferences", + Self::ViewPosts => "menu.item.viewPosts", + Self::ViewMedia => "menu.item.viewMedia", + Self::ToggleSidebar => "menu.item.toggleSidebar", + Self::TogglePanel => "menu.item.togglePanel", + Self::PublishSelected => "menu.item.publishSelected", + Self::PreviewPost => "menu.item.previewPost", + Self::EditMenu => "menu.item.editMenu", + Self::RebuildDatabase => "menu.item.rebuildDatabase", + Self::ReindexText => "menu.item.reindexText", + Self::MetadataDiff => "menu.item.metadataDiff", + Self::RegenerateCalendar => "menu.item.regenerateCalendar", + Self::ValidateTranslations => "menu.item.validateTranslations", + Self::FillMissingTranslations => "menu.item.fillMissingTranslations", + Self::GenerateSitemap => "menu.item.generateSitemap", + Self::ValidateSite => "menu.item.validateSite", + Self::UploadSite => "menu.item.uploadSite", + Self::About => "menu.item.about", + Self::OpenDocumentation => "menu.item.openDocumentation", + Self::ViewOnGitHub => "menu.item.viewOnGitHub", + Self::ReportIssue => "menu.item.reportIssue", + } + } +} + +/// Maps between muda `MenuId`s and application `MenuAction`s. +/// +/// Also holds clones of the `MenuItem` handles so that labels and +/// enabled state can be changed at runtime (e.g. on locale switch). +pub struct MenuRegistry { + action_map: HashMap, + id_map: HashMap, + items: HashMap, +} + +impl MenuRegistry { + fn new() -> Self { + Self { + action_map: HashMap::new(), + id_map: HashMap::new(), + items: HashMap::new(), + } + } + + fn register(&mut self, action: MenuAction, item: &MenuItem) { + self.action_map.insert(item.id().clone(), action); + self.id_map.insert(action, item.id().clone()); + self.items.insert(action, item.clone()); + } + + /// Look up the `MenuAction` for a raw muda event id. + pub fn lookup(&self, id: &MenuId) -> Option { + self.action_map.get(id).copied() + } + + /// Enable or disable the menu item for a given action. + pub fn set_enabled(&self, action: MenuAction, enabled: bool) { + if let Some(item) = self.items.get(&action) { + item.set_enabled(enabled); + } + } + + /// Change the displayed text for a given action. + pub fn set_text(&self, action: MenuAction, text: &str) { + if let Some(item) = self.items.get(&action) { + item.set_text(text); + } + } + + /// Number of registered (action, MenuId) pairs. + pub fn action_count(&self) -> usize { + self.action_map.len() + } +} + +// --------------------------------------------------------------------------- +// Menu construction helpers +// --------------------------------------------------------------------------- + +/// Helper: create a `MenuItem`, register it, and return a reference for +/// appending to a `Submenu`. +fn item( + registry: &mut MenuRegistry, + action: MenuAction, + locale: UiLocale, + accel: Option, +) -> MenuItem { + let label = translate(locale, action.i18n_key()); + let mi = MenuItem::new(label, true, accel); + registry.register(action, &mi); + mi +} + +/// Build the full native menu bar and a registry that maps ids to actions. +/// +/// On macOS this also calls `init_for_nsapp()` to attach the menu. +pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) { + let menu = Menu::new(); + let mut reg = MenuRegistry::new(); + + // -- macOS app menu -- let app_menu = Submenu::new("bDS", true); let _ = app_menu.append(&PredefinedMenuItem::about(None, None)); let _ = app_menu.append(&PredefinedMenuItem::separator()); @@ -23,15 +203,22 @@ pub fn build_menu_bar() -> Menu { let _ = app_menu.append(&PredefinedMenuItem::separator()); let _ = app_menu.append(&PredefinedMenuItem::quit(None)); - // File menu - let file_menu = Submenu::new("File", true); - let _ = file_menu.append(&MenuItem::new("Open Project...", true, None)); + // -- File -- + let file_menu = Submenu::new(translate(locale, "menu.group.file"), true); + let _ = file_menu.append(&item(&mut reg, MenuAction::NewPost, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN)))); + let _ = file_menu.append(&item(&mut reg, MenuAction::ImportMedia, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyI)))); + let _ = file_menu.append(&item(&mut reg, MenuAction::Save, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyS)))); + let _ = file_menu.append(&PredefinedMenuItem::separator()); + let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None)); + let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None)); let _ = file_menu.append(&PredefinedMenuItem::separator()); - let _ = file_menu.append(&MenuItem::new("Save", true, None)); let _ = file_menu.append(&PredefinedMenuItem::close_window(None)); - // Edit menu - let edit_menu = Submenu::new("Edit", true); + // -- Edit -- + let edit_menu = Submenu::new(translate(locale, "menu.group.edit"), true); let _ = edit_menu.append(&PredefinedMenuItem::undo(None)); let _ = edit_menu.append(&PredefinedMenuItem::redo(None)); let _ = edit_menu.append(&PredefinedMenuItem::separator()); @@ -39,41 +226,91 @@ pub fn build_menu_bar() -> Menu { let _ = edit_menu.append(&PredefinedMenuItem::copy(None)); let _ = edit_menu.append(&PredefinedMenuItem::paste(None)); let _ = edit_menu.append(&PredefinedMenuItem::select_all(None)); + let _ = edit_menu.append(&PredefinedMenuItem::separator()); + let _ = edit_menu.append(&item(&mut reg, MenuAction::Find, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyF)))); + let _ = edit_menu.append(&item(&mut reg, MenuAction::Replace, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyH)))); + let _ = edit_menu.append(&PredefinedMenuItem::separator()); + let _ = edit_menu.append(&item(&mut reg, MenuAction::EditPreferences, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma)))); - // View menu - let view_menu = Submenu::new("View", true); - let _ = view_menu.append(&MenuItem::new("Toggle Sidebar", true, None)); + // -- View -- + let view_menu = Submenu::new(translate(locale, "menu.group.view"), true); + let _ = view_menu.append(&item(&mut reg, MenuAction::ViewPosts, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit1)))); + let _ = view_menu.append(&item(&mut reg, MenuAction::ViewMedia, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Digit2)))); + let _ = view_menu.append(&PredefinedMenuItem::separator()); + let _ = view_menu.append(&item(&mut reg, MenuAction::ToggleSidebar, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyB)))); + let _ = view_menu.append(&item(&mut reg, MenuAction::TogglePanel, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyJ)))); + let _ = view_menu.append(&PredefinedMenuItem::separator()); let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None)); - // Window menu - let window_menu = Submenu::new("Window", true); - let _ = window_menu.append(&PredefinedMenuItem::minimize(None)); - let _ = window_menu.append(&PredefinedMenuItem::maximize(None)); + // -- Blog -- + let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true); + let _ = blog_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP)))); + let _ = blog_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV)))); + let _ = blog_menu.append(&PredefinedMenuItem::separator()); + let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None)); + let _ = blog_menu.append(&PredefinedMenuItem::separator()); + let _ = blog_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None)); + let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None)); + let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None)); + let _ = blog_menu.append(&PredefinedMenuItem::separator()); + let _ = blog_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None)); + let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None)); + let _ = blog_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None)); + let _ = blog_menu.append(&PredefinedMenuItem::separator()); + let _ = blog_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR)))); + let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL)))); + let _ = blog_menu.append(&item(&mut reg, MenuAction::UploadSite, locale, + Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU)))); - // Help menu - let help_menu = Submenu::new("Help", true); - let _ = help_menu.append(&MenuItem::new("bDS Help", true, None)); + // -- Help -- + let help_menu = Submenu::new(translate(locale, "menu.group.help"), true); + let _ = help_menu.append(&item(&mut reg, MenuAction::About, locale, None)); + let _ = help_menu.append(&PredefinedMenuItem::separator()); + let _ = help_menu.append(&item(&mut reg, MenuAction::OpenDocumentation, locale, None)); + let _ = help_menu.append(&item(&mut reg, MenuAction::ViewOnGitHub, locale, None)); + let _ = help_menu.append(&item(&mut reg, MenuAction::ReportIssue, locale, None)); + // Assemble the menu bar let _ = menu.append(&app_menu); let _ = menu.append(&file_menu); let _ = menu.append(&edit_menu); let _ = menu.append(&view_menu); - let _ = menu.append(&window_menu); + let _ = menu.append(&blog_menu); let _ = menu.append(&help_menu); - // Initialize the menu on macOS + // Attach on macOS #[cfg(target_os = "macos")] { let _ = menu.init_for_nsapp(); } - menu + (menu, reg) } -/// Iced subscription that polls muda menu events. +/// Re-translate every registered menu item for a new locale. +pub fn update_menu_labels(registry: &MenuRegistry, locale: UiLocale) { + for &action in MenuAction::ALL { + registry.set_text(action, &translate(locale, action.i18n_key())); + } +} + +/// Iced subscription that polls muda `MenuEvent`s each frame. +/// +/// Produces `Message::MenuEvent(MenuId)` so the app can look up the +/// `MenuAction` via its `MenuRegistry`. pub fn menu_subscription() -> Subscription { iced::event::listen_with(|_event, _status, _id| { - // Check for pending menu events if let Ok(event) = MenuEvent::receiver().try_recv() { Some(Message::MenuEvent(event.id)) } else { @@ -81,3 +318,68 @@ pub fn menu_subscription() -> Subscription { } }) } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + use bds_core::i18n::UiLocale; + + #[test] + fn all_variants_are_listed() { + // MenuAction::ALL must contain every variant. + // If someone adds a variant but forgets ALL, the i18n_key match + // will produce a compile error, but this also catches ALL length. + assert_eq!(MenuAction::ALL.len(), 28); + } + + #[test] + fn i18n_keys_resolve_for_english() { + for &action in MenuAction::ALL { + let key = action.i18n_key(); + let label = translate(UiLocale::En, key); + assert_ne!(label, key, "missing English translation for {key}"); + } + } + + #[test] + fn registry_register_and_lookup_roundtrip() { + let mut reg = MenuRegistry::new(); + let mi = MenuItem::new("Test", true, None); + reg.register(MenuAction::Save, &mi); + + assert_eq!(reg.lookup(mi.id()), Some(MenuAction::Save)); + assert_eq!(reg.action_count(), 1); + } + + #[test] + fn registry_lookup_missing_returns_none() { + let reg = MenuRegistry::new(); + let bogus = MenuId::new("nonexistent"); + assert_eq!(reg.lookup(&bogus), None); + } + + #[test] + fn registry_set_enabled_and_text() { + let mut reg = MenuRegistry::new(); + let mi = MenuItem::new("Original", true, None); + reg.register(MenuAction::NewPost, &mi); + + reg.set_enabled(MenuAction::NewPost, false); + assert!(!mi.is_enabled()); + + reg.set_text(MenuAction::NewPost, "Changed"); + assert_eq!(mi.text(), "Changed"); + } + + #[test] + fn all_actions_have_unique_i18n_keys() { + let mut seen = std::collections::HashSet::new(); + for &action in MenuAction::ALL { + let key = action.i18n_key(); + assert!(seen.insert(key), "duplicate i18n key: {key}"); + } + } +} diff --git a/crates/bds-ui/src/platform/mod.rs b/crates/bds-ui/src/platform/mod.rs index 1c71192..81a6255 100644 --- a/crates/bds-ui/src/platform/mod.rs +++ b/crates/bds-ui/src/platform/mod.rs @@ -1,4 +1,5 @@ pub mod menu; +pub mod dialog; #[cfg(target_os = "macos")] pub mod macos; diff --git a/crates/bds-ui/src/state/mod.rs b/crates/bds-ui/src/state/mod.rs new file mode 100644 index 0000000..fc54908 --- /dev/null +++ b/crates/bds-ui/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod navigation; +pub mod tabs; + +pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry}; +pub use tabs::{Tab, TabType}; diff --git a/crates/bds-ui/src/state/navigation.rs b/crates/bds-ui/src/state/navigation.rs new file mode 100644 index 0000000..146fe9d --- /dev/null +++ b/crates/bds-ui/src/state/navigation.rs @@ -0,0 +1,126 @@ +use std::fmt; + +/// Which list the sidebar is currently showing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SidebarView { + Posts, + Pages, + Media, + Scripts, + Templates, + Tags, + Chat, + Import, + Git, + Settings, +} + +impl fmt::Display for SidebarView { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let key = self.i18n_key(); + f.write_str(key) + } +} + +impl SidebarView { + /// Returns the `activity.*` i18n key for this sidebar view. + pub fn i18n_key(&self) -> &'static str { + match self { + Self::Posts => "activity.posts", + Self::Pages => "activity.pages", + Self::Media => "activity.media", + Self::Scripts => "activity.scripts", + Self::Templates => "activity.templates", + Self::Tags => "activity.tags", + Self::Chat => "activity.chat", + Self::Import => "activity.import", + Self::Git => "activity.git", + Self::Settings => "activity.settings", + } + } +} + +/// Which tab is selected in the bottom panel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelTab { + Tasks, + Output, +} + +/// A snapshot of a running or completed task shown in the panel. +#[derive(Debug, Clone)] +pub struct TaskSnapshot { + pub id: u64, + pub label: String, + pub status: String, + pub progress: Option, + pub message: Option, +} + +/// A single line of output shown in the panel. +#[derive(Debug, Clone)] +pub struct OutputEntry { + pub timestamp: i64, + pub text: String, +} + +/// Determine the next `(SidebarView, sidebar_visible)` after a user clicks +/// an activity-bar icon. +/// +/// * Same icon + visible -> toggle sidebar off +/// * Same icon + hidden -> toggle sidebar on +/// * Different icon -> switch view and ensure visible +pub fn handle_activity_click( + current_view: SidebarView, + sidebar_visible: bool, + clicked: SidebarView, +) -> (SidebarView, bool) { + if clicked == current_view { + (current_view, !sidebar_visible) + } else { + (clicked, true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toggle_off_when_same_and_visible() { + let (view, visible) = + handle_activity_click(SidebarView::Posts, true, SidebarView::Posts); + assert_eq!(view, SidebarView::Posts); + assert!(!visible); + } + + #[test] + fn toggle_on_when_same_and_hidden() { + let (view, visible) = + handle_activity_click(SidebarView::Posts, false, SidebarView::Posts); + assert_eq!(view, SidebarView::Posts); + assert!(visible); + } + + #[test] + fn switch_view_when_different() { + let (view, visible) = + handle_activity_click(SidebarView::Posts, true, SidebarView::Media); + assert_eq!(view, SidebarView::Media); + assert!(visible); + } + + #[test] + fn switch_view_when_different_and_hidden() { + let (view, visible) = + handle_activity_click(SidebarView::Posts, false, SidebarView::Settings); + assert_eq!(view, SidebarView::Settings); + assert!(visible); + } + + #[test] + fn display_returns_i18n_key() { + assert_eq!(SidebarView::Posts.to_string(), "activity.posts"); + assert_eq!(SidebarView::Settings.to_string(), "activity.settings"); + } +} diff --git a/crates/bds-ui/src/state/tabs.rs b/crates/bds-ui/src/state/tabs.rs new file mode 100644 index 0000000..9d7a01a --- /dev/null +++ b/crates/bds-ui/src/state/tabs.rs @@ -0,0 +1,161 @@ +/// The kind of content a tab holds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TabType { + Post, + Media, + Settings, + Style, + Tags, + Chat, + Import, + MenuEditor, + MetadataDiff, + Scripts, + Templates, + Documentation, + SiteValidation, + TranslationValidation, +} + +impl TabType { + /// Singleton tabs may only appear once in the tab bar. + /// Every type except `Post` and `Media` is a singleton. + pub fn is_singleton(&self) -> bool { + !matches!(self, Self::Post | Self::Media) + } +} + +/// A single tab in the editor area. +#[derive(Debug, Clone)] +pub struct Tab { + pub id: String, + pub tab_type: TabType, + pub title: String, + pub is_transient: bool, +} + +/// Open (or focus) a tab in the tab list. +/// +/// * **Singleton** — if a tab with the same `TabType` already exists, return +/// its index instead of inserting a duplicate. +/// * **Transient** — replace an existing transient tab of the same type, or +/// append if none exists. +/// * Otherwise — append unconditionally. +/// +/// Returns the index of the resulting tab. +pub fn open_tab(tabs: &mut Vec, new_tab: Tab) -> usize { + if new_tab.tab_type.is_singleton() { + if let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type) { + return idx; + } + } + + if new_tab.is_transient { + if let Some(idx) = tabs + .iter() + .position(|t| t.tab_type == new_tab.tab_type && t.is_transient) + { + tabs[idx] = new_tab; + return idx; + } + } + + tabs.push(new_tab); + tabs.len() - 1 +} + +/// Remove the tab whose `id` matches and return the index that should be +/// selected next. +/// +/// Prefers the same index (now occupied by the next tab), clamped to +/// `len - 1`. Returns `None` when the list is empty after removal. +pub fn close_tab(tabs: &mut Vec, id: &str) -> Option { + let pos = tabs.iter().position(|t| t.id == id)?; + tabs.remove(pos); + if tabs.is_empty() { + None + } else { + Some(pos.min(tabs.len() - 1)) + } +} + +/// Mark a transient tab as permanent (non-transient). +pub fn pin_tab(tabs: &mut Vec, id: &str) { + if let Some(tab) = tabs.iter_mut().find(|t| t.id == id) { + tab.is_transient = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_tab(id: &str, tab_type: TabType, transient: bool) -> Tab { + Tab { + id: id.to_string(), + tab_type, + title: id.to_string(), + is_transient: transient, + } + } + + #[test] + fn singleton_dedup() { + let mut tabs = Vec::new(); + let idx1 = open_tab(&mut tabs, make_tab("s1", TabType::Settings, false)); + let idx2 = open_tab(&mut tabs, make_tab("s2", TabType::Settings, false)); + assert_eq!(idx1, idx2); + assert_eq!(tabs.len(), 1); + assert_eq!(tabs[0].id, "s1"); + } + + #[test] + fn transient_replacement() { + let mut tabs = Vec::new(); + open_tab(&mut tabs, make_tab("p1", TabType::Post, true)); + assert_eq!(tabs.len(), 1); + let idx = open_tab(&mut tabs, make_tab("p2", TabType::Post, true)); + assert_eq!(tabs.len(), 1); + assert_eq!(tabs[idx].id, "p2"); + } + + #[test] + fn pin_makes_permanent() { + let mut tabs = Vec::new(); + open_tab(&mut tabs, make_tab("p1", TabType::Post, true)); + assert!(tabs[0].is_transient); + pin_tab(&mut tabs, "p1"); + assert!(!tabs[0].is_transient); + } + + #[test] + fn close_selects_neighbor() { + let mut tabs = Vec::new(); + open_tab(&mut tabs, make_tab("a", TabType::Post, false)); + open_tab(&mut tabs, make_tab("b", TabType::Media, false)); + open_tab(&mut tabs, make_tab("c", TabType::Post, false)); + + // Close the middle tab. + let next = close_tab(&mut tabs, "b"); + assert_eq!(next, Some(1)); // index 1 now holds "c" + assert_eq!(tabs[1].id, "c"); + + // Close the last tab. + let next = close_tab(&mut tabs, "c"); + assert_eq!(next, Some(0)); // clamped to len-1 + + // Close the only remaining tab. + let next = close_tab(&mut tabs, "a"); + assert_eq!(next, None); + } + + #[test] + fn open_appends_non_singleton_non_transient() { + let mut tabs = Vec::new(); + let i0 = open_tab(&mut tabs, make_tab("p1", TabType::Post, false)); + let i1 = open_tab(&mut tabs, make_tab("p2", TabType::Post, false)); + assert_eq!(i0, 0); + assert_eq!(i1, 1); + assert_eq!(tabs.len(), 2); + } +} diff --git a/crates/bds-ui/src/views/activity_bar.rs b/crates/bds-ui/src/views/activity_bar.rs new file mode 100644 index 0000000..f865253 --- /dev/null +++ b/crates/bds-ui/src/views/activity_bar.rs @@ -0,0 +1,89 @@ +use iced::widget::{button, column, container, text, Column, Space}; +use iced::{Alignment, Element, Length}; + +use bds_core::i18n::UiLocale; + +use crate::app::Message; +use crate::state::navigation::SidebarView; + +/// Activity-bar short labels used in M2 (no SVG icons yet). +fn short_label(view: SidebarView) -> &'static str { + match view { + SidebarView::Posts => "Po", + SidebarView::Pages => "Pa", + SidebarView::Media => "Me", + SidebarView::Scripts => "Sc", + SidebarView::Templates => "Tp", + SidebarView::Tags => "Ta", + SidebarView::Chat => "AI", + SidebarView::Import => "Im", + SidebarView::Git => "Gi", + SidebarView::Settings => "Se", + } +} + +/// Top group of activity items. +const TOP_ACTIVITIES: &[SidebarView] = &[ + SidebarView::Posts, + SidebarView::Pages, + SidebarView::Media, + SidebarView::Scripts, + SidebarView::Templates, + SidebarView::Tags, + SidebarView::Chat, + SidebarView::Import, +]; + +/// Bottom group of activity items. +const BOTTOM_ACTIVITIES: &[SidebarView] = &[ + SidebarView::Git, + SidebarView::Settings, +]; + +pub fn view( + active_view: SidebarView, + _locale: UiLocale, +) -> Element<'static, Message> { + let make_btn = |view: SidebarView| -> Element<'static, Message> { + let label = short_label(view); + let mut btn = button( + text(label).size(12).center(), + ) + .width(Length::Fixed(48.0)) + .height(Length::Fixed(48.0)) + .on_press(Message::SetActiveView(view)); + + if view == active_view { + btn = btn.style(button::primary); + } else { + btn = btn.style(button::secondary); + } + + container(btn) + .center_x(Length::Fixed(48.0)) + .into() + }; + + let top_items: Vec> = TOP_ACTIVITIES + .iter() + .map(|v| make_btn(*v)) + .collect(); + + let bottom_items: Vec> = BOTTOM_ACTIVITIES + .iter() + .map(|v| make_btn(*v)) + .collect(); + + let top = Column::with_children(top_items).spacing(2); + let bottom = Column::with_children(bottom_items).spacing(2); + + column![ + top, + Space::with_height(Length::Fill), + bottom, + ] + .width(Length::Fixed(48.0)) + .height(Length::Fill) + .align_x(Alignment::Center) + .into() +} diff --git a/crates/bds-ui/src/views/mod.rs b/crates/bds-ui/src/views/mod.rs new file mode 100644 index 0000000..68a0293 --- /dev/null +++ b/crates/bds-ui/src/views/mod.rs @@ -0,0 +1,7 @@ +pub mod workspace; +pub mod activity_bar; +pub mod sidebar; +pub mod tab_bar; +pub mod status_bar; +pub mod panel; +pub mod welcome; diff --git a/crates/bds-ui/src/views/panel.rs b/crates/bds-ui/src/views/panel.rs new file mode 100644 index 0000000..18c01c5 --- /dev/null +++ b/crates/bds-ui/src/views/panel.rs @@ -0,0 +1,111 @@ +use iced::widget::{button, column, container, row, scrollable, text, Space}; +use iced::{Alignment, Element, Length}; + +use bds_core::i18n::UiLocale; + +use crate::app::Message; +use crate::i18n::t; +use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot}; + +pub fn view( + panel_tab: PanelTab, + task_snapshots: &[TaskSnapshot], + output_entries: &[OutputEntry], + locale: UiLocale, +) -> Element<'static, Message> { + // Tab header + let tasks_btn = { + let mut btn = button(text(t(locale, "common.tasks")).size(12)) + .on_press(Message::SetPanelTab(PanelTab::Tasks)) + .padding([4, 8]); + if panel_tab == PanelTab::Tasks { + btn = btn.style(button::primary); + } else { + btn = btn.style(button::secondary); + } + btn + }; + + let output_btn = { + let mut btn = button(text(t(locale, "panel.output")).size(12)) + .on_press(Message::SetPanelTab(PanelTab::Output)) + .padding([4, 8]); + if panel_tab == PanelTab::Output { + btn = btn.style(button::primary); + } else { + btn = btn.style(button::secondary); + } + btn + }; + + let close_btn = button(text("×").size(12)) + .on_press(Message::TogglePanel) + .padding([4, 6]) + .style(button::text); + + let tab_header = row![ + tasks_btn, + output_btn, + Space::with_width(Length::Fill), + close_btn, + ] + .spacing(4) + .align_y(Alignment::Center); + + // Tab content + let content: Element<'static, Message> = match panel_tab { + PanelTab::Tasks => { + if task_snapshots.is_empty() { + container(text(t(locale, "tasks.noActive")).size(12)) + .padding(8) + .into() + } else { + let items: Vec> = task_snapshots + .iter() + .map(|snap| { + let status_text = format!( + "{} — {}{}", + snap.label, + snap.status, + snap.progress + .map(|p| format!(" ({:.0}%)", p * 100.0)) + .unwrap_or_default(), + ); + text(status_text).size(11).into() + }) + .collect(); + scrollable( + iced::widget::Column::with_children(items) + .spacing(4) + .padding(8), + ) + .into() + } + } + PanelTab::Output => { + if output_entries.is_empty() { + container(text(t(locale, "panel.noOutput")).size(12)) + .padding(8) + .into() + } else { + let items: Vec> = output_entries + .iter() + .map(|entry| text(entry.text.clone()).size(11).into()) + .collect(); + scrollable( + iced::widget::Column::with_children(items) + .spacing(2) + .padding(8), + ) + .into() + } + } + }; + + container( + column![tab_header, content].spacing(4), + ) + .width(Length::Fill) + .height(Length::Fixed(200.0)) + .into() +} diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs new file mode 100644 index 0000000..b7a54e1 --- /dev/null +++ b/crates/bds-ui/src/views/sidebar.rs @@ -0,0 +1,37 @@ +use iced::widget::{column, container, scrollable, text}; +use iced::{Element, Length}; + +use bds_core::i18n::UiLocale; + +use crate::app::Message; +use crate::i18n::t; +use crate::state::navigation::SidebarView; + +pub fn view( + sidebar_view: SidebarView, + locale: UiLocale, +) -> Element<'static, Message> { + let header_key = sidebar_view.i18n_key(); + let header_text = t(locale, header_key); + + let placeholder = match sidebar_view { + SidebarView::Posts => t(locale, "sidebar.noPostsYet"), + SidebarView::Pages => t(locale, "sidebar.noPagesYet"), + SidebarView::Media => t(locale, "sidebar.noMediaYet"), + SidebarView::Settings => t(locale, "sidebar.settingsHeader"), + SidebarView::Tags => t(locale, "sidebar.tagsHeader"), + _ => t(locale, "sidebar.loading"), + }; + + let content = column![ + text(header_text).size(14), + text(placeholder).size(12), + ] + .spacing(8) + .padding(12); + + container(scrollable(content)) + .width(Length::Fixed(280.0)) + .height(Length::Fill) + .into() +} diff --git a/crates/bds-ui/src/views/status_bar.rs b/crates/bds-ui/src/views/status_bar.rs new file mode 100644 index 0000000..9db45d1 --- /dev/null +++ b/crates/bds-ui/src/views/status_bar.rs @@ -0,0 +1,97 @@ +use iced::widget::{button, container, row, text, Space}; +use iced::{Alignment, Element, Length}; + +use bds_core::i18n::UiLocale; + +use crate::app::Message; +use crate::i18n::{t, tw}; +use crate::state::navigation::TaskSnapshot; + +pub fn view( + active_project_name: Option<&str>, + post_count: usize, + media_count: usize, + locale: UiLocale, + offline_mode: bool, + task_snapshots: &[TaskSnapshot], +) -> Element<'static, Message> { + // Left: project name + task indicator + let project_label = active_project_name.unwrap_or("—").to_string(); + let project_text = text(project_label).size(12); + + let running: Vec<&TaskSnapshot> = task_snapshots + .iter() + .filter(|t| t.status == "running") + .collect(); + let task_indicator: Element<'static, Message> = if !running.is_empty() { + let first = running[0].label.clone(); + if running.len() > 1 { + text(format!("{first} (+{})", running.len() - 1)).size(11).into() + } else { + text(first).size(11).into() + } + } else { + Space::with_width(0).into() + }; + + let left = row![project_text, task_indicator].spacing(12); + + // Right: post count, media count, airplane mode, locale selector, brand + let posts_label = tw(locale, "statusBar.posts", &[("count", &post_count.to_string())]); + let media_label = tw(locale, "statusBar.media", &[("count", &media_count.to_string())]); + + let airplane_label = if offline_mode { + t(locale, "statusBar.offlineModeActive") + } else { + t(locale, "statusBar.offlineMode") + }; + + let airplane_btn = button(text(airplane_label).size(11)) + .on_press(Message::SetOfflineMode(!offline_mode)) + .padding([2, 6]) + .style(if offline_mode { button::primary } else { button::text }); + + let ui_label = t(locale, "statusBar.ui"); + + let locale_buttons: Vec> = UiLocale::all() + .iter() + .map(|l| { + let code = l.code().to_string(); + let mut btn = button(text(code).size(11)) + .on_press(Message::SetUiLocale(*l)) + .padding([2, 4]); + if *l == locale { + btn = btn.style(button::primary); + } else { + btn = btn.style(button::text); + } + btn.into() + }) + .collect(); + + let locale_row = iced::widget::Row::with_children(locale_buttons).spacing(2); + + let right = row![ + text(posts_label).size(11), + text(media_label).size(11), + airplane_btn, + text(ui_label).size(11), + locale_row, + text("bDS").size(11), + ] + .spacing(12) + .align_y(Alignment::Center); + + container( + row![ + left, + Space::with_width(Length::Fill), + right, + ] + .align_y(Alignment::Center) + .padding([0, 8]), + ) + .width(Length::Fill) + .height(Length::Fixed(22.0)) + .into() +} diff --git a/crates/bds-ui/src/views/tab_bar.rs b/crates/bds-ui/src/views/tab_bar.rs new file mode 100644 index 0000000..9b100d9 --- /dev/null +++ b/crates/bds-ui/src/views/tab_bar.rs @@ -0,0 +1,58 @@ +use iced::widget::{button, container, row, text, Space}; +use iced::{Element, Length}; + +use crate::app::Message; +use crate::state::tabs::Tab; + +pub fn view( + tabs: &[Tab], + active_tab: Option<&str>, +) -> Element<'static, Message> { + if tabs.is_empty() { + return Space::with_height(0).into(); + } + + let tab_buttons: Vec> = tabs + .iter() + .map(|tab| { + let is_active = active_tab == Some(tab.id.as_str()); + let title_text = if tab.is_transient { + format!("{} (preview)", tab.title) + } else { + tab.title.clone() + }; + let tab_id = tab.id.clone(); + let close_id = tab.id.clone(); + + let label = row![ + text(title_text).size(12), + button(text("×").size(12)) + .on_press(Message::CloseTab(close_id)) + .padding(2) + .style(button::text), + ] + .spacing(4); + + let mut btn = button(label) + .on_press(Message::SelectTab(tab_id)) + .padding([4, 8]); + + if is_active { + btn = btn.style(button::primary); + } else { + btn = btn.style(button::secondary); + } + + btn.into() + }) + .collect(); + + container( + iced::widget::Row::with_children(tab_buttons) + .spacing(1) + .height(Length::Fixed(35.0)), + ) + .width(Length::Fill) + .height(Length::Fixed(35.0)) + .into() +} diff --git a/crates/bds-ui/src/views/welcome.rs b/crates/bds-ui/src/views/welcome.rs new file mode 100644 index 0000000..fb549bc --- /dev/null +++ b/crates/bds-ui/src/views/welcome.rs @@ -0,0 +1,26 @@ +use iced::widget::{column, container, text}; +use iced::{Element, Length}; + +use bds_core::i18n::UiLocale; + +use crate::app::Message; +use crate::i18n::t; + +pub fn view(locale: UiLocale) -> Element<'static, Message> { + let title = t(locale, "welcome.title"); + let subtitle = t(locale, "welcome.subtitle"); + + container( + column![ + text(title).size(28), + text(subtitle).size(14), + ] + .spacing(12) + .align_x(iced::Alignment::Center), + ) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs new file mode 100644 index 0000000..f62aaba --- /dev/null +++ b/crates/bds-ui/src/views/workspace.rs @@ -0,0 +1,84 @@ +use iced::widget::{column, row}; +use iced::{Element, Length}; + +use bds_core::i18n::UiLocale; + +use crate::app::Message; +use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot}; +use crate::state::tabs::Tab; +use crate::views::{activity_bar, panel, sidebar, status_bar, tab_bar, welcome}; + +/// Compose the full workspace layout. +pub fn view( + // Navigation + sidebar_view: SidebarView, + sidebar_visible: bool, + // Tabs + tabs: &[Tab], + active_tab: Option<&str>, + // Panel + panel_visible: bool, + panel_tab: PanelTab, + task_snapshots: &[TaskSnapshot], + output_entries: &[OutputEntry], + // Status bar + active_project_name: Option<&str>, + post_count: usize, + media_count: usize, + offline_mode: bool, + // i18n + locale: UiLocale, +) -> Element<'static, Message> { + // Activity bar (leftmost column) + let activity = activity_bar::view(sidebar_view, locale); + + // Sidebar (conditionally visible) + let sidebar_el: Option> = if sidebar_visible { + Some(sidebar::view(sidebar_view, locale)) + } else { + None + }; + + // Tab bar + let tabs_el = tab_bar::view(tabs, active_tab); + + // Content area + let content_area = welcome::view(locale); + + // Panel (conditionally visible) + let panel_el: Option> = if panel_visible { + Some(panel::view(panel_tab, task_snapshots, output_entries, locale)) + } else { + None + }; + + // Right column: tab bar + content + panel + let mut right_col = column![tabs_el, content_area]; + if let Some(p) = panel_el { + right_col = right_col.push(p); + } + let right = right_col.width(Length::Fill).height(Length::Fill); + + // Main row: activity bar + sidebar? + right column + let mut main_row = row![activity]; + if let Some(sb) = sidebar_el { + main_row = main_row.push(sb); + } + main_row = main_row.push(right); + let main_row = main_row.height(Length::Fill); + + // Status bar at bottom + let status = status_bar::view( + active_project_name, + post_count, + media_count, + locale, + offline_mode, + task_snapshots, + ); + + column![main_row, status] + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/crates/bds-ui/tests/app_smoke.rs b/crates/bds-ui/tests/app_smoke.rs index d6bbc30..83f87fa 100644 --- a/crates/bds-ui/tests/app_smoke.rs +++ b/crates/bds-ui/tests/app_smoke.rs @@ -1,6 +1,6 @@ //! App launch smoke tests. //! -//! M0 validation: verifies that the core UI types can be constructed +//! Validates that the core UI types can be constructed //! and that the message routing works at the type level. //! //! NOTE: muda::Menu on macOS requires the actual main thread (not just @@ -8,7 +8,11 @@ //! be tested via `cargo test`. The full smoke test is launching the binary: //! cargo run -p bds-ui +use std::path::PathBuf; +use bds_core::i18n::UiLocale; use bds_ui::app::Message; +use bds_ui::state::navigation::{PanelTab, SidebarView}; +use bds_ui::state::tabs::{Tab, TabType}; // ── Smoke: Message enum is well-formed ── @@ -16,7 +20,6 @@ use bds_ui::app::Message; fn message_variants_constructable() { let _noop = Message::Noop; let _menu = Message::MenuEvent(muda::MenuId::new("test")); - // Verify Debug trait works assert!(format!("{:?}", Message::Noop).contains("Noop")); } @@ -27,12 +30,62 @@ fn message_clone_works() { assert!(format!("{cloned:?}").contains("MenuEvent")); } +#[test] +fn new_message_variants_constructable() { + // Navigation + let _view = Message::SetActiveView(SidebarView::Posts); + let _toggle_sb = Message::ToggleSidebar; + let _toggle_p = Message::TogglePanel; + + // Tabs + let tab = Tab { + id: "test".to_string(), + tab_type: TabType::Post, + title: "Test".to_string(), + is_transient: false, + }; + let _open = Message::OpenTab(tab); + let _close = Message::CloseTab("test".into()); + let _select = Message::SelectTab("test".into()); + let _pin = Message::PinTab("test".into()); + + // Project + let _switch = Message::SwitchProject("id".into()); + let _create = Message::CreateProject { name: "X".into(), data_path: None }; + let _delete = Message::DeleteProject("id".into()); + + // Dialogs + let _folder = Message::FolderPicked(Some(PathBuf::from("/tmp"))); + let _media = Message::MediaFilesPicked(None); + + // Tasks + let _tick = Message::TaskTick; + + // macOS lifecycle + let _file = Message::FileOpenRequested(PathBuf::from("/test")); + let _url = Message::UrlOpenRequested("bds://open".into()); + + // Panel + let _panel = Message::SetPanelTab(PanelTab::Output); + + // Settings + let _offline = Message::SetOfflineMode(true); + let _locale = Message::SetUiLocale(UiLocale::De); + + // Blog actions + let _rebuild = Message::RebuildDatabase; + let _reindex = Message::ReindexText; + let _diff = Message::RunMetadataDiff; + let _finished = Message::BlogTaskFinished { + label: "test".into(), + result: Ok(()), + }; +} + // ── Smoke: BdsApp type is accessible from integration tests ── #[test] fn bds_app_type_is_public() { - // This test verifies the public API surface exists. - // BdsApp, Message, platform::menu are all reachable. fn _assert_types() { let _: fn() -> (bds_ui::BdsApp, iced::Task) = bds_ui::BdsApp::new; } diff --git a/crates/bds-ui/tests/menu_routing.rs b/crates/bds-ui/tests/menu_routing.rs new file mode 100644 index 0000000..15f4c88 --- /dev/null +++ b/crates/bds-ui/tests/menu_routing.rs @@ -0,0 +1,44 @@ +//! Menu routing integration tests. +//! +//! Validates: all MenuAction variants registered, no collisions, +//! bidirectional lookup, and i18n keys resolve in every locale. + +use bds_core::i18n::{translate, UiLocale}; +use bds_ui::platform::menu::MenuAction; + +#[test] +fn all_menu_actions_have_i18n_keys() { + for &action in MenuAction::ALL { + let key = action.i18n_key(); + assert!(key.starts_with("menu.item."), "bad key prefix: {key}"); + } +} + +#[test] +fn all_menu_actions_translate_in_all_locales() { + for locale in UiLocale::all() { + for &action in MenuAction::ALL { + let key = action.i18n_key(); + let label = translate(*locale, key); + assert_ne!( + label, key, + "missing translation for {key} in locale {locale}" + ); + } + } +} + +#[test] +fn menu_action_count_matches_spec() { + // M2 spec: 28 custom menu actions + assert_eq!(MenuAction::ALL.len(), 28); +} + +#[test] +fn no_duplicate_i18n_keys() { + let mut keys = std::collections::HashSet::new(); + for &action in MenuAction::ALL { + let key = action.i18n_key(); + assert!(keys.insert(key), "duplicate i18n key: {key}"); + } +} diff --git a/crates/bds-ui/tests/project_flow.rs b/crates/bds-ui/tests/project_flow.rs new file mode 100644 index 0000000..77d517d --- /dev/null +++ b/crates/bds-ui/tests/project_flow.rs @@ -0,0 +1,124 @@ +//! Project flow integration tests. +//! +//! Tests the project lifecycle: create, list, set active, switch, delete. +//! Uses in-memory DB — no Iced window required. + +use bds_core::db::Database; +use bds_core::engine::project; +use tempfile::TempDir; + +fn setup() -> (Database, TempDir) { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + let dir = TempDir::new().unwrap(); + (db, dir) +} + +#[test] +fn create_and_activate_default_project() { + let (db, dir) = setup(); + let data_path = dir.path().join("my-blog"); + + let p = project::create_project( + db.conn(), + "My Blog", + Some(data_path.to_str().unwrap()), + ) + .unwrap(); + + assert_eq!(p.name, "My Blog"); + assert_eq!(p.slug, "my-blog"); + assert!(!p.is_active); + + // Activate it + project::set_active_project(db.conn(), &p.id).unwrap(); + let active = project::get_active_project(db.conn()).unwrap().unwrap(); + assert_eq!(active.id, p.id); +} + +#[test] +fn switch_between_projects() { + let (db, dir) = setup(); + let p1_path = dir.path().join("blog-a"); + let p2_path = dir.path().join("blog-b"); + + let p1 = project::create_project(db.conn(), "Blog A", Some(p1_path.to_str().unwrap())).unwrap(); + let p2 = project::create_project(db.conn(), "Blog B", Some(p2_path.to_str().unwrap())).unwrap(); + + project::set_active_project(db.conn(), &p1.id).unwrap(); + let active = project::get_active_project(db.conn()).unwrap().unwrap(); + assert_eq!(active.id, p1.id); + + // Switch + project::set_active_project(db.conn(), &p2.id).unwrap(); + let active = project::get_active_project(db.conn()).unwrap().unwrap(); + assert_eq!(active.id, p2.id); +} + +#[test] +fn delete_inactive_project() { + let (db, dir) = setup(); + let p1_path = dir.path().join("keep"); + let p2_path = dir.path().join("remove"); + + let p1 = project::create_project(db.conn(), "Keep", Some(p1_path.to_str().unwrap())).unwrap(); + let p2 = project::create_project(db.conn(), "Remove", Some(p2_path.to_str().unwrap())).unwrap(); + + project::set_active_project(db.conn(), &p1.id).unwrap(); + project::delete_project(db.conn(), &p2.id, Some(&p2_path)).unwrap(); + + let list = project::list_projects(db.conn()).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "Keep"); +} + +#[test] +fn cannot_delete_active_project() { + let (db, dir) = setup(); + let p_path = dir.path().join("active"); + let p = project::create_project(db.conn(), "Active", Some(p_path.to_str().unwrap())).unwrap(); + + project::set_active_project(db.conn(), &p.id).unwrap(); + let result = project::delete_project(db.conn(), &p.id, None); + assert!(result.is_err()); +} + +#[test] +fn project_directory_structure_created() { + let (db, dir) = setup(); + let p_path = dir.path().join("structured"); + project::create_project(db.conn(), "Structured", Some(p_path.to_str().unwrap())).unwrap(); + + assert!(p_path.join("posts").is_dir()); + assert!(p_path.join("media").is_dir()); + assert!(p_path.join("meta").is_dir()); + assert!(p_path.join("thumbnails").is_dir()); + assert!(p_path.join("templates").is_dir()); + assert!(p_path.join("scripts").is_dir()); +} + +#[test] +fn default_meta_files_written() { + let (db, dir) = setup(); + let p_path = dir.path().join("meta-test"); + project::create_project(db.conn(), "Meta Test", Some(p_path.to_str().unwrap())).unwrap(); + + let project_json: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(p_path.join("meta/project.json")).unwrap(), + ) + .unwrap(); + assert_eq!(project_json["name"], "Meta Test"); + assert_eq!(project_json["maxPostsPerPage"], 50); + + let categories: Vec = serde_json::from_str( + &std::fs::read_to_string(p_path.join("meta/categories.json")).unwrap(), + ) + .unwrap(); + assert_eq!(categories, vec!["article", "aside", "page", "picture"]); + + let tags: Vec = serde_json::from_str( + &std::fs::read_to_string(p_path.join("meta/tags.json")).unwrap(), + ) + .unwrap(); + assert!(tags.is_empty()); +} diff --git a/locales/ui/de.json b/locales/ui/de.json new file mode 100644 index 0000000..72b9e0a --- /dev/null +++ b/locales/ui/de.json @@ -0,0 +1,98 @@ +{ + "menu.group.file": "Datei", + "menu.group.edit": "Bearbeiten", + "menu.group.view": "Ansicht", + "menu.group.blog": "Blogbereich", + "menu.group.help": "Hilfe", + "menu.item.newPost": "Neuer Beitrag", + "menu.item.importMedia": "Medien importieren...", + "menu.item.save": "Speichern", + "menu.item.openInBrowser": "Im Browser öffnen", + "menu.item.openDataFolder": "Datenordner öffnen", + "menu.item.find": "Suchen", + "menu.item.replace": "Ersetzen", + "menu.item.editPreferences": "Einstellungen bearbeiten", + "menu.item.viewPosts": "Beiträge", + "menu.item.viewMedia": "Medien", + "menu.item.toggleSidebar": "Seitenleiste umschalten", + "menu.item.togglePanel": "Panel umschalten", + "menu.item.publishSelected": "Ausgewählte veröffentlichen", + "menu.item.previewPost": "Beitragsvorschau", + "menu.item.rebuildDatabase": "Datenbank aus Dateien neu aufbauen", + "menu.item.reindexText": "Suchtext neu indizieren", + "menu.item.metadataDiff": "Metadaten-Diff-Werkzeug", + "menu.item.editMenu": "Blog-Menü bearbeiten", + "menu.item.generateSitemap": "Site rendern", + "menu.item.regenerateCalendar": "Kalender neu erzeugen", + "menu.item.validateSite": "Website validieren", + "menu.item.validateTranslations": "Übersetzungen validieren", + "menu.item.fillMissingTranslations": "Fehlende Übersetzungen ausfüllen", + "menu.item.uploadSite": "Website hochladen", + "menu.item.about": "Über Blogging Desktop Server", + "menu.item.openDocumentation": "Dokumentation öffnen", + "menu.item.viewOnGitHub": "Auf GitHub ansehen", + "menu.item.reportIssue": "Problem melden", + "activity.posts": "Beiträge", + "activity.pages": "Seiten", + "activity.media": "Medien", + "activity.scripts": "Skripte", + "activity.tags": "Schlagwörter", + "activity.templates": "Vorlagen", + "activity.aiAssistant": "KI-Assistent", + "activity.import": "Importieren", + "activity.sourceControl": "Versionskontrolle", + "common.save": "Speichern", + "common.cancel": "Abbrechen", + "common.settings": "Einstellungen", + "common.tasks": "Aufgaben", + "common.running": "laufend", + "common.pending": "ausstehend", + "tasks.backgroundTasks": "Hintergrundaufgaben", + "tasks.clearCompleted": "Abgeschlossene löschen", + "tasks.noActive": "Keine aktiven Aufgaben", + "tasks.cancelTask": "Aufgabe abbrechen", + "tasks.triggerTitle": "{running} laufend, {pending} ausstehend", + "app.taskCompleted": "Aufgabe abgeschlossen: {message}", + "app.taskFailed": "Aufgabe fehlgeschlagen: {message}", + "app.databaseRebuildFailed": "Datenbank-Neuaufbau fehlgeschlagen", + "app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen", + "panel.output": "Ausgabe", + "panel.closeTitle": "Panel schließen", + "panel.noRecentTasks": "Keine aktuellen Aufgaben", + "panel.noOutput": "Keine Ausgabe", + "statusBar.posts": "{count} Beiträge", + "statusBar.media": "{count} Medien", + "statusBar.ui": "UI", + "statusBar.uiLanguage": "UI-Sprache", + "statusBar.offlineMode": "Flugmodus", + "statusBar.offlineModeActive": "Flugmodus (aktiv)", + "projectSelector.switchProject": "Projekt wechseln", + "projectSelector.projectsHeader": "Projekte", + "projectSelector.newProject": "Neues Projekt", + "projectSelector.createProject": "Projekt erstellen", + "projectSelector.projectName": "Projektname", + "projectSelector.projectNamePlaceholder": "Mein Blog", + "projectSelector.chooseFolder": "Ordner wählen...", + "projectSelector.useDefaultLocation": "Standardort verwenden", + "projectSelector.deleteProject": "Projekt löschen", + "projectSelector.toast.switched": "Zu {name} gewechselt", + "projectSelector.toast.switchFailed": "Projektwechsel fehlgeschlagen", + "projectSelector.toast.created": "Projekt \"{name}\" erstellt", + "projectSelector.toast.createFailed": "Projekt konnte nicht erstellt werden", + "projectSelector.toast.deleteFailed": "Projekt konnte nicht gelöscht werden", + "tabBar.closeHint": "Schließen", + "tabBar.loading": "Laden...", + "sidebar.noPostsYet": "Noch keine Beiträge", + "sidebar.noPagesYet": "Noch keine Seiten", + "sidebar.noMediaYet": "Noch keine Medien", + "sidebar.loading": "Lädt...", + "sidebar.settingsHeader": "Einstellungen", + "sidebar.tagsHeader": "Schlagwörter", + "language.en": "Englisch", + "language.de": "Deutsch", + "language.fr": "Französisch", + "language.it": "Italienisch", + "language.es": "Spanisch", + "welcome.title": "Willkommen bei bDS", + "welcome.subtitle": "Öffne ein Projekt oder erstelle einen neuen Beitrag." +} diff --git a/locales/ui/en.json b/locales/ui/en.json new file mode 100644 index 0000000..e6568c2 --- /dev/null +++ b/locales/ui/en.json @@ -0,0 +1,98 @@ +{ + "menu.group.file": "File", + "menu.group.edit": "Edit", + "menu.group.view": "View", + "menu.group.blog": "Blog", + "menu.group.help": "Help", + "menu.item.newPost": "New Post", + "menu.item.importMedia": "Import Media...", + "menu.item.save": "Save", + "menu.item.openInBrowser": "Open in Browser", + "menu.item.openDataFolder": "Open Data Folder", + "menu.item.find": "Find", + "menu.item.replace": "Replace", + "menu.item.editPreferences": "Edit Preferences", + "menu.item.viewPosts": "Posts", + "menu.item.viewMedia": "Media", + "menu.item.toggleSidebar": "Toggle Sidebar", + "menu.item.togglePanel": "Toggle Panel", + "menu.item.publishSelected": "Publish Selected", + "menu.item.previewPost": "Preview Post", + "menu.item.rebuildDatabase": "Rebuild Database from Files", + "menu.item.reindexText": "Reindex Search Text", + "menu.item.metadataDiff": "Metadata Diff Tool", + "menu.item.editMenu": "Edit Blog Menu", + "menu.item.generateSitemap": "Render Site", + "menu.item.regenerateCalendar": "Regenerate Calendar", + "menu.item.validateSite": "Validate Site", + "menu.item.validateTranslations": "Validate Translations", + "menu.item.fillMissingTranslations": "Fill Missing Translations", + "menu.item.uploadSite": "Upload Site", + "menu.item.about": "About Blogging Desktop Server", + "menu.item.openDocumentation": "Open Documentation", + "menu.item.viewOnGitHub": "View on GitHub", + "menu.item.reportIssue": "Report Issue", + "activity.posts": "Posts", + "activity.pages": "Pages", + "activity.media": "Media", + "activity.scripts": "Scripts", + "activity.tags": "Tags", + "activity.templates": "Templates", + "activity.aiAssistant": "AI Assistant", + "activity.import": "Import", + "activity.sourceControl": "Source Control", + "common.save": "Save", + "common.cancel": "Cancel", + "common.settings": "Settings", + "common.tasks": "Tasks", + "common.running": "running", + "common.pending": "pending", + "tasks.backgroundTasks": "Background Tasks", + "tasks.clearCompleted": "Clear completed", + "tasks.noActive": "No active tasks", + "tasks.cancelTask": "Cancel task", + "tasks.triggerTitle": "{running} running, {pending} pending", + "app.taskCompleted": "Task completed: {message}", + "app.taskFailed": "Task failed: {message}", + "app.databaseRebuildFailed": "Database rebuild failed", + "app.textReindexFailed": "Text reindex failed", + "panel.output": "Output", + "panel.closeTitle": "Close panel", + "panel.noRecentTasks": "No recent tasks", + "panel.noOutput": "No output", + "statusBar.posts": "{count} posts", + "statusBar.media": "{count} media", + "statusBar.ui": "UI", + "statusBar.uiLanguage": "UI Language", + "statusBar.offlineMode": "Airplane Mode", + "statusBar.offlineModeActive": "Airplane Mode (active)", + "projectSelector.switchProject": "Switch project", + "projectSelector.projectsHeader": "Projects", + "projectSelector.newProject": "New Project", + "projectSelector.createProject": "Create Project", + "projectSelector.projectName": "Project name", + "projectSelector.projectNamePlaceholder": "My Blog", + "projectSelector.chooseFolder": "Choose folder...", + "projectSelector.useDefaultLocation": "Use default location", + "projectSelector.deleteProject": "Delete Project", + "projectSelector.toast.switched": "Switched to {name}", + "projectSelector.toast.switchFailed": "Project switch failed", + "projectSelector.toast.created": "Project \"{name}\" created", + "projectSelector.toast.createFailed": "Failed to create project", + "projectSelector.toast.deleteFailed": "Failed to delete project", + "tabBar.closeHint": "Close", + "tabBar.loading": "Loading...", + "sidebar.noPostsYet": "No posts yet", + "sidebar.noPagesYet": "No pages yet", + "sidebar.noMediaYet": "No media yet", + "sidebar.loading": "Loading...", + "sidebar.settingsHeader": "Settings", + "sidebar.tagsHeader": "Tags", + "language.en": "English", + "language.de": "German", + "language.fr": "French", + "language.it": "Italian", + "language.es": "Spanish", + "welcome.title": "Welcome to bDS", + "welcome.subtitle": "Open a project or create a new post to get started." +} diff --git a/locales/ui/es.json b/locales/ui/es.json new file mode 100644 index 0000000..70d66f1 --- /dev/null +++ b/locales/ui/es.json @@ -0,0 +1,98 @@ +{ + "menu.group.file": "Archivo", + "menu.group.edit": "Editar", + "menu.group.view": "Ver", + "menu.group.blog": "Bitácora", + "menu.group.help": "Ayuda", + "menu.item.newPost": "Nueva entrada", + "menu.item.importMedia": "Importar medios...", + "menu.item.save": "Guardar", + "menu.item.openInBrowser": "Abrir en el navegador", + "menu.item.openDataFolder": "Abrir carpeta de datos", + "menu.item.find": "Buscar", + "menu.item.replace": "Reemplazar", + "menu.item.editPreferences": "Editar preferencias", + "menu.item.viewPosts": "Entradas", + "menu.item.viewMedia": "Medios", + "menu.item.toggleSidebar": "Alternar barra lateral", + "menu.item.togglePanel": "Alternar panel", + "menu.item.publishSelected": "Publicar selección", + "menu.item.previewPost": "Vista previa de entrada", + "menu.item.rebuildDatabase": "Reconstruir base de datos desde archivos", + "menu.item.reindexText": "Reindexar texto de búsqueda", + "menu.item.metadataDiff": "Herramienta diff de metadatos", + "menu.item.editMenu": "Editar menú del blog", + "menu.item.generateSitemap": "Renderizar sitio", + "menu.item.regenerateCalendar": "Regenerar calendario", + "menu.item.validateSite": "Validar sitio", + "menu.item.validateTranslations": "Validar traducciones", + "menu.item.fillMissingTranslations": "Completar traducciones faltantes", + "menu.item.uploadSite": "Subir sitio", + "menu.item.about": "Acerca de Blogging Desktop Server", + "menu.item.openDocumentation": "Abrir documentación", + "menu.item.viewOnGitHub": "Ver en GitHub", + "menu.item.reportIssue": "Reportar problema", + "activity.posts": "Entradas", + "activity.pages": "Páginas", + "activity.media": "Medios", + "activity.scripts": "Scripts", + "activity.tags": "Etiquetas", + "activity.templates": "Plantillas", + "activity.aiAssistant": "Asistente IA", + "activity.import": "Importar", + "activity.sourceControl": "Control de código fuente", + "common.save": "Guardar", + "common.cancel": "Cancelar", + "common.settings": "Configuración", + "common.tasks": "Tareas", + "common.running": "en ejecución", + "common.pending": "pendiente", + "tasks.backgroundTasks": "Tareas en segundo plano", + "tasks.clearCompleted": "Limpiar completadas", + "tasks.noActive": "No hay tareas activas", + "tasks.cancelTask": "Cancelar tarea", + "tasks.triggerTitle": "{running} en ejecución, {pending} pendiente", + "app.taskCompleted": "Tarea completada: {message}", + "app.taskFailed": "Tarea fallida: {message}", + "app.databaseRebuildFailed": "La reconstrucción de la base de datos falló", + "app.textReindexFailed": "La reindexación de texto falló", + "panel.output": "Salida", + "panel.closeTitle": "Cerrar panel", + "panel.noRecentTasks": "No hay tareas recientes", + "panel.noOutput": "Sin salida", + "statusBar.posts": "{count} publicaciones", + "statusBar.media": "{count} medios", + "statusBar.ui": "UI", + "statusBar.uiLanguage": "Idioma de la interfaz", + "statusBar.offlineMode": "Modo avión", + "statusBar.offlineModeActive": "Modo avión (activo)", + "projectSelector.switchProject": "Cambiar proyecto", + "projectSelector.projectsHeader": "Proyectos", + "projectSelector.newProject": "Nuevo proyecto", + "projectSelector.createProject": "Crear proyecto", + "projectSelector.projectName": "Nombre del proyecto", + "projectSelector.projectNamePlaceholder": "Mi blog", + "projectSelector.chooseFolder": "Elegir carpeta...", + "projectSelector.useDefaultLocation": "Usar ubicación predeterminada", + "projectSelector.deleteProject": "Eliminar proyecto", + "projectSelector.toast.switched": "Cambiado a {name}", + "projectSelector.toast.switchFailed": "No se pudo cambiar de proyecto", + "projectSelector.toast.created": "Proyecto \"{name}\" creado", + "projectSelector.toast.createFailed": "No se pudo crear el proyecto", + "projectSelector.toast.deleteFailed": "No se pudo eliminar el proyecto", + "tabBar.closeHint": "Cerrar", + "tabBar.loading": "Cargando...", + "sidebar.noPostsYet": "Aún no hay entradas", + "sidebar.noPagesYet": "Aún no hay páginas", + "sidebar.noMediaYet": "Aún no hay medios", + "sidebar.loading": "Cargando...", + "sidebar.settingsHeader": "Configuración", + "sidebar.tagsHeader": "Etiquetas", + "language.en": "Inglés", + "language.de": "Alemán", + "language.fr": "Francés", + "language.it": "Italiano", + "language.es": "Español", + "welcome.title": "Bienvenido a bDS", + "welcome.subtitle": "Abre un proyecto o crea una nueva entrada para comenzar." +} diff --git a/locales/ui/fr.json b/locales/ui/fr.json new file mode 100644 index 0000000..c809a6d --- /dev/null +++ b/locales/ui/fr.json @@ -0,0 +1,98 @@ +{ + "menu.group.file": "Fichier", + "menu.group.edit": "Édition", + "menu.group.view": "Affichage", + "menu.group.blog": "Espace blog", + "menu.group.help": "Aide", + "menu.item.newPost": "Nouvel article", + "menu.item.importMedia": "Importer des médias...", + "menu.item.save": "Enregistrer", + "menu.item.openInBrowser": "Ouvrir dans le navigateur", + "menu.item.openDataFolder": "Ouvrir le dossier de données", + "menu.item.find": "Rechercher", + "menu.item.replace": "Remplacer", + "menu.item.editPreferences": "Modifier les préférences", + "menu.item.viewPosts": "Articles", + "menu.item.viewMedia": "Médias", + "menu.item.toggleSidebar": "Basculer la barre latérale", + "menu.item.togglePanel": "Basculer le panneau", + "menu.item.publishSelected": "Publier la sélection", + "menu.item.previewPost": "Aperçu de l'article", + "menu.item.rebuildDatabase": "Reconstruire la base de données", + "menu.item.reindexText": "Réindexer le texte de recherche", + "menu.item.metadataDiff": "Outil de diff des métadonnées", + "menu.item.editMenu": "Modifier le menu du blog", + "menu.item.generateSitemap": "Rendre le site", + "menu.item.regenerateCalendar": "Régénérer le calendrier", + "menu.item.validateSite": "Valider le site", + "menu.item.validateTranslations": "Valider les traductions", + "menu.item.fillMissingTranslations": "Compléter les traductions manquantes", + "menu.item.uploadSite": "Publier le site", + "menu.item.about": "À propos de Blogging Desktop Server", + "menu.item.openDocumentation": "Ouvrir la documentation", + "menu.item.viewOnGitHub": "Voir sur GitHub", + "menu.item.reportIssue": "Signaler un problème", + "activity.posts": "Articles", + "activity.pages": "Pages du site", + "activity.media": "Médias", + "activity.scripts": "Scripts", + "activity.tags": "Étiquettes", + "activity.templates": "Modèles", + "activity.aiAssistant": "Assistant IA", + "activity.import": "Importation", + "activity.sourceControl": "Contrôle de source", + "common.save": "Enregistrer", + "common.cancel": "Annuler", + "common.settings": "Paramètres", + "common.tasks": "Tâches", + "common.running": "en cours", + "common.pending": "en attente", + "tasks.backgroundTasks": "Tâches en arrière-plan", + "tasks.clearCompleted": "Effacer terminé", + "tasks.noActive": "Aucune tâche active", + "tasks.cancelTask": "Annuler la tâche", + "tasks.triggerTitle": "{running} en cours, {pending} en attente", + "app.taskCompleted": "Tâche terminée : {message}", + "app.taskFailed": "Échec de la tâche : {message}", + "app.databaseRebuildFailed": "Échec de la reconstruction de la base de données", + "app.textReindexFailed": "Échec de la réindexation du texte", + "panel.output": "Sortie", + "panel.closeTitle": "Fermer le panneau", + "panel.noRecentTasks": "Aucune tâche récente", + "panel.noOutput": "Aucune sortie", + "statusBar.posts": "{count} articles", + "statusBar.media": "{count} médias", + "statusBar.ui": "UI", + "statusBar.uiLanguage": "Langue de l'interface", + "statusBar.offlineMode": "Mode avion", + "statusBar.offlineModeActive": "Mode avion (actif)", + "projectSelector.switchProject": "Changer de projet", + "projectSelector.projectsHeader": "Projets", + "projectSelector.newProject": "Nouveau projet", + "projectSelector.createProject": "Créer le projet", + "projectSelector.projectName": "Nom du projet", + "projectSelector.projectNamePlaceholder": "Mon blog", + "projectSelector.chooseFolder": "Choisir un dossier...", + "projectSelector.useDefaultLocation": "Utiliser l'emplacement par défaut", + "projectSelector.deleteProject": "Supprimer le projet", + "projectSelector.toast.switched": "Projet actif : {name}", + "projectSelector.toast.switchFailed": "Échec du changement de projet", + "projectSelector.toast.created": "Projet \"{name}\" créé", + "projectSelector.toast.createFailed": "Impossible de créer le projet", + "projectSelector.toast.deleteFailed": "Impossible de supprimer le projet", + "tabBar.closeHint": "Fermer", + "tabBar.loading": "Chargement...", + "sidebar.noPostsYet": "Aucun article pour le moment", + "sidebar.noPagesYet": "Aucune page pour le moment", + "sidebar.noMediaYet": "Aucun média pour le moment", + "sidebar.loading": "Chargement...", + "sidebar.settingsHeader": "Paramètres", + "sidebar.tagsHeader": "Étiquettes", + "language.en": "Anglais", + "language.de": "Allemand", + "language.fr": "Français", + "language.it": "Italien", + "language.es": "Espagnol", + "welcome.title": "Bienvenue dans bDS", + "welcome.subtitle": "Ouvrez un projet ou créez un nouvel article pour commencer." +} diff --git a/locales/ui/it.json b/locales/ui/it.json new file mode 100644 index 0000000..ee812ee --- /dev/null +++ b/locales/ui/it.json @@ -0,0 +1,98 @@ +{ + "menu.group.file": "Archivio", + "menu.group.edit": "Modifica", + "menu.group.view": "Vista", + "menu.group.blog": "Sezione blog", + "menu.group.help": "Aiuto", + "menu.item.newPost": "Nuovo post", + "menu.item.importMedia": "Importa media...", + "menu.item.save": "Salva", + "menu.item.openInBrowser": "Apri nel browser", + "menu.item.openDataFolder": "Apri cartella dati", + "menu.item.find": "Trova", + "menu.item.replace": "Sostituisci", + "menu.item.editPreferences": "Modifica preferenze", + "menu.item.viewPosts": "Post", + "menu.item.viewMedia": "Contenuti media", + "menu.item.toggleSidebar": "Attiva/disattiva barra laterale", + "menu.item.togglePanel": "Attiva/disattiva pannello", + "menu.item.publishSelected": "Pubblica selezionato", + "menu.item.previewPost": "Anteprima post", + "menu.item.rebuildDatabase": "Ricostruisci database dai file", + "menu.item.reindexText": "Reindicizza testo di ricerca", + "menu.item.metadataDiff": "Strumento diff metadati", + "menu.item.editMenu": "Modifica menu blog", + "menu.item.generateSitemap": "Renderizza sito", + "menu.item.regenerateCalendar": "Rigenera calendario", + "menu.item.validateSite": "Valida sito", + "menu.item.validateTranslations": "Valida traduzioni", + "menu.item.fillMissingTranslations": "Completa le traduzioni mancanti", + "menu.item.uploadSite": "Carica sito", + "menu.item.about": "Informazioni su Blogging Desktop Server", + "menu.item.openDocumentation": "Apri documentazione", + "menu.item.viewOnGitHub": "Visualizza su GitHub", + "menu.item.reportIssue": "Segnala problema", + "activity.posts": "Post", + "activity.pages": "Pagine", + "activity.media": "Contenuti media", + "activity.scripts": "Script", + "activity.tags": "Tag", + "activity.templates": "Modelli", + "activity.aiAssistant": "Assistente IA", + "activity.import": "Importa", + "activity.sourceControl": "Controllo sorgente", + "common.save": "Salva", + "common.cancel": "Annulla", + "common.settings": "Impostazioni", + "common.tasks": "Attività", + "common.running": "in esecuzione", + "common.pending": "in attesa", + "tasks.backgroundTasks": "Attività in background", + "tasks.clearCompleted": "Cancella completate", + "tasks.noActive": "Nessuna attività attiva", + "tasks.cancelTask": "Annulla attività", + "tasks.triggerTitle": "{running} in esecuzione, {pending} in attesa", + "app.taskCompleted": "Attività completata: {message}", + "app.taskFailed": "Attività non riuscita: {message}", + "app.databaseRebuildFailed": "Ricostruzione database non riuscita", + "app.textReindexFailed": "Reindicizzazione testo non riuscita", + "panel.output": "Uscita", + "panel.closeTitle": "Chiudi pannello", + "panel.noRecentTasks": "Nessuna attività recente", + "panel.noOutput": "Nessun output", + "statusBar.posts": "{count} articoli", + "statusBar.media": "{count} media", + "statusBar.ui": "UI", + "statusBar.uiLanguage": "Lingua interfaccia", + "statusBar.offlineMode": "Modalità aereo", + "statusBar.offlineModeActive": "Modalità aereo (attiva)", + "projectSelector.switchProject": "Cambia progetto", + "projectSelector.projectsHeader": "Progetti", + "projectSelector.newProject": "Nuovo progetto", + "projectSelector.createProject": "Crea progetto", + "projectSelector.projectName": "Nome progetto", + "projectSelector.projectNamePlaceholder": "Il mio blog", + "projectSelector.chooseFolder": "Scegli cartella...", + "projectSelector.useDefaultLocation": "Usa percorso predefinito", + "projectSelector.deleteProject": "Elimina progetto", + "projectSelector.toast.switched": "Passato a {name}", + "projectSelector.toast.switchFailed": "Cambio progetto non riuscito", + "projectSelector.toast.created": "Progetto \"{name}\" creato", + "projectSelector.toast.createFailed": "Creazione progetto non riuscita", + "projectSelector.toast.deleteFailed": "Eliminazione progetto non riuscita", + "tabBar.closeHint": "Chiudi", + "tabBar.loading": "Caricamento...", + "sidebar.noPostsYet": "Nessun post", + "sidebar.noPagesYet": "Nessuna pagina", + "sidebar.noMediaYet": "Nessun media", + "sidebar.loading": "Caricamento...", + "sidebar.settingsHeader": "Impostazioni", + "sidebar.tagsHeader": "Tag", + "language.en": "Inglese", + "language.de": "Tedesco", + "language.fr": "Francese", + "language.it": "Italiano", + "language.es": "Spagnolo", + "welcome.title": "Benvenuto in bDS", + "welcome.subtitle": "Apri un progetto o crea un nuovo post per iniziare." +}