examples: add media-stats-ui plugin
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I7c9ccac175440d278fd129dbd53f04d66a6a6964
This commit is contained in:
parent
cf76d42c33
commit
119f6d2e06
6 changed files with 466 additions and 0 deletions
48
examples/plugins/media-stats-ui/Cargo.lock
generated
Normal file
48
examples/plugins/media-stats-ui/Cargo.lock
generated
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
20
examples/plugins/media-stats-ui/Cargo.toml
Normal file
20
examples/plugins/media-stats-ui/Cargo.toml
Normal file
|
|
@ -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
|
||||||
132
examples/plugins/media-stats-ui/pages/stats.json
Normal file
132
examples/plugins/media-stats-ui/pages/stats.json
Normal file
|
|
@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
examples/plugins/media-stats-ui/pages/tag-manager.json
Normal file
126
examples/plugins/media-stats-ui/pages/tag-manager.json
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
examples/plugins/media-stats-ui/plugin.toml
Normal file
39
examples/plugins/media-stats-ui/plugin.toml
Normal file
|
|
@ -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"
|
||||||
101
examples/plugins/media-stats-ui/src/lib.rs
Normal file
101
examples/plugins/media-stats-ui/src/lib.rs
Normal file
|
|
@ -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"[]");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue