GUI plugins #9

Merged
NotAShelf merged 46 commits from notashelf/push-mytsqvppsvxu into main 2026-03-12 16:53:43 +00:00
2 changed files with 196 additions and 40 deletions
Showing only changes of commit 6e442065b1 - Show all commits

pinakes-ui: integrate plugin registry into app navigation and routing

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7c4593d93693bf08555a0b5f89a67aea6a6a6964
raf 2026-03-10 00:02:31 +03:00
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -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();

View file

@ -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"
);
}
}