pinakes-ui: integrate plugin registry into app navigation and routing
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I7c4593d93693bf08555a0b5f89a67aea6a6a6964
This commit is contained in:
parent
de913e54bc
commit
6e442065b1
2 changed files with 196 additions and 40 deletions
|
|
@ -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::<String>::None);
|
||||
let mut collection_members = use_signal(Vec::<MediaResponse>::new);
|
||||
|
||||
// Phase 4A: Book management
|
||||
let mut books_list = use_signal(Vec::<MediaResponse>::new);
|
||||
let mut books_series_list =
|
||||
use_signal(Vec::<crate::client::SeriesSummary>::new);
|
||||
|
|
@ -160,31 +164,24 @@ pub fn App() -> Element {
|
|||
let mut loading = use_signal(|| true);
|
||||
let mut load_error = use_signal(|| Option::<String>::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::<String>::None);
|
||||
|
||||
// Phase 3.6: Saved searches
|
||||
let mut saved_searches = use_signal(Vec::<SavedSearchResponse>::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::<ScanStatusResponse>::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();
|
||||
|
|
|
|||
|
|
@ -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<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue