From 6e442065b1be7aef2cd5f8ad3d300bf8350490f0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:02:31 +0300 Subject: [PATCH] pinakes-ui: integrate plugin registry into app navigation and routing Signed-off-by: NotAShelf Change-Id: I7c4593d93693bf08555a0b5f89a67aea6a6a6964 --- crates/pinakes-ui/src/app.rs | 110 ++++++++++++----- crates/pinakes-ui/src/plugin_ui/registry.rs | 126 ++++++++++++++++++-- 2 files changed, 196 insertions(+), 40 deletions(-) diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs index 4ce21e1..972da40 100644 --- a/crates/pinakes-ui/src/app.rs +++ b/crates/pinakes-ui/src/app.rs @@ -59,6 +59,7 @@ use crate::{ tags, tasks, }, + plugin_ui::{PluginRegistry, PluginViewRenderer}, styles, }; @@ -80,6 +81,10 @@ enum View { Settings, Database, Graph, + PluginView { + plugin_id: String, + page_id: String, + }, } impl View { @@ -99,13 +104,13 @@ impl View { Self::Settings => "Settings", Self::Database => "Database", Self::Graph => "Note Graph", + Self::PluginView { .. } => "Plugin", } } } #[component] pub fn App() -> Element { - // Phase 1.3: Auth support let base_url = std::env::var("PINAKES_SERVER_URL") .unwrap_or_else(|_| "http://localhost:3000".into()); let api_key = std::env::var("PINAKES_API_KEY").ok(); @@ -139,7 +144,6 @@ pub fn App() -> Element { let mut viewing_collection = use_signal(|| Option::::None); let mut collection_members = use_signal(Vec::::new); - // Phase 4A: Book management let mut books_list = use_signal(Vec::::new); let mut books_series_list = use_signal(Vec::::new); @@ -160,31 +164,24 @@ pub fn App() -> Element { let mut loading = use_signal(|| true); let mut load_error = use_signal(|| Option::::None); - // Phase 1.4: Toast queue let mut toast_queue = use_signal(Vec::<(String, bool, usize)>::new); - // Phase 5.1: Search pagination let mut search_page = use_signal(|| 0u64); let search_page_size = use_signal(|| 50u64); let mut last_search_query = use_signal(String::new); let mut last_search_sort = use_signal(|| Option::::None); - // Phase 3.6: Saved searches let mut saved_searches = use_signal(Vec::::new); - // Phase 6.1: Audit pagination & filter let mut audit_page = use_signal(|| 0u64); let audit_page_size = use_signal(|| 200u64); let audit_total_count = use_signal(|| 0u64); let mut audit_filter = use_signal(|| "All".to_string()); - // Phase 6.2: Scan progress let mut scan_progress = use_signal(|| Option::::None); - // Phase 7.1: Help overlay let mut show_help = use_signal(|| false); - // Phase 8: Sidebar collapse let mut sidebar_collapsed = use_signal(|| false); // Auth state @@ -195,7 +192,8 @@ pub fn App() -> Element { let mut auto_play_media = use_signal(|| false); let mut play_queue = use_signal(PlayQueue::default); - // Theme state (Phase 3.3) + let mut plugin_registry = use_signal(PluginRegistry::default); + let mut current_theme = use_signal(|| "dark".to_string()); let mut system_prefers_dark = use_signal(|| true); @@ -287,7 +285,7 @@ pub fn App() -> Element { }); }); - // Load initial data (Phase 2.2: pass sort to list_media) + // Load initial data let client_init = client.read().clone(); let init_sort = media_sort.read().clone(); use_effect(move || { @@ -311,7 +309,6 @@ pub fn App() -> Element { if let Ok(c) = client.list_collections().await { collections_list.set(c); } - // Phase 3.6: Load saved searches if let Ok(ss) = client.list_saved_searches().await { saved_searches.set(ss); } @@ -319,7 +316,22 @@ pub fn App() -> Element { }); }); - // Phase 1.4: Toast helper with queue support + use_effect(move || { + let c = client.read().clone(); + spawn(async move { + match c.get_plugin_ui_pages().await { + Ok(pages) => { + let mut reg = PluginRegistry::default(); + for (plugin_id, page) in pages { + reg.register_page(plugin_id, page); + } + plugin_registry.set(reg); + }, + Err(e) => tracing::debug!("Plugin pages unavailable: {e}"), + } + }); + }); + let mut show_toast = move |msg: String, is_error: bool| { let id = TOAST_ID_COUNTER.fetch_add(1, Ordering::Relaxed); toast_queue.write().push((msg, is_error, id)); @@ -334,7 +346,6 @@ pub fn App() -> Element { }); }; - // Helper: refresh media list with current pagination (Phase 2.2: pass sort) let refresh_media = { let client = client.read().clone(); move || { @@ -355,7 +366,6 @@ pub fn App() -> Element { } }; - // Helper: refresh tags let refresh_tags = { let client = client.read().clone(); move || { @@ -368,7 +378,6 @@ pub fn App() -> Element { } }; - // Helper: refresh collections let refresh_collections = { let client = client.read().clone(); move || { @@ -381,7 +390,6 @@ pub fn App() -> Element { } }; - // Helper: refresh audit with pagination and filter (Phase 6.1) let refresh_audit = { let client = client.read().clone(); move || { @@ -440,7 +448,6 @@ pub fn App() -> Element { loading: *login_loading.read(), } } else { - // Phase 7.1: Keyboard shortcuts div { class: if *effective_theme.read() == "light" { "app theme-light" } else { "app" }, tabindex: "0", @@ -744,6 +751,36 @@ pub fn App() -> Element { } } + if !plugin_registry.read().is_empty() { + div { class: "nav-section", + div { class: "nav-label", "Plugins" } + for page in plugin_registry.read().all_pages() { + { + let pid = page.plugin_id.clone(); + let pageid = page.page.id.clone(); + let title = page.page.title.clone(); + let is_active = *current_view.read() + == View::PluginView { + plugin_id: pid.clone(), + page_id: pageid.clone(), + }; + rsx! { + button { + class: if is_active { "nav-item active" } else { "nav-item" }, + onclick: move |_| { + current_view.set(View::PluginView { + plugin_id: pid.clone(), + page_id: pageid.clone(), + }); + }, + span { class: "nav-item-text", "{title}" } + } + } + } + } + } + } + div { class: "sidebar-spacer" } // Show import progress in sidebar when not on import page @@ -881,17 +918,6 @@ pub fn App() -> Element { } { - // Phase 2.2: Sort wiring - actually refetch with sort - // Phase 4.1 + 4.2: Search improvements - // Phase 3.1 + 3.2: Detail view enhancements - // Phase 3.2: Delete from detail navigates back and refreshes - // Phase 5.1: Tags on_delete - confirmation handled inside Tags component - // Phase 5.2: Collections enhancements - // Phase 5.2: Navigate to detail when clicking a collection member - // Phase 5.2: Add member to collection - // Phase 6.1: Audit improvements - // Phase 6.2: Scan progress - // Phase 6.2: Scan with polling for progress // Poll scan status until done // Refresh duplicates list // Reload full config @@ -1178,7 +1204,6 @@ pub fn App() -> Element { }); } }, - // Phase 3.6: Saved searches saved_searches: saved_searches.read().clone(), on_save_search: { let client = client.read().clone(); @@ -2673,12 +2698,34 @@ pub fn App() -> Element { } } } + View::PluginView { + ref plugin_id, + ref page_id, + } => { + let pid = plugin_id.clone(); + let pageid = page_id.clone(); + let page_opt = + plugin_registry.read().get_page(&pid, &pageid).cloned(); + match page_opt { + Some(plugin_page) => rsx! { + PluginViewRenderer { + plugin_id: pid, + page: plugin_page.page, + client, + } + }, + None => rsx! { + div { class: "plugin-not-found", + "Plugin page not found: {pageid}" + } + }, + } + } } } } } - // Phase 7.1: Help overlay if *show_help.read() { div { class: "help-overlay", @@ -2747,7 +2794,6 @@ pub fn App() -> Element { } } // end else (auth not required) - // Phase 1.4: Toast queue - show up to 3 stacked from bottom div { class: "toast-container", { let toasts = toast_queue.read().clone(); diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs index 473509d..a50c533 100644 --- a/crates/pinakes-ui/src/plugin_ui/registry.rs +++ b/crates/pinakes-ui/src/plugin_ui/registry.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; use dioxus::prelude::*; -use pinakes_plugin_api::UiPage; +use pinakes_plugin_api::{UiPage, UiWidget}; use crate::client::ApiClient; @@ -39,15 +39,17 @@ impl PluginPage { } } -/// Registry of all plugin-provided UI pages +/// Registry of all plugin-provided UI pages and widgets /// -/// This is typically stored as a context value in the Dioxus tree. +/// This is typically stored as a signal in the Dioxus tree. #[derive(Debug, Clone)] pub struct PluginRegistry { /// API client for fetching pages from server client: ApiClient, /// Cached pages: (plugin_id, page_id) -> PluginPage pages: HashMap<(String, String), PluginPage>, + /// Cached widgets: (plugin_id, widget_id) -> UiWidget + widgets: Vec<(String, UiWidget)>, /// Last refresh timestamp last_refresh: Option>, } @@ -58,17 +60,15 @@ impl PluginRegistry { Self { client, pages: HashMap::new(), + widgets: Vec::new(), last_refresh: None, } } /// Create a new registry with pre-loaded pages - pub fn with_pages( - client: ApiClient, - pages: Vec<(String, String, UiPage)>, - ) -> Self { + pub fn with_pages(client: ApiClient, pages: Vec<(String, UiPage)>) -> Self { let mut registry = Self::new(client); - for (plugin_id, _page_id, page) in pages { + for (plugin_id, page) in pages { registry.register_page(plugin_id, page); } registry @@ -93,6 +93,16 @@ impl PluginRegistry { .get(&(plugin_id.to_string(), page_id.to_string())) } + /// Register a widget from a plugin + pub fn register_widget(&mut self, plugin_id: String, widget: UiWidget) { + self.widgets.push((plugin_id, widget)); + } + + /// Get all widgets (for use with WidgetContainer) + pub fn all_widgets(&self) -> Vec<(String, UiWidget)> { + self.widgets.clone() + } + /// Get all pages pub fn all_pages(&self) -> Vec<&PluginPage> { self.pages.values().collect() @@ -122,6 +132,7 @@ impl PluginRegistry { match self.client.get_plugin_ui_pages().await { Ok(pages) => { self.pages.clear(); + self.widgets.clear(); for (plugin_id, page) in pages { self.register_page(plugin_id, page); } @@ -245,4 +256,103 @@ mod tests { assert_eq!(routes[0].1, "page1"); assert_eq!(routes[0].2, "/plugins/plugin1/page1"); } + + #[test] + fn test_register_widget_and_all_widgets() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + let widget: UiWidget = serde_json::from_value(serde_json::json!({ + "id": "my-widget", + "target": "library_header", + "content": { "type": "badge", "text": "hello", "variant": "default" } + })) + .unwrap(); + + assert!(registry.all_widgets().is_empty()); + registry.register_widget("test-plugin".to_string(), widget.clone()); + let widgets = registry.all_widgets(); + assert_eq!(widgets.len(), 1); + assert_eq!(widgets[0].0, "test-plugin"); + assert_eq!(widgets[0].1.id, "my-widget"); + } + + #[test] + fn test_with_pages_builds_registry() { + let client = ApiClient::default(); + let pages = vec![ + ("plugin1".to_string(), create_test_page("page1", "Page 1")), + ("plugin2".to_string(), create_test_page("page2", "Page 2")), + ]; + + let registry = PluginRegistry::with_pages(client, pages); + assert_eq!(registry.len(), 2); + assert!(registry.get_page("plugin1", "page1").is_some()); + assert!(registry.get_page("plugin2", "page2").is_some()); + } + + #[test] + fn test_register_page_overwrites_same_key() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + registry + .register_page("plugin1".to_string(), create_test_page("p", "Original")); + registry + .register_page("plugin1".to_string(), create_test_page("p", "Updated")); + + assert_eq!(registry.len(), 1); + assert_eq!( + registry.get_page("plugin1", "p").unwrap().page.title, + "Updated" + ); + } + + #[test] + fn test_default_registry_is_empty() { + let registry = PluginRegistry::default(); + assert!(registry.is_empty()); + assert_eq!(registry.len(), 0); + assert!(registry.last_refresh().is_none()); + } + + #[test] + fn test_all_pages_returns_references() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + registry.register_page("p1".to_string(), create_test_page("a", "A")); + registry.register_page("p2".to_string(), create_test_page("b", "B")); + + let pages = registry.all_pages(); + assert_eq!(pages.len(), 2); + let titles: Vec<&str> = + pages.iter().map(|p| p.page.title.as_str()).collect(); + assert!(titles.contains(&"A")); + assert!(titles.contains(&"B")); + } + + #[test] + fn test_different_plugins_same_page_id_both_stored() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + registry.register_page( + "plugin-a".to_string(), + create_test_page("home", "A Home"), + ); + registry.register_page( + "plugin-b".to_string(), + create_test_page("home", "B Home"), + ); + + assert_eq!(registry.len(), 2); + assert_eq!( + registry.get_page("plugin-a", "home").unwrap().page.title, + "A Home" + ); + assert_eq!( + registry.get_page("plugin-b", "home").unwrap().page.title, + "B Home" + ); + } }