diff --git a/examples/plugins/media-stats-ui/Cargo.lock b/examples/plugins/media-stats-ui/Cargo.lock new file mode 100644 index 0000000..882e3ef --- /dev/null +++ b/examples/plugins/media-stats-ui/Cargo.lock @@ -0,0 +1,48 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "dlmalloc" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6738d2e996274e499bc7b0d693c858b7720b9cd2543a0643a3087e6cb0a4fa16" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "media-stats-ui" +version = "1.0.0" +dependencies = [ + "dlmalloc", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/examples/plugins/media-stats-ui/Cargo.toml b/examples/plugins/media-stats-ui/Cargo.toml new file mode 100644 index 0000000..f3004cc --- /dev/null +++ b/examples/plugins/media-stats-ui/Cargo.toml @@ -0,0 +1,20 @@ +[workspace] + +[package] +name = "media-stats-ui" +version = "1.0.0" +edition = "2024" +description = "Library statistics dashboard and tag manager, a UI-only Pinakes plugin" +license = "EUPL-1.2" + +[lib] +name = "media_stats_ui" +crate-type = ["cdylib"] + +[dependencies] +dlmalloc = { version = "0.2.12", features = ["global"] } + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/examples/plugins/media-stats-ui/pages/stats.json b/examples/plugins/media-stats-ui/pages/stats.json new file mode 100644 index 0000000..03961a0 --- /dev/null +++ b/examples/plugins/media-stats-ui/pages/stats.json @@ -0,0 +1,132 @@ +{ + "id": "stats", + "title": "Library Statistics", + "route": "/plugins/media-stats-ui/stats", + "icon": "chart-bar", + "layout": { + "type": "tabs", + "default_tab": 0, + "tabs": [ + { + "label": "Overview", + "content": { + "type": "container", + "gap": 24, + "children": [ + { + "type": "heading", + "level": 2, + "content": "Library Statistics" + }, + { + "type": "text", + "content": "Live summary of your media library. Refreshes every 30 seconds.", + "variant": "secondary" + }, + { + "type": "card", + "title": "Summary", + "content": [ + { + "type": "description_list", + "data": "stats", + "horizontal": true + } + ] + }, + { + "type": "chart", + "chart_type": "bar", + "data": "type-breakdown", + "title": "Files by Type", + "x_axis_label": "Media Type", + "y_axis_label": "Count", + "height": 280 + } + ] + } + }, + { + "label": "Recent Files", + "content": { + "type": "container", + "gap": 16, + "children": [ + { + "type": "heading", + "level": 2, + "content": "Recently Added" + }, + { + "type": "data_table", + "data": "recent", + "sortable": true, + "filterable": true, + "page_size": 10, + "columns": [ + { + "key": "title", + "header": "Title" + }, + { + "key": "media_type", + "header": "Type" + }, + { + "key": "file_size", + "header": "Size", + "data_type": "file_size" + }, + { + "key": "created_at", + "header": "Added", + "data_type": "date_time" + } + ] + } + ] + } + }, + { + "label": "Media Grid", + "content": { + "type": "container", + "gap": 16, + "children": [ + { + "type": "heading", + "level": 2, + "content": "Browse Media" + }, + { + "type": "media_grid", + "data": "recent", + "columns": 4, + "gap": 12 + } + ] + } + } + ] + }, + "data_sources": { + "stats": { + "type": "endpoint", + "path": "/api/v1/statistics", + "poll_interval": 30 + }, + "recent": { + "type": "endpoint", + "path": "/api/v1/media" + }, + "type-breakdown": { + "type": "static", + "value": [ + { "type": "Audio", "count": 0 }, + { "type": "Video", "count": 0 }, + { "type": "Image", "count": 0 }, + { "type": "Document", "count": 0 } + ] + } + } +} diff --git a/examples/plugins/media-stats-ui/pages/tag-manager.json b/examples/plugins/media-stats-ui/pages/tag-manager.json new file mode 100644 index 0000000..30b3c2f --- /dev/null +++ b/examples/plugins/media-stats-ui/pages/tag-manager.json @@ -0,0 +1,126 @@ +{ + "id": "tag-manager", + "title": "Tag Manager", + "route": "/plugins/media-stats-ui/tag-manager", + "icon": "tag", + "layout": { + "type": "tabs", + "default_tab": 0, + "tabs": [ + { + "label": "All Tags", + "content": { + "type": "container", + "gap": 16, + "children": [ + { + "type": "heading", + "level": 2, + "content": "Manage Tags" + }, + { + "type": "conditional", + "condition": { + "op": "eq", + "left": { "function": "len", "args": ["tags"] }, + "right": 0 + }, + "then": { + "type": "text", + "content": "No tags yet. Use the 'Create Tag' tab to add one.", + "variant": "secondary" + }, + "else": { + "type": "data_table", + "data": "tags", + "sortable": true, + "filterable": true, + "page_size": 20, + "columns": [ + { "key": "name", "header": "Tag Name" }, + { "key": "color", "header": "Color" }, + { "key": "item_count", "header": "Items", "data_type": "number" } + ] + } + } + ] + } + }, + { + "label": "Create Tag", + "content": { + "type": "container", + "gap": 24, + "children": [ + { + "type": "heading", + "level": 2, + "content": "Create New Tag" + }, + { + "type": "text", + "content": "Tags are used to organise media items. Choose a name and an optional colour.", + "variant": "secondary" + }, + { + "type": "form", + "submit_label": "Create Tag", + "submit_action": "create-tag", + "cancel_label": "Reset", + "fields": [ + { + "id": "name", + "label": "Tag Name", + "type": { "type": "text", "max_length": 64 }, + "required": true, + "placeholder": "e.g. favourite, to-watch, archived", + "help_text": "Must be unique. Alphanumeric characters, spaces, and hyphens.", + "validation": [ + { "type": "min_length", "value": 1 }, + { "type": "max_length", "value": 64 }, + { "type": "pattern", "regex": "^[a-zA-Z0-9 \\-]+$" } + ] + }, + { + "id": "color", + "label": "Colour", + "type": { + "type": "select", + "options": [ + { "value": "#ef4444", "label": "Red" }, + { "value": "#f97316", "label": "Orange" }, + { "value": "#eab308", "label": "Yellow" }, + { "value": "#22c55e", "label": "Green" }, + { "value": "#3b82f6", "label": "Blue" }, + { "value": "#8b5cf6", "label": "Purple" }, + { "value": "#ec4899", "label": "Pink" }, + { "value": "#6b7280", "label": "Grey" } + ] + }, + "required": false, + "default_value": "#3b82f6", + "help_text": "Optional accent colour shown beside the tag." + } + ] + } + ] + } + } + ] + }, + "data_sources": { + "tags": { + "type": "endpoint", + "path": "/api/v1/tags", + "poll_interval": 0 + } + }, + "actions": { + "create-tag": { + "method": "POST", + "path": "/api/v1/tags", + "success_message": "Tag created successfully!", + "error_message": "Failed to create tag: the name may already be in use." + } + } +} diff --git a/examples/plugins/media-stats-ui/plugin.toml b/examples/plugins/media-stats-ui/plugin.toml new file mode 100644 index 0000000..f65def5 --- /dev/null +++ b/examples/plugins/media-stats-ui/plugin.toml @@ -0,0 +1,39 @@ +[plugin] +name = "media-stats-ui" +version = "1.0.0" +api_version = "1.0" +author = "Pinakes Contributors" +description = "Library statistics dashboard and tag manager UI plugin" +homepage = "https://github.com/notashelf/pinakes" +license = "EUPL-1.2" +kind = ["ui_page"] + +[plugin.binary] +wasm = "media_stats_ui.wasm" + +[capabilities] +network = false + +[capabilities.filesystem] +read = [] +write = [] + +[ui] +required_endpoints = ["/api/v1/statistics", "/api/v1/media"] + +# UI pages +[[ui.pages]] +file = "pages/stats.json" + +[[ui.pages]] +file = "pages/tag-manager.json" + +# Widgets injected into host views +[[ui.widgets]] +id = "stats-badge" +target = "library_header" + +[ui.widgets.content] +type = "badge" +text = "Stats" +variant = "info" diff --git a/examples/plugins/media-stats-ui/src/lib.rs b/examples/plugins/media-stats-ui/src/lib.rs new file mode 100644 index 0000000..c11a346 --- /dev/null +++ b/examples/plugins/media-stats-ui/src/lib.rs @@ -0,0 +1,101 @@ +//! Media Stats UI - Pinakes plugin +//! +//! A UI-only plugin that adds a library statistics dashboard and a tag manager +//! page. All UI definitions live in `pages/stats.json` and +//! `pages/tag-manager.json`; this WASM binary provides the minimum lifecycle +//! surface the host runtime requires. +//! +//! This plugin is kind = ["ui_page"]: no media-type, metadata, thumbnail, or +//! event-handler extension points are needed. The host will never call them, +//! but exporting them avoids linker warnings if the host performs capability +//! discovery via symbol inspection. + +#![no_std] + +extern crate alloc; + +use core::alloc::Layout; + +#[global_allocator] +static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; + +#[panic_handler] +fn panic(_: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +// Host functions provided by the Pinakes runtime. +unsafe extern "C" { + // Write a result value back to the host (ptr + byte length). + fn host_set_result(ptr: i32, len: i32); + + // Emit a structured log message to the host logger. + // `level` mirrors tracing severity: 0=trace 1=debug 2=info 3=warn 4=error + fn host_log(level: i32, ptr: i32, len: i32); +} + +/// # Safety +/// +/// `json` is a valid slice; the host copies the bytes before +/// returning so there are no lifetime concerns. +fn set_response(json: &[u8]) { + unsafe { host_set_result(json.as_ptr() as i32, json.len() as i32) } +} + +/// # Safety +/// +/// Same as [`set_response`] +fn log_info(msg: &[u8]) { + unsafe { host_log(2, msg.as_ptr() as i32, msg.len() as i32) } +} + +/// Allocate a buffer for the host to write request data into. +/// +/// # Returns +/// +/// The byte offset of the allocation, or -1 on failure. +/// +/// # Safety +/// +/// Size is positive; Layout construction cannot fail for align=1. +#[unsafe(no_mangle)] +pub extern "C" fn alloc(size: i32) -> i32 { + if size <= 0 { + return 0; + } + unsafe { + let layout = Layout::from_size_align_unchecked(size as usize, 1); + let ptr = alloc::alloc::alloc(layout); + if ptr.is_null() { -1 } else { ptr as i32 } + } +} + +/// Called once after the plugin is loaded. Returns 0 on success. +#[unsafe(no_mangle)] +pub extern "C" fn initialize() -> i32 { + log_info(b"media-stats-ui: initialized"); + 0 +} + +/// Called before the plugin is unloaded. Returns 0 on success. +#[unsafe(no_mangle)] +pub extern "C" fn shutdown() -> i32 { + log_info(b"media-stats-ui: shutdown"); + 0 +} + +/// # Returns +/// +/// an empty JSON array; this plugin adds no custom media types. +#[unsafe(no_mangle)] +pub extern "C" fn supported_media_types(_ptr: i32, _len: i32) { + set_response(b"[]"); +} + +/// # Returns +/// +/// An empty JSON array; this plugin handles no event types. +#[unsafe(no_mangle)] +pub extern "C" fn interested_events(_ptr: i32, _len: i32) { + set_response(b"[]"); +}